Merge branch 'stable-2.14' into stable-2.15

* stable-2.14:
  Set version to 2.14.18
  Revert "Fix the missing DB entry in Gerrit DB"
  Upgrade JGit to 4.7.7.201812240805-r
  maven_jar: Add repo.eclipse.org to supported repositories
  Upgrade JGit to 4.5.5.201812240535-r

Change-Id: I69dca6c1f942de80b3c77c125b561ae33de8abaa
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/.mailmap b/.mailmap
index bd4d222..0c691cf 100644
--- a/.mailmap
+++ b/.mailmap
@@ -35,6 +35,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 +66,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/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..a407856 100644
--- a/Documentation/cmd-review.txt
+++ b/Documentation/cmd-review.txt
@@ -109,16 +109,6 @@
 	(option is mutually exclusive with --abandon, --publish --delete, --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 +120,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.
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..37af110 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,33 @@
 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]
+
+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]
+
+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 a7822e6..7785935 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
@@ -2726,7 +2826,7 @@
 simultaneous writes that may cause one of the writes to not be reflected in the
 index. The check to avoid this does consume some resources.
 +
-Defaults to true.
+Defaults to false.
 
 ==== Lucene configuration
 
@@ -2795,7 +2895,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
@@ -2832,6 +2932,22 @@
 +
 Defaults to `30000 ms`.
 
+[[elasticsearch.numberOfShards]]elasticsearch.numberOfShards::
++
+Sets the number of shards to use per index. Refer to the
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/_basic_concepts.html#getting-started-shards-and-replicas[
+Elasticsearch documentation] for details.
++
+Defaults to 5.
+
+[[elasticsearch.numberOfReplicas]]elasticsearch.numberOfReplicas::
++
+Sets the number of replicas to use per index. Refer to the
+link:https://www.elastic.co/guide/en/elasticsearch/reference/current/_basic_concepts.html#getting-started-shards-and-replicas[
+Elasticsearch documentation] for details.
++
+Defaults to 1.
+
 ==== Elasticsearch Security
 
 When security is enabled in Elasticsearch, the username and password must be provided.
@@ -2839,11 +2955,11 @@
 
 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]
 
 [[elasticsearch.username]]elasticsearch.username::
 +
@@ -3302,8 +3418,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
@@ -3439,10 +3588,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.
@@ -3489,6 +3637,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.
@@ -3612,7 +3769,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::
 +
@@ -3948,15 +4108,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.
 
@@ -4064,6 +4234,10 @@
 are queued and serviced in a first-come-first-served order.
 +
 By default, 2x the number of CPUs available to the JVM.
++
+[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::
 +
@@ -4082,6 +4256,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::
 +
@@ -4515,6 +4693,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
 
@@ -4579,6 +4795,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
 
@@ -4656,6 +4887,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-plugins.txt b/Documentation/config-plugins.txt
index 59287a4..98fa50e 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]
 
 [[imagare]]
@@ -298,7 +268,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] |
@@ -326,7 +296,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]
@@ -342,7 +312,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] |
@@ -354,7 +324,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]
@@ -364,17 +334,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]
@@ -384,7 +364,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]
@@ -397,7 +377,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] |
@@ -412,7 +392,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]
@@ -423,7 +403,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] |
@@ -435,7 +415,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]]
@@ -443,7 +423,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]]
@@ -451,7 +431,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]]
@@ -463,7 +443,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] |
@@ -486,7 +466,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]]
@@ -498,7 +478,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] |
@@ -514,7 +494,7 @@
 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]
@@ -527,7 +507,7 @@
 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] |
@@ -542,7 +522,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]
@@ -552,19 +532,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] |
@@ -580,7 +570,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] |
@@ -592,7 +582,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]
@@ -602,7 +592,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]
@@ -616,7 +606,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]]
@@ -628,7 +618,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]]
@@ -641,7 +631,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] |
@@ -657,7 +647,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] |
@@ -671,7 +661,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] |
@@ -686,37 +676,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..016c846 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
 
@@ -219,6 +250,12 @@
 values are 'fast forward only', 'merge if necessary', 'rebase if necessary',
 '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 129071e..2e7cb23 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -35,7 +35,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
@@ -229,7 +229,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-contributing.txt b/Documentation/dev-contributing.txt
index eab3618..30b8126 100644
--- a/Documentation/dev-contributing.txt
+++ b/Documentation/dev-contributing.txt
@@ -147,6 +147,17 @@
 [[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 +353,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 39fa333..6e39502 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
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 43be550..534f0a8 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
@@ -477,10 +485,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 +503,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 +738,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 +1339,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 +1351,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 +2345,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 +2587,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].
 
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..4101349 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,7 +258,7 @@
 ==== 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.
 
 * Push the commits done on `stable-2.5` to `refs/for/stable-2.5` and
@@ -330,7 +305,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 +326,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 +348,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 +368,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-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..d448241 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,132 @@
   $ 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 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 +735,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 +813,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 +827,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..aa0103a
--- /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[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..4a81bcb4 100644
--- a/Documentation/project-configuration.txt
+++ b/Documentation/project-configuration.txt
@@ -70,7 +70,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,
@@ -224,6 +225,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..dc45ff5 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
 ----
 
@@ -1248,6 +1251,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 +1260,6 @@
         "name": "Changes"
       },
       {
-        "url": "#/q/owner:self+is:draft",
-        "name": "Drafts"
-      },
-      {
         "url": "#/q/has:draft",
         "name": "Draft Comments"
       },
@@ -1313,10 +1314,6 @@
         "name": "Changes"
       },
       {
-        "url": "#/q/owner:self+is:draft",
-        "name": "Drafts"
-      },
-      {
         "url": "#/q/has:draft",
         "name": "Draft Comments"
       },
@@ -1361,6 +1358,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 +1367,6 @@
         "name": "Changes"
       },
       {
-        "url": "#/q/owner:self+is:draft",
-        "name": "Drafts"
-      },
-      {
         "url": "#/q/has:draft",
         "name": "Draft Comments"
       },
@@ -1759,9 +1754,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 +2121,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 +2647,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..ec2500d 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
 --
@@ -4341,6 +4832,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 +5211,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 +5389,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 +5548,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 +5624,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 +5644,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 +5691,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 +5700,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 +5725,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 +5761,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 +5796,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 +5822,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 +5880,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 +5927,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 +5975,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 +6064,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 +6179,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 +6264,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 +6276,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 +6395,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 +6494,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 +6542,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 +6604,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 +6612,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 +6698,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 +6709,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 +6733,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 +6781,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 +6829,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 +6872,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 +7060,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 +7095,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..fbf78a3 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,11 @@
 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' exactly.
+
 [[ref]]
 ref:'REF'::
 +
@@ -344,10 +353,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 +384,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 +542,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..1ca01d5 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
 
diff --git a/WORKSPACE b/WORKSPACE
index cabe11e..6e168a9 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -201,8 +201,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(
@@ -239,8 +239,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.7"
@@ -301,8 +301,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(
@@ -313,8 +313,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(
@@ -349,8 +349,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(
@@ -361,8 +361,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(
@@ -554,17 +554,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(
@@ -610,15 +610,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(
@@ -633,6 +633,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(
@@ -687,23 +690,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",
 )
 
@@ -892,8 +896,8 @@
 # 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:6.5.4",
+    sha1 = "552175b06e34df96f114d1c8aaa908e535c8f1be",
 )
 
 JACKSON_VERSION = "2.9.8"
@@ -904,10 +908,18 @@
     sha1 = "0f5a654e4675769c716e5b387830d19b501ca191",
 )
 
+TESTCONTAINERS_VERSION = "1.10.3"
+
 maven_jar(
     name = "testcontainers",
-    artifact = "org.testcontainers:testcontainers:1.8.0",
-    sha1 = "bc413912f7044f9f12aa0782853aef0a067ee52a",
+    artifact = "org.testcontainers:testcontainers:" + TESTCONTAINERS_VERSION,
+    sha1 = "e561ce99fc616b383d85f35ce881e58e8de59ae7",
+)
+
+maven_jar(
+    name = "testcontainers-elasticsearch",
+    artifact = "org.testcontainers:elasticsearch:" + TESTCONTAINERS_VERSION,
+    sha1 = "0cb114ecba0ed54a116e2be2f031bc45ca4cbfc8",
 )
 
 maven_jar(
@@ -948,8 +960,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(
@@ -969,8 +981,8 @@
 bower_archive(
     name = "iron-dropdown",
     package = "polymerelements/iron-dropdown",
-    sha1 = "63e3d669a09edaa31c4f05afc76b53b919ef0595",
-    version = "1.4.0",
+    sha1 = "ac96fe31cdf203a63426fa75131b43c98c0597d3",
+    version = "1.5.5",
 )
 
 bower_archive(
@@ -983,15 +995,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(
@@ -1009,6 +1049,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",
@@ -1016,6 +1070,13 @@
 )
 
 bower_archive(
+    name = "polymer-resin",
+    package = "polymer/polymer-resin",
+    sha1 = "93ac118f2b9209cfbfd6dc8022d9492743d17f24",
+    version = "1.2.7",
+)
+
+bower_archive(
     name = "promise-polyfill",
     package = "polymerlabs/promise-polyfill",
     sha1 = "a3b598c06cbd7f441402e666ff748326030905d6",
@@ -1041,8 +1102,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 73216d9..667c37b 100644
--- a/gerrit-acceptance-framework/BUILD
+++ b/gerrit-acceptance-framework/BUILD
@@ -1,16 +1,25 @@
 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",
@@ -32,15 +41,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",
@@ -74,3 +83,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 1b78dd1..7773d89 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.18</version>
+  <version>2.15.8-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..3d81179 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,35 @@
 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.Provider;
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
@@ -159,103 +172,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 +201,62 @@
         }
       };
 
+  @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;
+
+  @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 +269,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 +287,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 +305,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 +314,7 @@
   }
 
   protected void beforeTest(Description description) throws Exception {
+    this.description = description;
     GerritServer.Description classDesc =
         GerritServer.Description.forTestClass(description, configName);
     GerritServer.Description methodDesc =
@@ -345,11 +331,27 @@
     }
 
     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 +360,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 +389,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 +404,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 +491,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 +525,7 @@
       server.close();
       server = null;
     }
+    NoteDbMode.resetFromEnv(notesMigration);
   }
 
   protected TestRepository<?>.CommitBuilder commitBuilder() throws Exception {
@@ -589,8 +600,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 +722,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 +735,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 +768,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 +801,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 +867,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 +912,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 +971,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 +985,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 +1055,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 +1081,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 +1113,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 +1132,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 +1169,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 +1204,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 +1285,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 +1302,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 +1335,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 +1415,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..af82910
--- /dev/null
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AbstractNotificationTest.java
@@ -0,0 +1,528 @@
+// 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..d60463f 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,12 +27,13 @@
 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;
 import com.google.gerrit.server.util.SocketUtil;
 import com.google.gerrit.server.util.SystemLog;
+import com.google.gerrit.testutil.FakeAuditService;
 import com.google.gerrit.testutil.FakeEmailSender;
 import com.google.gerrit.testutil.NoteDbChecker;
 import com.google.gerrit.testutil.NoteDbMode;
@@ -46,7 +48,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 +58,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 +81,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 +96,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 +131,11 @@
 
     abstract boolean sandboxed();
 
-    abstract boolean useSsh();
+    abstract boolean useSshAnnotation();
+
+    boolean useSsh() {
+      return useSshAnnotation() && SshMode.useSsh();
+    }
 
     @Nullable
     abstract GerritConfig config();
@@ -172,6 +182,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.
    *
@@ -233,7 +258,7 @@
       if (!desc.memory()) {
         init(desc, baseConfig, site);
       }
-      return start(desc, baseConfig, site);
+      return start(desc, baseConfig, site, null);
     } catch (Exception e) {
       TempFileUtil.recursivelyDelete(site.toFile());
       throw e;
@@ -247,36 +272,45 @@
    * @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.setAuditEventModuleForTesting(new FakeAuditService.Module());
+    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 +323,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 +334,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 +388,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 +399,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 bd7cbb7..ebc7c9b 100644
--- a/gerrit-acceptance-tests/BUILD
+++ b/gerrit-acceptance-tests/BUILD
@@ -1,14 +1,15 @@
+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",
@@ -18,9 +19,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..e5b7cfc 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,50 @@
 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 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.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 +78,48 @@
 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.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 +127,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 +154,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 +220,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 +238,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 +245,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 +253,114 @@
   }
 
   @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));
+  }
+
+  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 +368,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 +378,7 @@
 
     info = gApi.accounts().id("self").get();
     assertUser(info, admin);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -202,8 +386,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 +418,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 +463,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 +482,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 +509,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 +537,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 +560,7 @@
     List<Message> messages = sender.getMessages();
     assertThat(messages).hasSize(1);
     assertThat(messages.get(0).rcpt()).containsExactly(user2.emailAddress);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -360,6 +581,7 @@
     Message message = messages.get(0);
     assertThat(message.rcpt()).containsExactly(user.emailAddress);
     assertMailReplyTo(message, admin.email);
+    accountIndexedCounter.assertNoReindex();
   }
 
   @Test
@@ -374,24 +596,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 +628,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,20 +638,54 @@
         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 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);
@@ -452,7 +700,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 +710,7 @@
     assertThat(getEmails()).contains(email);
 
     gApi.accounts().self().deleteEmail(email);
+    accountIndexedCounter.assertReindexOf(admin);
 
     resetCurrentApiUser();
     assertThat(getEmails()).doesNotContain(email);
@@ -476,6 +726,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 +734,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 +804,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 +850,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 +862,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 +879,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 +890,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 +911,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 +1168,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,6 +1189,339 @@
   }
 
   @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();
@@ -709,7 +1557,8 @@
   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());
@@ -730,6 +1579,7 @@
     }
     gApi.accounts().self().putGpgKeys(toAdd, ImmutableList.<String>of());
     assertKeys(keys);
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   @Test
@@ -741,6 +1591,7 @@
     assertKeys(key);
 
     gApi.accounts().self().gpgKey(id).delete();
+    accountIndexedCounter.assertReindexOf(admin);
     assertKeys();
 
     exception.expect(ResourceNotFoundException.class);
@@ -765,6 +1616,7 @@
                 ImmutableList.of(key5.getKeyIdString()));
     assertThat(infos.keySet()).containsExactly(key1.getKeyIdString(), key2.getKeyIdString());
     assertKeys(key1, key2);
+    accountIndexedCounter.assertReindexOf(admin);
 
     infos =
         gApi.accounts()
@@ -776,6 +1628,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()));
@@ -806,6 +1659,7 @@
     SshKeyInfo key = info.get(0);
     String inital = AccountCreator.publicKey(admin.sshKey, admin.email);
     assertThat(key.sshPublicKey).isEqualTo(inital);
+    accountIndexedCounter.assertNoReindex();
 
     // Add a new key
     String newKey = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
@@ -813,12 +1667,14 @@
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(admin);
 
     // Add an existing key (the request succeeds, but the key isn't added again)
     gApi.accounts().self().addSshKey(inital);
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(2);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertNoReindex();
 
     // Add another new key
     String newKey2 = AccountCreator.publicKey(AccountCreator.genSshKey(), admin.email);
@@ -826,6 +1682,7 @@
     info = gApi.accounts().self().listSshKeys();
     assertThat(info).hasSize(3);
     assertSequenceNumbers(info);
+    accountIndexedCounter.assertReindexOf(admin);
 
     // Delete second key
     gApi.accounts().self().deleteSshKey(2);
@@ -833,6 +1690,7 @@
     assertThat(info).hasSize(2);
     assertThat(info.get(0).seq).isEqualTo(1);
     assertThat(info.get(1).seq).isEqualTo(3);
+    accountIndexedCounter.assertReindexOf(admin);
   }
 
   // reindex is tested by {@link AbstractQueryAccountsTest#reindex}
@@ -841,18 +1699,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 +1837,38 @@
     }
   }
 
+  @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 {
+    String name = name("test");
+    gApi.accounts().create(name);
+    AuthRequest who = AuthRequest.forUser(name);
+    accountManager.authenticate(who);
+    assertThat(gApi.accounts().id(name).get().name).isEqualTo(name);
+    who.setDisplayName("Something Else");
+    accountManager.authenticate(who);
+    assertThat(gApi.accounts().id(name).get().name).isEqualTo("Something Else");
+  }
+
+  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 +1933,11 @@
     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.
@@ -973,12 +1962,16 @@
   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());
+    Map<String, GpgKeyInfo> gpgKeys =
+        gApi.accounts().self().putGpgKeys(ImmutableList.of(armored), ImmutableList.<String>of());
+    accountIndexedCounter.assertReindexOf(gApi.accounts().self().get());
+    return gpgKeys;
   }
 
   private void assertUser(AccountInfo info, TestAccount account) throws Exception {
@@ -996,4 +1989,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 56c85d1..1ee3582 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;
@@ -93,7 +122,6 @@
 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.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -103,13 +131,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 +147,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 +177,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 +195,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 +232,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 +728,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 +748,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 +839,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");
@@ -418,7 +913,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 +922,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 +976,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 +1012,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 +1034,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 +1046,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 +1062,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 +1094,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 +1121,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 +1372,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 +1449,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 +1496,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 +1520,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 +1603,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 +1671,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 +1715,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 +1757,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 +1835,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 +1901,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 +2081,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 +2199,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 +2375,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 +2401,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 +2429,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 +2484,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 +2545,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 +2588,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 +2619,6 @@
     gApi.changes().id(r1.getChangeId()).revision(r1.getCommit().name()).submit();
 
     createChange();
-    createDraftChange();
 
     setApiUser(user);
     AcceptanceTestRequestScope.Context ctx = disableDb();
@@ -1922,9 +2628,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 +2643,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 +2653,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 +2668,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 +2770,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 +2779,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 +2823,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 +2841,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 +2868,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 +2905,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 +2916,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 +2953,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 +3101,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 +3111,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 +3131,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 +3152,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 +3160,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 +3238,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 +3636,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()
@@ -2675,7 +3655,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();
     }
@@ -2705,4 +3685,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..28933ad 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;
@@ -336,13 +338,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 {
@@ -533,7 +529,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/CommitIncludedInIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java
new file mode 100644
index 0000000..7768916
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/project/CommitIncludedInIT.java
@@ -0,0 +1,64 @@
+// 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 com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.NoHttpd;
+import com.google.gerrit.acceptance.PushOneCommit.Result;
+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;
+
+@NoHttpd
+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 {
+    return gApi.projects().name(project.get()).commit(id.name()).includedIn();
+  }
+}
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 6447044..e38958c 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,20 +15,21 @@
 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 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;
@@ -37,34 +38,45 @@
 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 +86,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 +107,7 @@
 public class RevisionIT extends AbstractDaemonTest {
 
   @Inject private GetRevisionActions getRevisionActions;
+  @Inject private DynamicSet<PatchSetWebLink> patchSetLinks;
 
   @Test
   public void reviewTriplet() throws Exception {
@@ -148,8 +160,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 +187,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 {
@@ -213,7 +224,7 @@
             .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();
@@ -227,7 +238,7 @@
     ApprovalInfo cr =
         gApi.changes()
             .id(changeId)
-            .get(EnumSet.of(ListChangesOption.DETAILED_LABELS))
+            .get(DETAILED_LABELS)
             .labels
             .get("Code-Review")
             .all
@@ -282,9 +293,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
@@ -299,8 +313,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;
@@ -314,8 +329,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);
 
@@ -326,6 +343,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();
@@ -341,6 +380,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();
@@ -589,6 +641,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");
@@ -687,60 +935,86 @@
   }
 
   @Test
-  public void diff() throws Exception {
-    PushOneCommit.Result r = createChange();
-    assertDiffForNewFile(r, FILE_NAME, FILE_CONTENT);
-    assertDiffForNewFile(r, COMMIT_MSG, r.getCommit().getFullMessage());
+  public void listFilesOnDifferentBases() throws Exception {
+    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");
   }
 
   @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();
-  }
+  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();
 
-  @Test
-  public void diffOnMergeCommitChange() throws Exception {
-    PushOneCommit.Result r = createMergeCommitChange("refs/for/master");
-
-    DiffInfo diff;
-
-    // automerge
-    diff = gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).file("foo").diff();
-    assertThat(diff.metaA.lines).isEqualTo(5);
-    assertThat(diff.metaB.lines).isEqualTo(1);
-
-    diff = gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).file("bar").diff();
-    assertThat(diff.metaA.lines).isEqualTo(5);
-    assertThat(diff.metaB.lines).isEqualTo(1);
-
-    // parent 1
-    diff = gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).file("bar").diff(1);
-    assertThat(diff.metaA.lines).isEqualTo(1);
-    assertThat(diff.metaB.lines).isEqualTo(1);
-
-    // parent 2
-    diff = gApi.changes().id(r.getChangeId()).revision(r.getCommit().name()).file("foo").diff(2);
-    assertThat(diff.metaA.lines).isEqualTo(1);
-    assertThat(diff.metaB.lines).isEqualTo(1);
+    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
@@ -768,6 +1042,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);
@@ -867,6 +1182,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());
@@ -978,10 +1313,20 @@
     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()));
   }
 
+  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 =
@@ -990,11 +1335,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();
   }
@@ -1006,59 +1346,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());
@@ -1094,7 +1381,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..c440d90 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,22 @@
       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..814bd06 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
@@ -25,6 +25,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;
@@ -50,6 +51,7 @@
 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 +163,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
@@ -671,7 +690,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);
@@ -802,16 +821,18 @@
 
   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..e053d13 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,45 +15,66 @@
 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.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.git.ProjectConfig;
+import com.google.gerrit.server.git.receive.ReceiveConstants;
+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;
@@ -61,20 +82,27 @@
 import com.google.gerrit.testutil.TestTimeUtil;
 import java.util.ArrayList;
 import java.util.Collection;
+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.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;
@@ -108,7 +136,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 +187,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 +312,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 +357,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 +383,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 +449,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 +461,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 +494,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 +526,139 @@
   }
 
   @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
+    // Pushing a new patch set without --wip doesn't remove the wip flag from the change.
     r = amendChange(r.getChangeId(), "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(r.getChangeId(), "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(r.getChangeId(), "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(r.getChangeId(), "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(r.getChangeId(), "refs/for/master%wip,ready");
+    r.assertErrorStatus();
+  }
+
+  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 +678,9 @@
     r.assertMessage(
         "Updated Changes:\n  "
             + canonicalWebUrl.get()
+            + "#/c/"
+            + project.get()
+            + "/+/"
             + r.getChange().getId()
             + " "
             + editInfo.commit.subject
@@ -452,7 +715,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 +739,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 +972,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
@@ -849,7 +1153,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 +1170,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,7 +1292,7 @@
 
   private void testPushWithoutChangeId() throws Exception {
     RevCommit c = createCommit(testRepo, "Message without Change-Id");
-    assertThat(GitUtil.getChangeId(testRepo, c).isPresent()).isFalse();
+    assertThat(GitUtil.getChangeId(testRepo, c)).isEmpty();
     pushForReviewRejected(testRepo, "missing Change-Id in commit message footer");
 
     setRequireChangeId(InheritableBoolean.FALSE);
@@ -1198,7 +1505,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 +1601,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 +1700,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 +1725,275 @@
     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 {
+    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());
+  }
+
+  @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 +2032,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 +2066,6 @@
       testRepo.getRevWalk().parseBody(c);
       commits.add(c);
     }
-    assertPushOk(pushHead(testRepo, refsFor, false), refsFor);
     return commits;
   }
 
@@ -1553,4 +2130,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..f6d4277
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/git/GitOverHttpServletIT.java
@@ -0,0 +1,68 @@
+// 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 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;
+
+public class GitOverHttpServletIT extends AbstractPushForReview {
+
+  @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/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 6e54ae1..6dad2c6 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);
@@ -76,4 +81,106 @@
           .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 5326e48..ccd73d0 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
@@ -7,6 +7,7 @@
     ),
     group = "pgm",
     labels = ["pgm"],
+    vm_args = ["-Xmx512m"],
     deps = [":util"],
 )
 
@@ -21,12 +22,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..3d59ad3 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_5);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV7() {
+    return getConfig(ElasticVersion.V7_0);
   }
 
   @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..0f8308c 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()).isEqualTo(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..ba37248d 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;
@@ -30,24 +32,29 @@
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 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.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.MergeInput;
+import com.google.gerrit.extensions.common.RevisionInfo;
 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.Iterator;
 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 +66,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);
@@ -152,7 +154,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 +211,40 @@
   }
 
   @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 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
@@ -352,6 +378,60 @@
     assertCreateSucceeds(in);
   }
 
+  @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 ChangeInput newChangeInput(ChangeStatus status) {
     ChangeInput in = new ChangeInput();
     in.project = project.get();
@@ -369,10 +449,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 +465,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 +499,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 +535,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 da7d7b5..0000000
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/DraftChangeIT.java
+++ /dev/null
@@ -1,322 +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 b4fef08..cb0d768ef 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()
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 f05ecce..7cd9584 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;
@@ -40,7 +41,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 3266be8..fbe5d80 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
@@ -6,6 +6,7 @@
     labels = ["rest"],
     deps = [
         ":project",
+        ":push_tag_util",
         ":refassert",
     ],
 )
@@ -35,3 +36,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/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..8bfb646 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,10 +22,14 @@
 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;
@@ -38,6 +42,7 @@
 import org.junit.Test;
 
 @NoHttpd
+@Sandboxed
 public class ListProjectsIT extends AbstractDaemonTest {
 
   @Inject private AllUsersName allUsers;
@@ -190,6 +195,35 @@
         .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..6fc9929 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
@@ -380,7 +403,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));
@@ -739,6 +762,282 @@
     }
   }
 
+  @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);
@@ -803,10 +1102,18 @@
     return gApi.changes().id(changeId).revision(revId).drafts();
   }
 
+  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();
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..d4019ec34 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
@@ -64,8 +64,6 @@
     System.setProperty("user.timezone", systemTimeZone);
   }
 
-  @Inject private BatchUpdate.Factory updateFactory;
-
   @Inject private ChangesCollection changes;
 
   @Test
@@ -565,9 +563,10 @@
     return Iterables.getOnlyElement(queryProvider.get().byCommit(c));
   }
 
-  private static ChangeAndCommit changeAndCommit(
+  private ChangeAndCommit changeAndCommit(
       PatchSet.Id psId, ObjectId commitId, int currentRevisionNum) {
     ChangeAndCommit result = new ChangeAndCommit();
+    result.project = project.get();
     result._changeNumber = psId.getParentKey().get();
     result.commit = new CommitInfo();
     result.commit.commit = commitId.name();
@@ -577,8 +576,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 +585,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;
             }
           });
@@ -601,6 +600,7 @@
       String name = "index " + i + " related to " + psId;
       ChangeAndCommit a = actual.get(i);
       ChangeAndCommit 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..79536a7 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,97 @@
     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 +1469,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 +1495,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 +1520,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..f7204f3
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/NoteDbOnlyIT.java
@@ -0,0 +1,327 @@
+// 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 1d2f0e0..d81bf6b 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
@@ -274,7 +274,7 @@
               .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..388c240
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/OnlineNoteDbMigrationIT.java
@@ -0,0 +1,635 @@
+// 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.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 set both changes and projects", b -> b.setChanges(cs).setProjects(ps), m -> {});
+    assertMigrationException(
+        "Cannot set changes or projects during full migration",
+        b -> b.setChanges(cs),
+        NoteDbMigrator::migrate);
+    assertMigrationException(
+        "Cannot set changes or projects during full migration",
+        b -> b.setProjects(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();
+
+    try (ReviewDb db = schemaFactory.open()) {
+      Change c1 = db.changes().get(id1);
+      c1.setNoteDbState(INVALID_STATE);
+      Change c2 = db.changes().get(id2);
+      c2.setNoteDbState(INVALID_STATE);
+      db.changes().update(ImmutableList.of(c1, c2));
+    }
+
+    migrate(b -> b.setChanges(ImmutableList.of(id2)), NoteDbMigrator::rebuild);
+
+    try (ReviewDb db = schemaFactory.open()) {
+      NoteDbChangeState s1 = NoteDbChangeState.parse(db.changes().get(id1));
+      assertThat(s1.getChangeMetaId().name()).isEqualTo(INVALID_STATE);
+
+      NoteDbChangeState s2 = NoteDbChangeState.parse(db.changes().get(id2));
+      assertThat(s2.getChangeMetaId().name()).isNotEqualTo(INVALID_STATE);
+    }
+  }
+
+  @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();
+
+    String invalidState = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
+    try (ReviewDb db = schemaFactory.open()) {
+      Change c1 = db.changes().get(id1);
+      c1.setNoteDbState(invalidState);
+      Change c2 = db.changes().get(id2);
+      c2.setNoteDbState(invalidState);
+      db.changes().update(ImmutableList.of(c1, c2));
+    }
+
+    migrate(b -> b.setProjects(ImmutableList.of(p2)), NoteDbMigrator::rebuild);
+
+    try (ReviewDb db = schemaFactory.open()) {
+      NoteDbChangeState s1 = NoteDbChangeState.parse(db.changes().get(id1));
+      assertThat(s1.getChangeMetaId().name()).isEqualTo(invalidState);
+
+      NoteDbChangeState s2 = NoteDbChangeState.parse(db.changes().get(id2));
+      assertThat(s2.getChangeMetaId().name()).isNotEqualTo(invalidState);
+    }
+  }
+
+  @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);
+    } 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 c6d2299..3a8b677 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
@@ -31,6 +31,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/ElasticIndexIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/ssh/ElasticIndexIT.java
index 18ad621..cf8503d 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_5);
+  }
+
+  @ConfigSuite.Config
+  public static Config elasticsearchV7() {
+    return getConfig(ElasticVersion.V7_0);
   }
 
   @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 80e3e80..0000000
--- a/gerrit-antlr/BUILD
+++ /dev/null
@@ -1,35 +0,0 @@
-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 c2bafad..5936775 100644
--- a/gerrit-elasticsearch/BUILD
+++ b/gerrit-elasticsearch/BUILD
@@ -2,15 +2,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",
@@ -33,16 +31,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",
     ],
 )
 
@@ -64,12 +68,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",
@@ -77,14 +81,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],
@@ -100,6 +96,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..c21299f 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);
@@ -174,7 +176,7 @@
   protected abstract String getMappings();
 
   private String getSettings() {
-    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting()));
+    return gson.toJson(ImmutableMap.of(SETTINGS, ElasticSetting.createSetting(config)));
   }
 
   protected abstract String getId(V v);
@@ -224,8 +226,11 @@
   }
 
   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());
+    if (SEARCH.equals(request) && client.adapter().omitTypeFromSearch()) {
+      return encodedIndexName + "/" + request;
+    }
+    String encodedType = URLEncoder.encode(type, UTF_8.toString());
     return encodedIndexName + "/" + encodedType + "/" + request;
   }
 
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..264822e 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,7 +140,7 @@
     ElasticQueryAdapter adapter = client.adapter();
     BulkRequest bulk =
         new IndexRequest(getId(cd), indexName, adapter.getType(insertIndex), adapter)
-            .add(new UpdateRequest<>(fillArgs, schema, cd));
+            .add(new UpdateRequest<>(schema, cd));
     if (!adapter.usePostV5Type()) {
       bulk.add(new DeleteRequest(cd.getId().toString(), indexName, deleteIndex, adapter));
     }
@@ -282,10 +280,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 +364,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(),
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..cd74567 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
@@ -41,9 +41,13 @@
   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 int DEFAULT_NUMBER_OF_SHARDS = 5;
+  static final int DEFAULT_NUMBER_OF_REPLICAS = 1;
   static final TimeUnit MAX_RETRY_TIMEOUT_UNIT = TimeUnit.MILLISECONDS;
 
   private final Config cfg;
@@ -52,6 +56,8 @@
   final String username;
   final String password;
   final int maxRetryTimeout;
+  final int numberOfShards;
+  final int numberOfReplicas;
   final String prefix;
 
   @Inject
@@ -72,6 +78,10 @@
                 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 {
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..0c28dd1 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
@@ -21,6 +21,7 @@
 
   private final boolean ignoreUnmapped;
   private final boolean usePostV5Type;
+  private final boolean omitTypeFromSearch;
 
   private final String searchFilteringName;
   private final String indicesExistParam;
@@ -30,30 +31,15 @@
   private final String versionDiscoveryUrl;
 
   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;
-    }
+    this.ignoreUnmapped = false;
+    this.usePostV5Type = version.isV6OrLater();
+    this.omitTypeFromSearch = version.isV7OrLater();
+    this.versionDiscoveryUrl = version.isV6OrLater() ? "/%s*" : "/%s*/_aliases";
+    this.searchFilteringName = "_source";
+    this.indicesExistParam = "?allow_no_indices=false";
+    this.exactFieldType = "keyword";
+    this.stringFieldType = "text";
+    this.indexProperty = "true";
   }
 
   void setIgnoreUnmapped(JsonObject properties) {
@@ -92,8 +78,12 @@
     return usePostV5Type;
   }
 
-  String getType(String preV6Type) {
-    return usePostV5Type() ? POST_V5_TYPE : preV6Type;
+  boolean omitTypeFromSearch() {
+    return omitTypeFromSearch;
+  }
+
+  String getType(String type) {
+    return usePostV5Type() ? POST_V5_TYPE : type;
   }
 
   String getVersionDiscoveryUrl(String name) {
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/ElasticSetting.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticSetting.java
index 6fd234d..98c313c 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) {
+    return new ElasticSetting.Builder().addCharFilter().addAnalyzer().build(config);
   }
 
   static class Builder {
     private final ImmutableMap.Builder<String, FieldProperties> fields =
         new ImmutableMap.Builder<>();
 
-    SettingProperties build() {
+    SettingProperties build(ElasticConfiguration config) {
       SettingProperties properties = new SettingProperties();
       properties.analysis = fields.build();
+      properties.numberOfShards = config.numberOfShards;
+      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..b69f8f9 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,12 @@
 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.*"),
+  V7_0("7.0.*");
 
   private final String version;
   private final Pattern pattern;
@@ -55,8 +56,16 @@
     return Joiner.on(", ").join(ElasticVersion.values());
   }
 
-  public boolean isV6() {
-    return version.startsWith("6.");
+  public boolean isV6OrLater() {
+    return isAtLeastVersion(6);
+  }
+
+  public boolean isV7OrLater() {
+    return isAtLeastVersion(7);
+  }
+
+  private boolean isAtLeastVersion(int v) {
+    return Integer.valueOf(version.split("\\.")[0]) >= v;
   }
 
   @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 0b6e3ee..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.13";
-      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/ElasticContainer.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.java
new file mode 100644
index 0000000..b68e866
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticContainer.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 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 "docker.elastic.co/elasticsearch/elasticsearch:5.6.14";
+      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";
+      case V6_5:
+        return "docker.elastic.co/elasticsearch/elasticsearch-oss:6.5.4";
+      case V7_0:
+        return "docker.elastic.co/elasticsearch/elasticsearch-oss:7.0.0-alpha2";
+    }
+    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..c4a787f 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_5);
     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..536ba74 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_5);
     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..22186f5 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_5);
     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..8634709
--- /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_0);
+    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..f46364e
--- /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_0);
+    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..33f6666
--- /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_0);
+    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..2456535 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,12 @@
 
     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("7.0.0")).isEqualTo(ElasticVersion.V7_0);
+    assertThat(ElasticVersion.forVersion("7.0.1")).isEqualTo(ElasticVersion.V7_0);
   }
 
   @Test
@@ -51,9 +54,19 @@
 
   @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();
+    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.V7_0.isV6OrLater()).isTrue();
+  }
+
+  @Test
+  public void version7() 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.V7_0.isV7OrLater()).isTrue();
   }
 }
diff --git a/gerrit-extension-api/pom.xml b/gerrit-extension-api/pom.xml
index 93da8c9..2d2ecc2 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.18</version>
+  <version>2.15.8-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Extension API</name>
   <description>API for Gerrit Extensions</description>
@@ -65,6 +65,9 @@
       <name>Martin Fick</name>
     </developer>
     <developer>
+      <name>Patrick Hiesel</name>
+    </developer>
+    <developer>
       <name>Saša Živkov</name>
     </developer>
     <developer>
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..1526cc6 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,8 @@
 
   void deleteExternalIds(List<String> externalIds) throws RestApiException;
 
+  void setName(String name) throws RestApiException;
+
   /**
    * A default implementation which allows source compatibility when adding new methods to the
    * interface.
@@ -197,6 +202,11 @@
     }
 
     @Override
+    public List<GroupInfo> getGroups() throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public List<EmailInfo> getEmails() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -271,5 +281,10 @@
     public void deleteExternalIds(List<String> externalIds) throws RestApiException {
       throw new NotImplementedException();
     }
+
+    @Override
+    public void setName(String name) throws RestApiException {
+      throw new NotImplementedException();
+    }
   }
 }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/AbandonInput.java
index 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..5183a89 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;
 
@@ -221,6 +286,12 @@
 
   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 +378,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 +407,7 @@
       throw new NotImplementedException();
     }
 
+    @Deprecated
     @Override
     public void rebase() throws RestApiException {
       throw new NotImplementedException();
@@ -352,12 +439,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 +459,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 +479,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();
     }
@@ -477,5 +579,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/Changes.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/Changes.java
index d14ddfe..0708ef5 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();
@@ -153,6 +162,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..89dc269 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;
@@ -43,6 +44,7 @@
     private Integer context;
     private Boolean intraline;
     private Whitespace whitespace;
+    private OptionalInt parent = OptionalInt.empty();
 
     public abstract DiffInfo get() throws RestApiException;
 
@@ -66,6 +68,11 @@
       return this;
     }
 
+    public DiffRequest withParent(int parent) {
+      this.parent = OptionalInt.of(parent);
+      return this;
+    }
+
     public String getBase() {
       return base;
     }
@@ -81,6 +88,10 @@
     public Whitespace getWhitespace() {
       return whitespace;
     }
+
+    public OptionalInt getParent() {
+      return parent;
+    }
   }
 
   /**
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..72e762c 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
@@ -18,6 +18,7 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 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 +31,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 +48,7 @@
 
   BinaryResult submitPreview(String format) throws RestApiException;
 
+  @Deprecated
   void publish() throws RestApiException;
 
   ChangeApi cherryPick(CherryPickInput in) throws RestApiException;
@@ -68,8 +71,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 +93,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;
@@ -139,13 +157,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 +178,7 @@
       throw new NotImplementedException();
     }
 
+    @Deprecated
     @Override
     public void publish() throws RestApiException {
       throw new NotImplementedException();
@@ -225,11 +245,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 +285,11 @@
     }
 
     @Override
+    public EditInfo applyFix(String fixId) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() 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..567d9ba 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;
     }
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..6b3c1fb
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/projects/CommitApi.java
@@ -0,0 +1,41 @@
+// 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.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface CommitApi {
+
+  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 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/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..ee7d039 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
@@ -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/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/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/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..5cdf267 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
@@ -186,7 +186,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 +203,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 +213,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() {
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/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..baf5a58 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,7 +15,7 @@
 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.io.BaseEncoding;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -23,10 +23,9 @@
 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.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -42,18 +41,15 @@
   public static class Input {}
 
   private final Provider<PersonIdent> serverIdent;
-  private final Provider<ReviewDb> db;
   private final Provider<PublicKeyStore> storeProvider;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
 
   @Inject
   DeleteGpgKey(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      Provider<ReviewDb> db,
       Provider<PublicKeyStore> storeProvider,
       ExternalIdsUpdate.User externalIdsUpdateFactory) {
     this.serverIdent = serverIdent;
-    this.db = db;
     this.storeProvider = storeProvider;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
@@ -66,7 +62,6 @@
     externalIdsUpdateFactory
         .create()
         .delete(
-            db.get(),
             rsrc.getUser().getAccountId(),
             ExternalId.Key.create(
                 SCHEME_GPGKEY, BaseEncoding.base16().encode(key.getFingerprint())));
@@ -93,6 +88,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..ecff7e6 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) {
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..39e789a 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,14 +40,14 @@
 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.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
@@ -84,31 +84,31 @@
 
   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 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,
       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.accountQueryProvider = accountQueryProvider;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
   }
 
@@ -119,7 +119,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 +144,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());
     }
   }
@@ -239,6 +239,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..bda9aed 100644
--- a/gerrit-gwtui/BUILD
+++ b/gerrit-gwtui/BUILD
@@ -34,6 +34,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 b819580..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..cb3e9f0 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,14 @@
     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("status:");
     suggestions.add("status:open");
@@ -143,7 +146,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 +154,8 @@
 
     suggestions.add("unresolved:");
 
+    suggestions.add("revertof:");
+
     if (Gerrit.isNoteDbEnabled()) {
       suggestions.add("cc:");
       suggestions.add("hashtag:");
@@ -189,7 +193,7 @@
       return;
     }
 
-    for (final ParamSuggester ps : paramSuggester) {
+    for (ParamSuggester ps : paramSuggester) {
       if (ps.applicable(lastWord)) {
         ps.suggest(lastWord, request, done);
         return;
@@ -208,7 +212,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 +224,7 @@
   }
 
   @Override
-  protected String getQueryPattern(final String query) {
+  protected String getQueryPattern(String query) {
     return super.getQueryPattern(getLastWord(query));
   }
 
@@ -255,18 +259,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 +278,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 +299,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..f65f0ac 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));
     }
@@ -246,7 +246,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 d3460c7..15dd4c8 100644
--- a/gerrit-httpd/BUILD
+++ b/gerrit-httpd/BUILD
@@ -13,16 +13,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/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/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..f43c2dc 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;
     }
 
@@ -241,7 +241,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 +272,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..de549a0 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,32 @@
     }
   }
 
-  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..369ad48 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(
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 eed6ccc..0e89e1a 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}
@@ -27,19 +28,32 @@
   <meta name="description" content="Gerrit Code Review">{\n}
   <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">{\n}
 
-  {if $canonicalPath != ''}
-    <script>window.CANONICAL_PATH = '{$canonicalPath}';</script>{\n}
+  {if $canonicalPath != '' or $versionInfo}
+    <script>
+      {if $canonicalPath != ''}window.CANONICAL_PATH = '{$canonicalPath}';{/if}
+      {if $versionInfo}window.VERSION_INFO = '{$versionInfo}';{/if}
+    </script>{\n}
   {/if}
 
   <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..a55f7c4
--- /dev/null
+++ b/gerrit-index/BUILD
@@ -0,0 +1,78 @@
+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/index/query/OperatorPredicate.java
@@ -0,0 +1,70 @@
+// 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 java.util.Collection;
+
+/** Predicate to filter a field by matching value. */
+public abstract class OperatorPredicate<T> extends Predicate<T> {
+  protected final String name;
+  protected final String value;
+
+  public OperatorPredicate(String name, String value) {
+    this.name = name;
+    this.value = value;
+  }
+
+  public String getOperator() {
+    return name;
+  }
+
+  public String getValue() {
+    return value;
+  }
+
+  @Override
+  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+    if (!children.isEmpty()) {
+      throw new IllegalArgumentException("Expected 0 children");
+    }
+    return this;
+  }
+
+  @Override
+  public int hashCode() {
+    return getOperator().hashCode() * 31 + getValue().hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other == null) {
+      return false;
+    }
+    if (getClass() == other.getClass()) {
+      final OperatorPredicate<?> p = (OperatorPredicate<?>) other;
+      return getOperator().equals(p.getOperator()) && getValue().equals(p.getValue());
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    final String val = getValue();
+    if (QueryParser.isSingleWord(val)) {
+      return getOperator() + ":" + val;
+    }
+    return getOperator() + ":\"" + val + "\"";
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/OrPredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/OrPredicate.java
new file mode 100644
index 0000000..8c3ed1c
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/OrPredicate.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 one predicate to be true. */
+public class OrPredicate<T> extends Predicate<T> implements Matchable<T> {
+  private final List<Predicate<T>> children;
+  private final int cost;
+
+  @SafeVarargs
+  protected OrPredicate(Predicate<T>... that) {
+    this(Arrays.asList(that));
+  }
+
+  protected OrPredicate(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 OrPredicate<>(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 true;
+      }
+    }
+    return false;
+  }
+
+  @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(" OR ");
+      }
+      r.append(getChild(i));
+    }
+    r.append(")");
+    return r.toString();
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/Paginated.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/Paginated.java
new file mode 100644
index 0000000..20f65dc
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/Paginated.java
@@ -0,0 +1,25 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import com.google.gerrit.index.QueryOptions;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+
+public interface Paginated<T> {
+  QueryOptions getOptions();
+
+  ResultSet<T> restart(int start) throws OrmException;
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/PostFilterPredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/PostFilterPredicate.java
new file mode 100644
index 0000000..3e780bf
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/PostFilterPredicate.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;
+
+/**
+ * Matches all documents in the index, with additional filtering done in the subclass's {@code
+ * match} method.
+ */
+public abstract class PostFilterPredicate<T> extends Predicate<T> implements Matchable<T> {}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/Predicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/Predicate.java
new file mode 100644
index 0000000..ca74a52
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/Predicate.java
@@ -0,0 +1,165 @@
+// 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.common.collect.Iterables;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * An abstract predicate tree for any form of query.
+ *
+ * <p>Implementations should be immutable, such that the meaning of a predicate never changes once
+ * constructed. They should ensure their immutable promise by defensively copying any structures
+ * which might be modified externally, but was passed into the object's constructor.
+ *
+ * <p>However, implementations <i>may</i> retain non-thread-safe caches internally, to speed up
+ * evaluation operations within the context of one thread's evaluation of the predicate. As a
+ * result, callers should assume predicates are not thread-safe, but that two predicate graphs
+ * produce the same results given the same inputs if they are {@link #equals(Object)}.
+ *
+ * <p>Predicates should support deep inspection whenever possible, so that generic algorithms can be
+ * written to operate against them. Predicates which contain other predicates should override {@link
+ * #getChildren()} to return the list of children nested within the predicate.
+ *
+ * @param <T> type of object the predicate can evaluate in memory.
+ */
+public abstract class Predicate<T> {
+  /** A predicate that matches any input, always, with no cost. */
+  @SuppressWarnings("unchecked")
+  public static <T> Predicate<T> any() {
+    return (Predicate<T>) Any.INSTANCE;
+  }
+
+  /** Combine the passed predicates into a single AND node. */
+  @SafeVarargs
+  public static <T> Predicate<T> and(Predicate<T>... that) {
+    if (that.length == 1) {
+      return that[0];
+    }
+    return new AndPredicate<>(that);
+  }
+
+  /** Combine the passed predicates into a single AND node. */
+  public static <T> Predicate<T> and(Collection<? extends Predicate<T>> that) {
+    if (that.size() == 1) {
+      return Iterables.getOnlyElement(that);
+    }
+    return new AndPredicate<>(that);
+  }
+
+  /** Combine the passed predicates into a single OR node. */
+  @SafeVarargs
+  public static <T> Predicate<T> or(Predicate<T>... that) {
+    if (that.length == 1) {
+      return that[0];
+    }
+    return new OrPredicate<>(that);
+  }
+
+  /** Combine the passed predicates into a single OR node. */
+  public static <T> Predicate<T> or(Collection<? extends Predicate<T>> that) {
+    if (that.size() == 1) {
+      return Iterables.getOnlyElement(that);
+    }
+    return new OrPredicate<>(that);
+  }
+
+  /** Invert the passed node. */
+  public static <T> Predicate<T> not(Predicate<T> that) {
+    if (that instanceof NotPredicate) {
+      // Negate of a negate is the original predicate.
+      //
+      return that.getChild(0);
+    }
+    return new NotPredicate<>(that);
+  }
+
+  /** Get the children of this predicate, if any. */
+  public List<Predicate<T>> getChildren() {
+    return Collections.emptyList();
+  }
+
+  /** Same as {@code getChildren().size()} */
+  public int getChildCount() {
+    return getChildren().size();
+  }
+
+  /** Same as {@code getChildren().get(i)} */
+  public Predicate<T> getChild(int i) {
+    return getChildren().get(i);
+  }
+
+  /** Create a copy of this predicate, with new children. */
+  public abstract Predicate<T> copy(Collection<? extends Predicate<T>> children);
+
+  public boolean isMatchable() {
+    return this instanceof Matchable;
+  }
+
+  @SuppressWarnings("unchecked")
+  public Matchable<T> asMatchable() {
+    checkState(isMatchable(), "not matchable");
+    return (Matchable<T>) this;
+  }
+
+  /** @return a cost estimate to run this predicate, higher figures cost more. */
+  public int estimateCost() {
+    if (!isMatchable()) {
+      return 1;
+    }
+    return asMatchable().getCost();
+  }
+
+  @Override
+  public abstract int hashCode();
+
+  @Override
+  public abstract boolean equals(Object other);
+
+  private static class Any<T> extends Predicate<T> implements Matchable<T> {
+    private static final Any<Object> INSTANCE = new Any<>();
+
+    private Any() {}
+
+    @Override
+    public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
+      return this;
+    }
+
+    @Override
+    public boolean match(T object) {
+      return true;
+    }
+
+    @Override
+    public int getCost() {
+      return 0;
+    }
+
+    @Override
+    public int hashCode() {
+      return System.identityHashCode(this);
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      return other == this;
+    }
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryBuilder.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryBuilder.java
new file mode 100644
index 0000000..c6c39c3
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryBuilder.java
@@ -0,0 +1,350 @@
+// 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.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.index.query.Predicate.not;
+import static com.google.gerrit.index.query.Predicate.or;
+import static com.google.gerrit.index.query.QueryParser.AND;
+import static com.google.gerrit.index.query.QueryParser.DEFAULT_FIELD;
+import static com.google.gerrit.index.query.QueryParser.EXACT_PHRASE;
+import static com.google.gerrit.index.query.QueryParser.FIELD_NAME;
+import static com.google.gerrit.index.query.QueryParser.NOT;
+import static com.google.gerrit.index.query.QueryParser.OR;
+import static com.google.gerrit.index.query.QueryParser.SINGLE_WORD;
+
+import com.google.common.base.Strings;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.antlr.runtime.tree.Tree;
+
+/**
+ * Base class to support writing parsers for query languages.
+ *
+ * <p>Subclasses may document their supported query operators by declaring public methods that
+ * perform the query conversion into a {@link Predicate}. For example, to support "is:starred",
+ * "is:unread", and nothing else, a subclass may write:
+ *
+ * <pre>
+ * &#064;Operator
+ * public Predicate is(String value) {
+ *   if (&quot;starred&quot;.equals(value)) {
+ *     return new StarredPredicate();
+ *   }
+ *   if (&quot;unread&quot;.equals(value)) {
+ *     return new UnreadPredicate();
+ *   }
+ *   throw new IllegalArgumentException();
+ * }
+ * </pre>
+ *
+ * <p>The available operator methods are discovered at runtime via reflection. Method names (after
+ * being converted to lowercase), correspond to operators in the query language, method string
+ * values correspond to the operator argument. Methods must be declared {@code public}, returning
+ * {@link Predicate}, accepting one {@link String}, and annotated with the {@link Operator}
+ * annotation.
+ *
+ * <p>Subclasses may also declare a handler for values which appear without operator by overriding
+ * {@link #defaultField(String)}.
+ *
+ * @param <T> type of object the predicates can evaluate in memory.
+ */
+public abstract class QueryBuilder<T> {
+  /** Converts a value string passed to an operator into a {@link Predicate}. */
+  public interface OperatorFactory<T, Q extends QueryBuilder<T>> {
+    Predicate<T> create(Q builder, String value) throws QueryParseException;
+  }
+
+  /**
+   * Defines the operators known by a QueryBuilder.
+   *
+   * <p>This class is thread-safe and may be reused or cached.
+   *
+   * @param <T> type of object the predicates can evaluate in memory.
+   * @param <Q> type of the query builder subclass.
+   */
+  public static class Definition<T, Q extends QueryBuilder<T>> {
+    private final Map<String, OperatorFactory<T, Q>> opFactories = new HashMap<>();
+
+    public Definition(Class<Q> clazz) {
+      // Guess at the supported operators by scanning methods.
+      //
+      Class<?> c = clazz;
+      while (c != QueryBuilder.class) {
+        for (Method method : c.getDeclaredMethods()) {
+          if (method.getAnnotation(Operator.class) != null
+              && Predicate.class.isAssignableFrom(method.getReturnType())
+              && method.getParameterTypes().length == 1
+              && method.getParameterTypes()[0] == String.class
+              && (method.getModifiers() & Modifier.ABSTRACT) == 0
+              && (method.getModifiers() & Modifier.PUBLIC) == Modifier.PUBLIC) {
+            final String name = method.getName().toLowerCase();
+            if (!opFactories.containsKey(name)) {
+              opFactories.put(name, new ReflectionFactory<T, Q>(name, method));
+            }
+          }
+        }
+        c = c.getSuperclass();
+      }
+    }
+  }
+
+  /**
+   * Locate a predicate in the predicate tree.
+   *
+   * @param p the predicate to find.
+   * @param clazz type of the predicate instance.
+   * @return the predicate, null if not found.
+   */
+  @SuppressWarnings("unchecked")
+  public static <T, P extends Predicate<T>> P find(Predicate<T> p, Class<P> clazz) {
+    if (clazz.isAssignableFrom(p.getClass())) {
+      return (P) p;
+    }
+
+    for (Predicate<T> c : p.getChildren()) {
+      P r = find(c, clazz);
+      if (r != null) {
+        return r;
+      }
+    }
+
+    return null;
+  }
+
+  /**
+   * Locate a predicate in the predicate tree.
+   *
+   * @param p the predicate to find.
+   * @param clazz type of the predicate instance.
+   * @param name name of the operator.
+   * @return the first instance of a predicate having the given type, as found by a depth-first
+   *     search.
+   */
+  @SuppressWarnings("unchecked")
+  public static <T, P extends OperatorPredicate<T>> P find(
+      Predicate<T> p, Class<P> clazz, String name) {
+    if (p instanceof OperatorPredicate
+        && ((OperatorPredicate<?>) p).getOperator().equals(name)
+        && clazz.isAssignableFrom(p.getClass())) {
+      return (P) p;
+    }
+
+    for (Predicate<T> c : p.getChildren()) {
+      P r = find(c, clazz, name);
+      if (r != null) {
+        return r;
+      }
+    }
+
+    return null;
+  }
+
+  protected final Definition<T, ? extends QueryBuilder<T>> builderDef;
+
+  protected final Map<String, OperatorFactory<?, ?>> opFactories;
+
+  @SuppressWarnings({"unchecked", "rawtypes"})
+  protected QueryBuilder(Definition<T, ? extends QueryBuilder<T>> def) {
+    builderDef = def;
+    opFactories = (Map) def.opFactories;
+  }
+
+  /**
+   * Parse a user-supplied query string into a predicate.
+   *
+   * @param query the query string.
+   * @return predicate representing the user query.
+   * @throws QueryParseException the query string is invalid and cannot be parsed by this parser.
+   *     This may be due to a syntax error, may be due to an operator not being supported, or due to
+   *     an invalid value being passed to a recognized operator.
+   */
+  public Predicate<T> parse(String query) throws QueryParseException {
+    if (Strings.isNullOrEmpty(query)) {
+      throw new QueryParseException("query is empty");
+    }
+    return toPredicate(QueryParser.parse(query));
+  }
+
+  /**
+   * Parse multiple user-supplied query strings into a list of predicates.
+   *
+   * @param queries the query strings.
+   * @return predicates representing the user query, in the same order as the input.
+   * @throws QueryParseException one of the query strings is invalid and cannot be parsed by this
+   *     parser. This may be due to a syntax error, may be due to an operator not being supported,
+   *     or due to an invalid value being passed to a recognized operator.
+   */
+  public List<Predicate<T>> parse(List<String> queries) throws QueryParseException {
+    List<Predicate<T>> predicates = new ArrayList<>(queries.size());
+    for (String query : queries) {
+      predicates.add(parse(query));
+    }
+    return predicates;
+  }
+
+  private Predicate<T> toPredicate(Tree r) throws QueryParseException, IllegalArgumentException {
+    switch (r.getType()) {
+      case AND:
+        return and(children(r));
+      case OR:
+        return or(children(r));
+      case NOT:
+        return not(toPredicate(onlyChildOf(r)));
+
+      case DEFAULT_FIELD:
+        return defaultField(onlyChildOf(r));
+
+      case FIELD_NAME:
+        return operator(r.getText(), onlyChildOf(r));
+
+      default:
+        throw error("Unsupported operator: " + r);
+    }
+  }
+
+  private Predicate<T> operator(String name, Tree val) throws QueryParseException {
+    switch (val.getType()) {
+        // Expand multiple values, "foo:(a b c)", as though they were written
+        // out with the longer form, "foo:a foo:b foo:c".
+        //
+      case AND:
+      case OR:
+        {
+          List<Predicate<T>> p = new ArrayList<>(val.getChildCount());
+          for (int i = 0; i < val.getChildCount(); i++) {
+            final Tree c = val.getChild(i);
+            if (c.getType() != DEFAULT_FIELD) {
+              throw error("Nested operator not expected: " + c);
+            }
+            p.add(operator(name, onlyChildOf(c)));
+          }
+          return val.getType() == AND ? and(p) : or(p);
+        }
+
+      case SINGLE_WORD:
+      case EXACT_PHRASE:
+        if (val.getChildCount() != 0) {
+          throw error("Expected no children under: " + val);
+        }
+        return operator(name, val.getText());
+
+      default:
+        throw error("Unsupported node in operator " + name + ": " + val);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  private Predicate<T> operator(String name, String value) throws QueryParseException {
+    @SuppressWarnings("rawtypes")
+    OperatorFactory f = opFactories.get(name);
+    if (f == null) {
+      throw error("Unsupported operator " + name + ":" + value);
+    }
+    return f.create(this, value);
+  }
+
+  private Predicate<T> defaultField(Tree r) throws QueryParseException {
+    switch (r.getType()) {
+      case SINGLE_WORD:
+      case EXACT_PHRASE:
+        if (r.getChildCount() != 0) {
+          throw error("Expected no children under: " + r);
+        }
+        return defaultField(r.getText());
+
+      default:
+        throw error("Unsupported node: " + r);
+    }
+  }
+
+  /**
+   * Handle a value present outside of an operator.
+   *
+   * <p>This default implementation always throws an "Unsupported query: " message containing the
+   * input text. Subclasses may override this method to perform do-what-i-mean guesses based on the
+   * input string.
+   *
+   * @param value the value supplied by itself in the query.
+   * @return predicate representing this value.
+   * @throws QueryParseException the parser does not recognize this value.
+   */
+  protected Predicate<T> defaultField(String value) throws QueryParseException {
+    throw error("Unsupported query:" + value);
+  }
+
+  private List<Predicate<T>> children(Tree r) throws QueryParseException, IllegalArgumentException {
+    List<Predicate<T>> p = new ArrayList<>(r.getChildCount());
+    for (int i = 0; i < r.getChildCount(); i++) {
+      p.add(toPredicate(r.getChild(i)));
+    }
+    return p;
+  }
+
+  private Tree onlyChildOf(Tree r) throws QueryParseException {
+    if (r.getChildCount() != 1) {
+      throw error("Expected exactly one child: " + r);
+    }
+    return r.getChild(0);
+  }
+
+  protected static QueryParseException error(String msg) {
+    return new QueryParseException(msg);
+  }
+
+  protected static QueryParseException error(String msg, Throwable why) {
+    return new QueryParseException(msg, why);
+  }
+
+  /** Denotes a method which is a query operator. */
+  @Retention(RetentionPolicy.RUNTIME)
+  @Target(ElementType.METHOD)
+  protected @interface Operator {}
+
+  private static class ReflectionFactory<T, Q extends QueryBuilder<T>>
+      implements OperatorFactory<T, Q> {
+    private final String name;
+    private final Method method;
+
+    ReflectionFactory(String name, Method method) {
+      this.name = name;
+      this.method = method;
+    }
+
+    @SuppressWarnings("unchecked")
+    @Override
+    public Predicate<T> create(Q builder, String value) throws QueryParseException {
+      try {
+        return (Predicate<T>) method.invoke(builder, value);
+      } catch (RuntimeException | IllegalAccessException e) {
+        throw error("Error in operator " + name + ":" + value, e);
+      } catch (InvocationTargetException e) {
+        if (e.getCause() instanceof QueryParseException) {
+          throw (QueryParseException) e.getCause();
+        }
+        throw error("Error in operator " + name + ":" + value, e.getCause());
+      }
+    }
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryParseException.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryParseException.java
new file mode 100644
index 0000000..6a62b9e
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryParseException.java
@@ -0,0 +1,32 @@
+// 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;
+
+/**
+ * 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(String message) {
+    super(message);
+  }
+
+  public QueryParseException(String msg, Throwable why) {
+    super(msg, why);
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryProcessor.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryProcessor.java
new file mode 100644
index 0000000..b318199
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryProcessor.java
@@ -0,0 +1,335 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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 static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Ordering;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.SchemaDefinitions;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer1;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.gwtorm.server.ResultSet;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.IntSupplier;
+import java.util.stream.IntStream;
+
+/**
+ * Lower-level implementation for executing a single query over a secondary index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public abstract class QueryProcessor<T> {
+  protected static class Metrics {
+    final Timer1<String> executionTime;
+
+    Metrics(MetricMaker metricMaker) {
+      Field<String> index = Field.ofString("index", "index name");
+      executionTime =
+          metricMaker.newTimer(
+              "query/query_latency",
+              new Description("Successful query latency, accumulated over the life of the process")
+                  .setCumulative()
+                  .setUnit(Description.Units.MILLISECONDS),
+              index);
+    }
+  }
+
+  private final Metrics metrics;
+  private final SchemaDefinitions<T> schemaDef;
+  private final IndexConfig indexConfig;
+  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
+  private final IndexRewriter<T> rewriter;
+  private final String limitField;
+  private final IntSupplier permittedLimit;
+
+  // This class is not generally thread-safe, but programmer error may result in it being shared
+  // across threads. At least ensure the bit for checking if it's been used is threadsafe.
+  private final AtomicBoolean used;
+
+  protected int start;
+
+  private boolean enforceVisibility = true;
+  private int userProvidedLimit;
+  private Set<String> requestedFields;
+
+  protected QueryProcessor(
+      MetricMaker metricMaker,
+      SchemaDefinitions<T> schemaDef,
+      IndexConfig indexConfig,
+      IndexCollection<?, T, ? extends Index<?, T>> indexes,
+      IndexRewriter<T> rewriter,
+      String limitField,
+      IntSupplier permittedLimit) {
+    this.metrics = new Metrics(metricMaker);
+    this.schemaDef = schemaDef;
+    this.indexConfig = indexConfig;
+    this.indexes = indexes;
+    this.rewriter = rewriter;
+    this.limitField = limitField;
+    this.permittedLimit = permittedLimit;
+    this.used = new AtomicBoolean(false);
+  }
+
+  public QueryProcessor<T> setStart(int n) {
+    start = n;
+    return this;
+  }
+
+  /**
+   * Specify whether to enforce visibility by filtering out results that are not visible to the
+   * user.
+   *
+   * <p>Enforcing visibility may have performance consequences, as the index system may need to
+   * post-filter a large number of results to fill even a modest limit.
+   *
+   * <p>If visibility is enforced, the user's {@code queryLimit} global capability is also used to
+   * bound the total number of results. If this capability is non-positive, this results in the
+   * entire query processor being {@link #isDisabled() disabled}.
+   *
+   * @param enforce whether to enforce visibility.
+   * @return this.
+   */
+  public QueryProcessor<T> enforceVisibility(boolean enforce) {
+    enforceVisibility = enforce;
+    return this;
+  }
+
+  /**
+   * Set an end-user-provided limit on the number of results returned.
+   *
+   * <p>Since this limit is provided by an end user, it may exceed the limit that they are
+   * authorized to use. This is allowed; the processor will take multiple possible limits into
+   * account and choose the one that makes the most sense.
+   *
+   * @param n limit; zero or negative means no limit.
+   * @return this.
+   */
+  public QueryProcessor<T> setUserProvidedLimit(int n) {
+    userProvidedLimit = n;
+    return this;
+  }
+
+  public QueryProcessor<T> setRequestedFields(Set<String> fields) {
+    requestedFields = fields;
+    return this;
+  }
+
+  /**
+   * Query for entities that match a structured query.
+   *
+   * @see #query(List)
+   * @param query the query.
+   * @return results of the query.
+   */
+  public QueryResult<T> query(Predicate<T> query) throws OrmException, QueryParseException {
+    return query(ImmutableList.of(query)).get(0);
+  }
+
+  /**
+   * Perform multiple queries in parallel.
+   *
+   * <p>If querying is disabled, short-circuits the index and returns empty results. Callers that
+   * wish to distinguish this case from a query returning no results from the index may call {@link
+   * #isDisabled()} themselves.
+   *
+   * @param queries list of queries.
+   * @return results of the queries, one QueryResult per input query, in the same order as the
+   *     input.
+   */
+  public List<QueryResult<T>> query(List<Predicate<T>> queries)
+      throws OrmException, QueryParseException {
+    try {
+      return query(null, queries);
+    } catch (OrmRuntimeException e) {
+      throw new OrmException(e.getMessage(), e);
+    } catch (OrmException e) {
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), QueryParseException.class);
+      }
+      throw e;
+    }
+  }
+
+  private List<QueryResult<T>> query(
+      @Nullable List<String> queryStrings, List<Predicate<T>> queries)
+      throws OrmException, QueryParseException {
+    long startNanos = System.nanoTime();
+    checkState(!used.getAndSet(true), "%s has already been used", getClass().getSimpleName());
+    int cnt = queries.size();
+    if (queryStrings != null) {
+      int qs = queryStrings.size();
+      checkArgument(qs == cnt, "got %s query strings but %s predicates", qs, cnt);
+    }
+    if (cnt == 0) {
+      return ImmutableList.of();
+    }
+    if (isDisabled()) {
+      return disabledResults(queryStrings, queries);
+    }
+
+    // Parse and rewrite all queries.
+    List<Integer> limits = new ArrayList<>(cnt);
+    List<Predicate<T>> predicates = new ArrayList<>(cnt);
+    List<DataSource<T>> sources = new ArrayList<>(cnt);
+    for (Predicate<T> q : queries) {
+      int limit = getEffectiveLimit(q);
+      limits.add(limit);
+
+      if (limit == getBackendSupportedLimit()) {
+        limit--;
+      }
+
+      int page = (start / limit) + 1;
+      if (page > indexConfig.maxPages()) {
+        throw new QueryParseException(
+            "Cannot go beyond page " + indexConfig.maxPages() + " of results");
+      }
+
+      // Always bump limit by 1, even if this results in exceeding the permitted
+      // max for this user. The only way to see if there are more entities is to
+      // ask for one more result from the query.
+      QueryOptions opts = createOptions(indexConfig, start, limit + 1, getRequestedFields());
+      Predicate<T> pred = rewriter.rewrite(q, opts);
+      if (enforceVisibility) {
+        pred = enforceVisibility(pred);
+      }
+      predicates.add(pred);
+
+      @SuppressWarnings("unchecked")
+      DataSource<T> s = (DataSource<T>) pred;
+      sources.add(s);
+    }
+
+    // Run each query asynchronously, if supported.
+    List<ResultSet<T>> matches = new ArrayList<>(cnt);
+    for (DataSource<T> s : sources) {
+      matches.add(s.read());
+    }
+
+    List<QueryResult<T>> out = new ArrayList<>(cnt);
+    for (int i = 0; i < cnt; i++) {
+      out.add(
+          QueryResult.create(
+              queryStrings != null ? queryStrings.get(i) : null,
+              predicates.get(i),
+              limits.get(i),
+              matches.get(i).toList()));
+    }
+
+    // Only measure successful queries that actually touched the index.
+    metrics.executionTime.record(
+        schemaDef.getName(), System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
+    return out;
+  }
+
+  private static <T> ImmutableList<QueryResult<T>> disabledResults(
+      List<String> queryStrings, List<Predicate<T>> queries) {
+    return IntStream.range(0, queries.size())
+        .mapToObj(
+            i ->
+                QueryResult.create(
+                    queryStrings != null ? queryStrings.get(i) : null,
+                    queries.get(i),
+                    0,
+                    ImmutableList.of()))
+        .collect(toImmutableList());
+  }
+
+  protected QueryOptions createOptions(
+      IndexConfig indexConfig, int start, int limit, Set<String> requestedFields) {
+    return QueryOptions.create(indexConfig, start, limit, requestedFields);
+  }
+
+  /**
+   * Invoked after the query was rewritten. Subclasses must overwrite this method to filter out
+   * results that are not visible to the calling user.
+   *
+   * @param pred the query
+   * @return the modified query
+   */
+  protected abstract Predicate<T> enforceVisibility(Predicate<T> pred);
+
+  private Set<String> getRequestedFields() {
+    if (requestedFields != null) {
+      return requestedFields;
+    }
+    Index<?, T> index = indexes.getSearchIndex();
+    return index != null ? index.getSchema().getStoredFields().keySet() : ImmutableSet.<String>of();
+  }
+
+  /**
+   * Check whether querying should be disabled.
+   *
+   * <p>Currently, the only condition that can disable the whole query processor is if both {@link
+   * #enforceVisibility(boolean) visibility is enforced} and the user has a non-positive maximum
+   * value for the {@code queryLimit} capability.
+   *
+   * <p>If querying is disabled, all calls to {@link #query(Predicate)} and {@link #query(List)}
+   * will return empty results. This method can be used if callers wish to distinguish this case
+   * from a query returning no results from the index.
+   *
+   * @return true if querying should be disabled.
+   */
+  public boolean isDisabled() {
+    return enforceVisibility && getPermittedLimit() <= 0;
+  }
+
+  private int getPermittedLimit() {
+    return enforceVisibility ? permittedLimit.getAsInt() : Integer.MAX_VALUE;
+  }
+
+  private int getBackendSupportedLimit() {
+    return indexConfig.maxLimit();
+  }
+
+  private int getEffectiveLimit(Predicate<T> p) {
+    List<Integer> possibleLimits = new ArrayList<>(4);
+    possibleLimits.add(getBackendSupportedLimit());
+    possibleLimits.add(getPermittedLimit());
+    if (userProvidedLimit > 0) {
+      possibleLimits.add(userProvidedLimit);
+    }
+    if (limitField != null) {
+      Integer limitFromPredicate = LimitPredicate.getLimit(limitField, p);
+      if (limitFromPredicate != null) {
+        possibleLimits.add(limitFromPredicate);
+      }
+    }
+    int result = Ordering.natural().min(possibleLimits);
+    // Should have short-circuited from #query or thrown some other exception before getting here.
+    checkState(result > 0, "effective limit should be positive");
+    return result;
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java
new file mode 100644
index 0000000..67c159e
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryRequiresAuthException.java
@@ -0,0 +1,32 @@
+// 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.index.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-index/src/main/java/com/google/gerrit/index/query/QueryResult.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryResult.java
new file mode 100644
index 0000000..341e2b6
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/QueryResult.java
@@ -0,0 +1,51 @@
+// 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.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import java.util.List;
+
+/** Results of a query over entities. */
+@AutoValue
+public abstract class QueryResult<T> {
+  public static <T> QueryResult<T> create(
+      @Nullable String query, Predicate<T> predicate, int limit, List<T> entites) {
+    boolean more;
+    if (entites.size() > limit) {
+      more = true;
+      entites = entites.subList(0, limit);
+    } else {
+      more = false;
+    }
+    return new AutoValue_QueryResult<>(query, predicate, entites, more);
+  }
+
+  /** @return the original query string, or null if the query was created programmatically. */
+  @Nullable
+  public abstract String query();
+
+  /** @return the predicate after all rewriting and other modification by the query subsystem. */
+  public abstract Predicate<T> predicate();
+
+  /** @return the query results. */
+  public abstract List<T> entities();
+
+  /**
+   * @return whether the query could be retried with a higher start/limit to produce more results.
+   *     Never true if {@link #entities()} is empty.
+   */
+  public abstract boolean more();
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/RangeUtil.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/RangeUtil.java
new file mode 100644
index 0000000..1f22f36
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/RangeUtil.java
@@ -0,0 +1,118 @@
+// 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.common.primitives.Ints;
+import com.google.gerrit.common.Nullable;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public final class RangeUtil {
+  private static final Pattern RANGE_PATTERN = Pattern.compile("(>|>=|=|<|<=|)([+-]?\\d+)$");
+
+  private RangeUtil() {}
+
+  public static class Range {
+    /** The prefix of the query, before the range component. */
+    public final String prefix;
+
+    /** The minimum value specified in the query, inclusive. */
+    public final int min;
+
+    /** The maximum value specified in the query, inclusive. */
+    public final int max;
+
+    public Range(String prefix, int min, int max) {
+      this.prefix = prefix;
+      this.min = min;
+      this.max = max;
+    }
+  }
+
+  /**
+   * Determine the range of values being requested in the given query.
+   *
+   * @param rangeQuery the raw query, e.g. "{@code added:>12345}"
+   * @param minValue the minimum possible value for the field, inclusive
+   * @param maxValue the maximum possible value for the field, inclusive
+   * @return the calculated {@link Range}, or null if the query is invalid
+   */
+  @Nullable
+  public static Range getRange(String rangeQuery, int minValue, int maxValue) {
+    Matcher m = RANGE_PATTERN.matcher(rangeQuery);
+    String prefix;
+    String test;
+    Integer queryInt;
+    if (m.find()) {
+      prefix = rangeQuery.substring(0, m.start());
+      test = m.group(1);
+      queryInt = value(m.group(2));
+      if (queryInt == null) {
+        return null;
+      }
+    } else {
+      return null;
+    }
+
+    return getRange(prefix, test, queryInt, minValue, maxValue);
+  }
+
+  /**
+   * Determine the range of values being requested in the given query.
+   *
+   * @param prefix a prefix string which is copied into the range
+   * @param test the test operator, one of &gt;, &gt;=, =, &lt;, or &lt;=
+   * @param queryInt the integer being queried
+   * @param minValue the minimum possible value for the field, inclusive
+   * @param maxValue the maximum possible value for the field, inclusive
+   * @return the calculated {@link Range}
+   */
+  public static Range getRange(
+      String prefix, String test, int queryInt, int minValue, int maxValue) {
+    int min;
+    int max;
+    switch (test) {
+      case "=":
+      default:
+        min = max = queryInt;
+        break;
+      case ">":
+        min = Ints.saturatedCast(queryInt + 1L);
+        max = maxValue;
+        break;
+      case ">=":
+        min = queryInt;
+        max = maxValue;
+        break;
+      case "<":
+        min = minValue;
+        max = Ints.saturatedCast(queryInt - 1L);
+        break;
+      case "<=":
+        min = minValue;
+        max = queryInt;
+        break;
+    }
+
+    return new Range(prefix, min, max);
+  }
+
+  private static Integer value(String value) {
+    if (value.startsWith("+")) {
+      value = value.substring(1);
+    }
+    return Ints.tryParse(value);
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/RegexPredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/RegexPredicate.java
new file mode 100644
index 0000000..60a2a9e
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/RegexPredicate.java
@@ -0,0 +1,27 @@
+// 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;
+
+public abstract class RegexPredicate<I> extends IndexPredicate<I> {
+  protected RegexPredicate(FieldDef<I, ?> def, String value) {
+    super(def, value);
+  }
+
+  protected RegexPredicate(FieldDef<I, ?> def, String name, String value) {
+    super(def, name, value);
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/query/TimestampRangePredicate.java b/gerrit-index/src/main/java/com/google/gerrit/index/query/TimestampRangePredicate.java
new file mode 100644
index 0000000..edc2120
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/query/TimestampRangePredicate.java
@@ -0,0 +1,40 @@
+// 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.gwtjsonrpc.common.JavaSqlTimestampHelper;
+import java.sql.Timestamp;
+import java.util.Date;
+
+// TODO: Migrate this to IntegerRangePredicate
+public abstract class TimestampRangePredicate<I> extends IndexPredicate<I> {
+  protected static Timestamp parse(String value) throws QueryParseException {
+    try {
+      return JavaSqlTimestampHelper.parseTimestamp(value);
+    } catch (IllegalArgumentException e) {
+      // parseTimestamp's errors are specific and helpful, so preserve them.
+      throw new QueryParseException(e.getMessage(), e);
+    }
+  }
+
+  protected TimestampRangePredicate(FieldDef<I, Timestamp> def, String name, String value) {
+    super(def, name, value);
+  }
+
+  public abstract Date getMinTimestamp();
+
+  public abstract Date getMaxTimestamp();
+}
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/SchemaUtilTest.java b/gerrit-index/src/test/java/com/google/gerrit/index/SchemaUtilTest.java
new file mode 100644
index 0000000..3c0bbe0
--- /dev/null
+++ b/gerrit-index/src/test/java/com/google/gerrit/index/SchemaUtilTest.java
@@ -0,0 +1,83 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF 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.truth.Truth.assertThat;
+import static com.google.gerrit.index.SchemaUtil.getNameParts;
+import static com.google.gerrit.index.SchemaUtil.getPersonParts;
+import static com.google.gerrit.index.SchemaUtil.schema;
+
+import java.util.Map;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class SchemaUtilTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  static class TestSchemas {
+    static final Schema<String> V1 = schema();
+    static final Schema<String> V2 = schema();
+    static Schema<String> V3 = schema(); // Not final, ignored.
+    private static final Schema<String> V4 = schema();
+
+    // Ignored.
+    static Schema<String> V10 = schema();
+    final Schema<String> V11 = schema();
+  }
+
+  @Test
+  public void schemasFromClassBuildsMap() {
+    Map<Integer, Schema<String>> all = SchemaUtil.schemasFromClass(TestSchemas.class, String.class);
+    assertThat(all.keySet()).containsExactly(1, 2, 4);
+    assertThat(all.get(1)).isEqualTo(TestSchemas.V1);
+    assertThat(all.get(2)).isEqualTo(TestSchemas.V2);
+    assertThat(all.get(4)).isEqualTo(TestSchemas.V4);
+
+    exception.expect(IllegalArgumentException.class);
+    SchemaUtil.schemasFromClass(TestSchemas.class, Object.class);
+  }
+
+  @Test
+  public void getPersonPartsExtractsParts() {
+    // PersonIdent allows empty email, which should be extracted as the empty
+    // string. However, it converts empty names to null internally.
+    assertThat(getPersonParts(new PersonIdent("", ""))).containsExactly("");
+    assertThat(getPersonParts(new PersonIdent("foo bar", ""))).containsExactly("foo", "bar", "");
+
+    assertThat(getPersonParts(new PersonIdent("", "foo@example.com")))
+        .containsExactly("foo@example.com", "foo", "example.com", "example", "com");
+    assertThat(getPersonParts(new PersonIdent("foO J. bAr", "bA-z@exAmple.cOm")))
+        .containsExactly(
+            "foo",
+            "j",
+            "bar",
+            "ba-z@example.com",
+            "ba-z",
+            "ba",
+            "z",
+            "example.com",
+            "example",
+            "com");
+  }
+
+  @Test
+  public void getNamePartsExtractsParts() {
+    assertThat(getNameParts("")).isEmpty();
+    assertThat(getNameParts("foO-bAr_Baz a.b@c/d"))
+        .containsExactly("foo", "bar", "baz", "a", "b", "c", "d");
+  }
+}
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/AndPredicateTest.java b/gerrit-index/src/test/java/com/google/gerrit/index/query/AndPredicateTest.java
new file mode 100644
index 0000000..21098b3
--- /dev/null
+++ b/gerrit-index/src/test/java/com/google/gerrit/index/query/AndPredicateTest.java
@@ -0,0 +1,124 @@
+// 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.collect.ImmutableList.of;
+import static com.google.gerrit.index.query.Predicate.and;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.util.List;
+import org.junit.Test;
+
+public class AndPredicateTest extends PredicateTest {
+  @Test
+  public void children() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final Predicate<String> n = and(a, b);
+    assertEquals(2, n.getChildCount());
+    assertSame(a, n.getChild(0));
+    assertSame(b, n.getChild(1));
+  }
+
+  @Test
+  public void childrenUnmodifiable() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final Predicate<String> n = and(a, b);
+
+    try {
+      n.getChildren().clear();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
+    assertChildren("clear", n, of(a, b));
+
+    try {
+      n.getChildren().remove(0);
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
+    assertChildren("remove(0)", n, of(a, b));
+
+    try {
+      n.getChildren().iterator().remove();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
+    assertChildren("iterator().remove()", n, of(a, b));
+  }
+
+  private static void assertChildren(
+      String o, Predicate<String> p, List<? extends Predicate<String>> l) {
+    assertEquals(o + " did not affect child", l, p.getChildren());
+  }
+
+  @Test
+  public void testToString() {
+    final TestPredicate a = f("q", "alice");
+    final TestPredicate b = f("q", "bob");
+    final TestPredicate c = f("q", "charlie");
+    assertEquals("(q:alice q:bob)", and(a, b).toString());
+    assertEquals("(q:alice q:bob q:charlie)", and(a, b, c).toString());
+  }
+
+  @Test
+  public void testEquals() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final TestPredicate c = f("author", "charlie");
+
+    assertTrue(and(a, b).equals(and(a, b)));
+    assertTrue(and(a, b, c).equals(and(a, b, c)));
+
+    assertFalse(and(a, b).equals(and(b, a)));
+    assertFalse(and(a, c).equals(and(a, b)));
+
+    assertFalse(and(a, c).equals(a));
+  }
+
+  @Test
+  public void testHashCode() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final TestPredicate c = f("author", "charlie");
+
+    assertTrue(and(a, b).hashCode() == and(a, b).hashCode());
+    assertTrue(and(a, b, c).hashCode() == and(a, b, c).hashCode());
+    assertFalse(and(a, c).hashCode() == and(a, b).hashCode());
+  }
+
+  @Test
+  public void testCopy() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final TestPredicate c = f("author", "charlie");
+    final List<TestPredicate> s2 = of(a, b);
+    final List<TestPredicate> s3 = of(a, b, c);
+    final Predicate<String> n2 = and(a, b);
+
+    assertNotSame(n2, n2.copy(s2));
+    assertEquals(s2, n2.copy(s2).getChildren());
+    assertEquals(s3, n2.copy(s3).getChildren());
+  }
+}
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/FieldPredicateTest.java b/gerrit-index/src/test/java/com/google/gerrit/index/query/FieldPredicateTest.java
new file mode 100644
index 0000000..8fe90fc
--- /dev/null
+++ b/gerrit-index/src/test/java/com/google/gerrit/index/query/FieldPredicateTest.java
@@ -0,0 +1,68 @@
+// 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 org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+
+import java.util.Collections;
+import org.junit.Test;
+
+public class FieldPredicateTest extends PredicateTest {
+  @Test
+  public void testToString() {
+    assertEquals("author:bob", f("author", "bob").toString());
+    assertEquals("author:\"\"", f("author", "").toString());
+    assertEquals("owner:\"A U Thor\"", f("owner", "A U Thor").toString());
+  }
+
+  @SuppressWarnings("unlikely-arg-type")
+  @Test
+  public void testEquals() {
+    assertTrue(f("author", "bob").equals(f("author", "bob")));
+    assertFalse(f("author", "bob").equals(f("author", "alice")));
+    assertFalse(f("owner", "bob").equals(f("author", "bob")));
+    assertFalse(f("author", "bob").equals("author"));
+  }
+
+  @Test
+  public void testHashCode() {
+    assertTrue(f("a", "bob").hashCode() == f("a", "bob").hashCode());
+    assertFalse(f("a", "bob").hashCode() == f("a", "alice").hashCode());
+  }
+
+  @Test
+  public void nameValue() {
+    final String name = "author";
+    final String value = "alice";
+    final OperatorPredicate<String> f = f(name, value);
+    assertSame(name, f.getOperator());
+    assertSame(value, f.getValue());
+    assertEquals(0, f.getChildren().size());
+  }
+
+  @Test
+  public void testCopy() {
+    final OperatorPredicate<String> f = f("author", "alice");
+    assertSame(f, f.copy(Collections.<Predicate<String>>emptyList()));
+    assertSame(f, f.copy(f.getChildren()));
+
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("Expected 0 children");
+    f.copy(Collections.singleton(f("owner", "bob")));
+  }
+}
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/NotPredicateTest.java b/gerrit-index/src/test/java/com/google/gerrit/index/query/NotPredicateTest.java
new file mode 100644
index 0000000..74eac61
--- /dev/null
+++ b/gerrit-index/src/test/java/com/google/gerrit/index/query/NotPredicateTest.java
@@ -0,0 +1,129 @@
+// 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.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.index.query.Predicate.not;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.util.Collections;
+import java.util.List;
+import org.junit.Test;
+
+public class NotPredicateTest extends PredicateTest {
+  @Test
+  public void notNot() {
+    final TestPredicate p = f("author", "bob");
+    final Predicate<String> n = not(p);
+    assertTrue(n instanceof NotPredicate);
+    assertNotSame(p, n);
+    assertSame(p, not(n));
+  }
+
+  @Test
+  public void children() {
+    final TestPredicate p = f("author", "bob");
+    final Predicate<String> n = not(p);
+    assertEquals(1, n.getChildCount());
+    assertSame(p, n.getChild(0));
+  }
+
+  @Test
+  public void childrenUnmodifiable() {
+    final TestPredicate p = f("author", "bob");
+    final Predicate<String> n = not(p);
+
+    try {
+      n.getChildren().clear();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      assertOnlyChild("clear", p, n);
+    }
+
+    try {
+      n.getChildren().remove(0);
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      assertOnlyChild("remove(0)", p, n);
+    }
+
+    try {
+      n.getChildren().iterator().remove();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      assertOnlyChild("remove()", p, n);
+    }
+  }
+
+  private static void assertOnlyChild(String o, Predicate<String> c, Predicate<String> p) {
+    assertEquals(o + " did not affect child", 1, p.getChildCount());
+    assertSame(o + " did not affect child", c, p.getChild(0));
+  }
+
+  @Test
+  public void testToString() {
+    assertEquals("-author:bob", not(f("author", "bob")).toString());
+  }
+
+  @SuppressWarnings("unlikely-arg-type")
+  @Test
+  public void testEquals() {
+    assertTrue(not(f("author", "bob")).equals(not(f("author", "bob"))));
+    assertFalse(not(f("author", "bob")).equals(not(f("author", "alice"))));
+    assertFalse(not(f("author", "bob")).equals(f("author", "bob")));
+    assertFalse(not(f("author", "bob")).equals("author"));
+  }
+
+  @Test
+  public void testHashCode() {
+    assertTrue(not(f("a", "b")).hashCode() == not(f("a", "b")).hashCode());
+    assertFalse(not(f("a", "b")).hashCode() == not(f("a", "a")).hashCode());
+  }
+
+  @Test
+  @SuppressWarnings({"rawtypes", "unchecked"})
+  public void testCopy() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final List<TestPredicate> sa = Collections.singletonList(a);
+    final List<TestPredicate> sb = Collections.singletonList(b);
+    final Predicate n = not(a);
+
+    assertNotSame(n, n.copy(sa));
+    assertEquals(sa, n.copy(sa).getChildren());
+
+    assertNotSame(n, n.copy(sb));
+    assertEquals(sb, n.copy(sb).getChildren());
+
+    try {
+      n.copy(Collections.<Predicate>emptyList());
+      fail("Expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      assertEquals("Expected exactly one child", e.getMessage());
+    }
+
+    try {
+      n.copy(and(a, b).getChildren());
+      fail("Expected IllegalArgumentException");
+    } catch (IllegalArgumentException e) {
+      assertEquals("Expected exactly one child", e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/OrPredicateTest.java b/gerrit-index/src/test/java/com/google/gerrit/index/query/OrPredicateTest.java
new file mode 100644
index 0000000..255a3f8
--- /dev/null
+++ b/gerrit-index/src/test/java/com/google/gerrit/index/query/OrPredicateTest.java
@@ -0,0 +1,124 @@
+// 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.collect.ImmutableList.of;
+import static com.google.gerrit.index.query.Predicate.or;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotSame;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.util.List;
+import org.junit.Test;
+
+public class OrPredicateTest extends PredicateTest {
+  @Test
+  public void children() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final Predicate<String> n = or(a, b);
+    assertEquals(2, n.getChildCount());
+    assertSame(a, n.getChild(0));
+    assertSame(b, n.getChild(1));
+  }
+
+  @Test
+  public void childrenUnmodifiable() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final Predicate<String> n = or(a, b);
+
+    try {
+      n.getChildren().clear();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
+    assertChildren("clear", n, of(a, b));
+
+    try {
+      n.getChildren().remove(0);
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
+    assertChildren("remove(0)", n, of(a, b));
+
+    try {
+      n.getChildren().iterator().remove();
+      fail("Expected UnsupportedOperationException");
+    } catch (UnsupportedOperationException e) {
+      // Expected
+    }
+    assertChildren("iterator().remove()", n, of(a, b));
+  }
+
+  private static void assertChildren(
+      String o, Predicate<String> p, List<? extends Predicate<String>> l) {
+    assertEquals(o + " did not affect child", l, p.getChildren());
+  }
+
+  @Test
+  public void testToString() {
+    final TestPredicate a = f("q", "alice");
+    final TestPredicate b = f("q", "bob");
+    final TestPredicate c = f("q", "charlie");
+    assertEquals("(q:alice OR q:bob)", or(a, b).toString());
+    assertEquals("(q:alice OR q:bob OR q:charlie)", or(a, b, c).toString());
+  }
+
+  @Test
+  public void testEquals() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final TestPredicate c = f("author", "charlie");
+
+    assertTrue(or(a, b).equals(or(a, b)));
+    assertTrue(or(a, b, c).equals(or(a, b, c)));
+
+    assertFalse(or(a, b).equals(or(b, a)));
+    assertFalse(or(a, c).equals(or(a, b)));
+
+    assertFalse(or(a, c).equals(a));
+  }
+
+  @Test
+  public void testHashCode() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final TestPredicate c = f("author", "charlie");
+
+    assertTrue(or(a, b).hashCode() == or(a, b).hashCode());
+    assertTrue(or(a, b, c).hashCode() == or(a, b, c).hashCode());
+    assertFalse(or(a, c).hashCode() == or(a, b).hashCode());
+  }
+
+  @Test
+  public void testCopy() {
+    final TestPredicate a = f("author", "alice");
+    final TestPredicate b = f("author", "bob");
+    final TestPredicate c = f("author", "charlie");
+    final List<TestPredicate> s2 = of(a, b);
+    final List<TestPredicate> s3 = of(a, b, c);
+    final Predicate<String> n2 = or(a, b);
+
+    assertNotSame(n2, n2.copy(s2));
+    assertEquals(s2, n2.copy(s2).getChildren());
+    assertEquals(s3, n2.copy(s3).getChildren());
+  }
+}
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/PredicateTest.java b/gerrit-index/src/test/java/com/google/gerrit/index/query/PredicateTest.java
new file mode 100644
index 0000000..6979d82
--- /dev/null
+++ b/gerrit-index/src/test/java/com/google/gerrit/index/query/PredicateTest.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.index.query;
+
+import org.junit.Ignore;
+import org.junit.Rule;
+import org.junit.rules.ExpectedException;
+
+@Ignore
+public abstract class PredicateTest {
+  @Rule public ExpectedException exception = ExpectedException.none();
+
+  protected static final class TestPredicate extends OperatorPredicate<String> {
+    protected TestPredicate(String name, String value) {
+      super(name, value);
+    }
+  }
+
+  protected static TestPredicate f(String name, String value) {
+    return new TestPredicate(name, value);
+  }
+}
diff --git a/gerrit-index/src/test/java/com/google/gerrit/index/query/QueryParserTest.java b/gerrit-index/src/test/java/com/google/gerrit/index/query/QueryParserTest.java
new file mode 100644
index 0000000..448f292
--- /dev/null
+++ b/gerrit-index/src/test/java/com/google/gerrit/index/query/QueryParserTest.java
@@ -0,0 +1,47 @@
+// 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 org.junit.Assert.assertEquals;
+
+import org.antlr.runtime.tree.Tree;
+import org.junit.Test;
+
+public class QueryParserTest {
+  @Test
+  public void projectBare() throws QueryParseException {
+    Tree r;
+
+    r = parse("project:tools/gerrit");
+    assertSingleWord("project", "tools/gerrit", r);
+
+    r = parse("project:tools/*");
+    assertSingleWord("project", "tools/*", r);
+  }
+
+  private static void assertSingleWord(String name, String value, Tree r) {
+    assertEquals(QueryParser.FIELD_NAME, r.getType());
+    assertEquals(name, r.getText());
+    assertEquals(1, r.getChildCount());
+    final Tree c = r.getChild(0);
+    assertEquals(QueryParser.SINGLE_WORD, c.getType());
+    assertEquals(value, c.getText());
+    assertEquals(0, c.getChildCount());
+  }
+
+  private static Tree parse(String str) throws QueryParseException {
+    return QueryParser.parse(str);
+  }
+}
diff --git a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
index 2379204..a592b7e 100644
--- a/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
+++ b/gerrit-launcher/src/main/java/com/google/gerrit/launcher/GerritLauncher.java
@@ -59,11 +59,22 @@
 
   private static ClassLoader daemonClassLoader;
 
-  public static void main(final String[] argv) throws Exception {
+  public static void main(String[] argv) throws Exception {
     System.exit(mainImpl(argv));
   }
 
-  public static int mainImpl(final String[] argv) throws Exception {
+  /**
+   * Invokes a proram.
+   *
+   * <p>Creates a new classloader to load and run the program class. To reuse a classloader across
+   * calls (e.g. from tests), use {@link #invokeProgram(ClassLoader, String[])}.
+   *
+   * @param argv arguments, as would be passed to {@code gerrit.war}. The first argument is the
+   *     program name.
+   * @return program return code.
+   * @throws Exception if any error occurs.
+   */
+  public static int mainImpl(String[] argv) throws Exception {
     if (argv.length == 0) {
       File me;
       try {
@@ -108,7 +119,7 @@
     return invokeProgram(cl, argv);
   }
 
-  public static void daemonStart(final String[] argv) throws Exception {
+  public static void daemonStart(String[] argv) throws Exception {
     if (daemonClassLoader != null) {
       throw new IllegalStateException("daemonStart can be called only once per JVM instance");
     }
@@ -128,7 +139,7 @@
     }
   }
 
-  public static void daemonStop(final String[] argv) throws Exception {
+  public static void daemonStop(String[] argv) throws Exception {
     if (daemonClassLoader == null) {
       throw new IllegalStateException("daemonStop can be called only after call to daemonStop");
     }
@@ -148,7 +159,7 @@
     return "PrologShell".equals(cn) || "Rulec".equals(cn);
   }
 
-  private static String getVersion(final File me) {
+  private static String getVersion(File me) {
     if (me == null) {
       return "";
     }
@@ -163,8 +174,16 @@
     }
   }
 
-  private static int invokeProgram(final ClassLoader loader, final String[] origArgv)
-      throws Exception {
+  /**
+   * Invokes a proram in the provided {@code ClassLoader}.
+   *
+   * @param loader classloader to load program class from.
+   * @param origArgv arguments, as would be passed to {@code gerrit.war}. The first argument is the
+   *     program name.
+   * @return program return code.
+   * @throws Exception if any error occurs.
+   */
+  public static int invokeProgram(ClassLoader loader, String[] origArgv) throws Exception {
     String name = origArgv[0];
     final String[] argv = new String[origArgv.length - 1];
     System.arraycopy(origArgv, 1, argv, 0, argv.length);
@@ -316,7 +335,7 @@
     }
   }
 
-  private static String safeName(final ZipEntry ze) {
+  private static String safeName(ZipEntry ze) {
     // Try to derive the name of the temporary file so it
     // doesn't completely suck. Best if we can make it
     // match the name it was in the archive.
@@ -535,7 +554,7 @@
     if (tmpEntries != null) {
       final long now = System.currentTimeMillis();
       final long expired = now - MILLISECONDS.convert(7, DAYS);
-      for (final File tmpEntry : tmpEntries) {
+      for (File tmpEntry : tmpEntries) {
         if (tmpEntry.isDirectory() && tmpEntry.lastModified() < expired) {
           final String[] all = tmpEntry.list();
           if (all == null || all.length == 0) {
diff --git a/gerrit-lucene/BUILD b/gerrit-lucene/BUILD
index 5f87b4e..aae5000 100644
--- a/gerrit-lucene/BUILD
+++ b/gerrit-lucene/BUILD
@@ -7,7 +7,8 @@
     srcs = QUERY_BUILDER,
     visibility = ["//visibility:public"],
     deps = [
-        "//gerrit-antlr:query_exception",
+        "//gerrit-index:index",
+        "//gerrit-index:query_exception",
         "//gerrit-reviewdb:server",
         "//gerrit-server:server",
         "//lib:guava",
@@ -25,10 +26,11 @@
     visibility = ["//visibility:public"],
     deps = [
         ":query_builder",
-        "//gerrit-antlr:query_exception",
         "//gerrit-common:annotations",
         "//gerrit-common:server",
         "//gerrit-extension-api:api",
+        "//gerrit-index:index",
+        "//gerrit-index:query_exception",
         "//gerrit-reviewdb:server",
         "//gerrit-server:server",
         "//lib:guava",
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index 5c3183a..9d474dd 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -20,20 +20,18 @@
 import com.google.common.base.Joiner;
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.AbstractFuture;
-import com.google.common.util.concurrent.AsyncFunction;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.FieldType;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.Schema.Values;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.FieldType;
-import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.index.Schema.Values;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.Set;
@@ -98,7 +96,7 @@
     this.sitePaths = sitePaths;
     this.dir = dir;
     this.name = name;
-    final String index = Joiner.on('_').skipNulls().join(name, subIndex);
+    String index = Joiner.on('_').skipNulls().join(name, subIndex);
     IndexWriter delegateWriter;
     long commitPeriod = writerConfig.getCommitWithinMs();
 
@@ -121,28 +119,25 @@
       @SuppressWarnings("unused") // Error handling within Runnable.
       Future<?> possiblyIgnoredError =
           autoCommitExecutor.scheduleAtFixedRate(
-              new Runnable() {
-                @Override
-                public void run() {
+              () -> {
+                try {
+                  if (autoCommitWriter.hasUncommittedChanges()) {
+                    autoCommitWriter.manualFlush();
+                    autoCommitWriter.commit();
+                  }
+                } catch (IOException e) {
+                  log.error("Error committing " + index + " Lucene index", e);
+                } catch (OutOfMemoryError e) {
+                  log.error("Error committing " + index + " Lucene index", e);
                   try {
-                    if (autoCommitWriter.hasUncommittedChanges()) {
-                      autoCommitWriter.manualFlush();
-                      autoCommitWriter.commit();
-                    }
-                  } catch (IOException e) {
-                    log.error("Error committing " + index + " Lucene index", e);
-                  } catch (OutOfMemoryError e) {
-                    log.error("Error committing " + index + " Lucene index", e);
-                    try {
-                      autoCommitWriter.close();
-                    } catch (IOException e2) {
-                      log.error(
-                          "SEVERE: Error closing "
-                              + index
-                              + " Lucene index after OOM;"
-                              + " index may be corrupted.",
-                          e);
-                    }
+                    autoCommitWriter.close();
+                  } catch (IOException e2) {
+                    log.error(
+                        "SEVERE: Error closing "
+                            + index
+                            + " Lucene index after OOM;"
+                            + " index may be corrupted.",
+                        e);
                   }
                 }
               },
@@ -247,48 +242,27 @@
     }
   }
 
-  ListenableFuture<?> insert(final Document doc) {
-    return submit(
-        new Callable<Long>() {
-          @Override
-          public Long call() throws IOException, InterruptedException {
-            return writer.addDocument(doc);
-          }
-        });
+  ListenableFuture<?> insert(Document doc) {
+    return submit(() -> writer.addDocument(doc));
   }
 
-  ListenableFuture<?> replace(final Term term, final Document doc) {
-    return submit(
-        new Callable<Long>() {
-          @Override
-          public Long call() throws IOException, InterruptedException {
-            return writer.updateDocument(term, doc);
-          }
-        });
+  ListenableFuture<?> replace(Term term, Document doc) {
+    return submit(() -> writer.updateDocument(term, doc));
   }
 
-  ListenableFuture<?> delete(final Term term) {
-    return submit(
-        new Callable<Long>() {
-          @Override
-          public Long call() throws IOException, InterruptedException {
-            return writer.deleteDocuments(term);
-          }
-        });
+  ListenableFuture<?> delete(Term term) {
+    return submit(() -> writer.deleteDocuments(term));
   }
 
   private ListenableFuture<?> submit(Callable<Long> task) {
     ListenableFuture<Long> future = Futures.nonCancellationPropagating(writerThread.submit(task));
     return Futures.transformAsync(
         future,
-        new AsyncFunction<Long, Void>() {
-          @Override
-          public ListenableFuture<Void> apply(Long gen) throws InterruptedException {
-            // Tell the reopen thread a future is waiting on this
-            // generation so it uses the min stale time when refreshing.
-            reopenThread.waitForGeneration(gen, 0);
-            return new NrtFuture(gen);
-          }
+        gen -> {
+          // Tell the reopen thread a future is waiting on this
+          // generation so it uses the min stale time when refreshing.
+          reopenThread.waitForGeneration(gen, 0);
+          return new NrtFuture(gen);
         },
         directExecutor());
   }
@@ -310,9 +284,9 @@
     searcherManager.release(searcher);
   }
 
-  Document toDocument(V obj, FillArgs fillArgs) {
+  Document toDocument(V obj) {
     Document result = new Document();
-    for (Values<V> vs : schema.buildFields(obj, fillArgs)) {
+    for (Values<V> vs : schema.buildFields(obj)) {
       if (vs.getValues() != null) {
         add(result, vs);
       }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java
index 58117b8..126c79f 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/ChangeSubIndex.java
@@ -19,17 +19,17 @@
 import static com.google.gerrit.lucene.LuceneChangeIndex.UPDATED_SORT_FIELD;
 import static com.google.gerrit.server.index.change.ChangeSchemaDefinitions.NAME;
 
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.Schema.Values;
+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;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.index.Schema.Values;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.gerrit.server.query.DataSource;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
 import java.io.IOException;
 import java.nio.file.Path;
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
index fbde7be..7a4cd40 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneAccountIndex.java
@@ -16,18 +16,18 @@
 
 import static com.google.gerrit.server.index.account.AccountField.ID;
 
+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.GerritServerConfig;
 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.AccountIndex;
-import com.google.gerrit.server.query.DataSource;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
@@ -81,7 +81,7 @@
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
       return new RAMDirectory();
     }
-    Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS + "_", schema);
+    Path indexDir = LuceneVersionManager.getDir(sitePaths, ACCOUNTS, schema);
     return FSDirectory.open(indexDir);
   }
 
@@ -109,8 +109,7 @@
   @Override
   public void replace(AccountState as) throws IOException {
     try {
-      // No parts of FillArgs are currently required, just use null.
-      replace(idTerm(as), toDocument(as, null)).get();
+      replace(idTerm(as), toDocument(as)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new IOException(e);
     }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 4adc3e6..2912733 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -35,6 +35,10 @@
 import com.google.common.collect.Sets;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListeningExecutorService;
+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.PatchSet;
@@ -43,26 +47,21 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.FieldDef.FillArgs;
 import com.google.gerrit.server.index.IndexExecutor;
 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.gwtorm.protobuf.ProtobufCodec;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.util.ArrayList;
@@ -106,7 +105,7 @@
   static final String UPDATED_SORT_FIELD = sortFieldName(ChangeField.UPDATED);
   static final String ID_SORT_FIELD = sortFieldName(ChangeField.LEGACY_ID);
 
-  private static final String CHANGES_PREFIX = "changes_";
+  private static final String CHANGES = "changes";
   private static final String CHANGES_OPEN = "open";
   private static final String CHANGES_CLOSED = "closed";
   private static final String ADDED_FIELD = ChangeField.ADDED.getName();
@@ -115,10 +114,14 @@
   private static final String DELETED_FIELD = ChangeField.DELETED.getName();
   private static final String MERGEABLE_FIELD = ChangeField.MERGEABLE.getName();
   private static final String PATCH_SET_FIELD = ChangeField.PATCH_SET.getName();
+  private static final String PENDING_REVIEWER_FIELD = ChangeField.PENDING_REVIEWER.getName();
+  private static final String PENDING_REVIEWER_BY_EMAIL_FIELD =
+      ChangeField.PENDING_REVIEWER_BY_EMAIL.getName();
   private static final String REF_STATE_FIELD = ChangeField.REF_STATE.getName();
   private static final String REF_STATE_PATTERN_FIELD = ChangeField.REF_STATE_PATTERN.getName();
   private static final String REVIEWEDBY_FIELD = ChangeField.REVIEWEDBY.getName();
   private static final String REVIEWER_FIELD = ChangeField.REVIEWER.getName();
+  private static final String REVIEWER_BY_EMAIL_FIELD = ChangeField.REVIEWER_BY_EMAIL.getName();
   private static final String HASHTAG_FIELD = ChangeField.HASHTAG_CASE_AWARE.getName();
   private static final String STAR_FIELD = ChangeField.STAR.getName();
   private static final String SUBMIT_RECORD_LENIENT_FIELD =
@@ -136,7 +139,6 @@
     return QueryBuilder.intTerm(LEGACY_ID.getName(), id.get());
   }
 
-  private final FillArgs fillArgs;
   private final ListeningExecutorService executor;
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
@@ -145,17 +147,15 @@
   private final ChangeSubIndex openIndex;
   private final ChangeSubIndex closedIndex;
 
-  @AssistedInject
+  @Inject
   LuceneChangeIndex(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       @IndexExecutor(INTERACTIVE) ListeningExecutorService executor,
       Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
-      FillArgs fillArgs,
       @Assisted Schema<ChangeData> schema)
       throws IOException {
-    this.fillArgs = fillArgs;
     this.executor = executor;
     this.db = db;
     this.changeDataFactory = changeDataFactory;
@@ -175,7 +175,7 @@
           new ChangeSubIndex(
               schema, sitePaths, new RAMDirectory(), "ramClosed", closedConfig, searcherFactory);
     } else {
-      Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES_PREFIX, schema);
+      Path dir = LuceneVersionManager.getDir(sitePaths, CHANGES, schema);
       openIndex =
           new ChangeSubIndex(
               schema, sitePaths, dir.resolve(CHANGES_OPEN), openConfig, searcherFactory);
@@ -204,7 +204,7 @@
     Term id = LuceneChangeIndex.idTerm(cd);
     // toDocument is essentially static and doesn't depend on the specific
     // sub-index, so just pick one.
-    Document doc = openIndex.toDocument(cd, fillArgs);
+    Document doc = openIndex.toDocument(cd);
     try {
       if (cd.change().getStatus().isOpen()) {
         Futures.allAsList(closedIndex.delete(id), openIndex.replace(id, doc)).get();
@@ -418,14 +418,9 @@
     } else {
       IndexableField f = Iterables.getFirst(doc.get(idFieldName), null);
       Change.Id id = new Change.Id(f.numericValue().intValue());
-      IndexableField project = Iterables.getFirst(doc.get(PROJECT.getName()), null);
-      if (project == null) {
-        // Old schema without project field: we can safely assume NoteDb is
-        // disabled.
-        cd = changeDataFactory.createOnlyWhenNoteDbDisabled(db.get(), id);
-      } else {
-        cd = changeDataFactory.create(db.get(), new Project.NameKey(project.stringValue()), id);
-      }
+      // IndexUtils#changeFields ensures either CHANGE or PROJECT is always present.
+      IndexableField project = doc.get(PROJECT.getName()).iterator().next();
+      cd = changeDataFactory.create(db.get(), new Project.NameKey(project.stringValue()), id);
     }
 
     if (fields.contains(PATCH_SET_FIELD)) {
@@ -452,6 +447,15 @@
     if (fields.contains(REVIEWER_FIELD)) {
       decodeReviewers(doc, cd);
     }
+    if (fields.contains(REVIEWER_BY_EMAIL_FIELD)) {
+      decodeReviewersByEmail(doc, cd);
+    }
+    if (fields.contains(PENDING_REVIEWER_FIELD)) {
+      decodePendingReviewers(doc, cd);
+    }
+    if (fields.contains(PENDING_REVIEWER_BY_EMAIL_FIELD)) {
+      decodePendingReviewersByEmail(doc, cd);
+    }
     decodeSubmitRecords(
         doc, SUBMIT_RECORD_STRICT_FIELD, ChangeField.SUBMIT_RULE_OPTIONS_STRICT, cd);
     decodeSubmitRecords(
@@ -548,6 +552,28 @@
             FluentIterable.from(doc.get(REVIEWER_FIELD)).transform(IndexableField::stringValue)));
   }
 
+  private void decodeReviewersByEmail(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setReviewersByEmail(
+        ChangeField.parseReviewerByEmailFieldValues(
+            FluentIterable.from(doc.get(REVIEWER_BY_EMAIL_FIELD))
+                .transform(IndexableField::stringValue)));
+  }
+
+  private void decodePendingReviewers(ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setPendingReviewers(
+        ChangeField.parseReviewerFieldValues(
+            FluentIterable.from(doc.get(PENDING_REVIEWER_FIELD))
+                .transform(IndexableField::stringValue)));
+  }
+
+  private void decodePendingReviewersByEmail(
+      ListMultimap<String, IndexableField> doc, ChangeData cd) {
+    cd.setPendingReviewersByEmail(
+        ChangeField.parseReviewerByEmailFieldValues(
+            FluentIterable.from(doc.get(PENDING_REVIEWER_BY_EMAIL_FIELD))
+                .transform(IndexableField::stringValue)));
+  }
+
   private void decodeSubmitRecords(
       ListMultimap<String, IndexableField> doc,
       String field,
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java
index c4f10ff..32870cb 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneGroupIndex.java
@@ -16,17 +16,18 @@
 
 import static com.google.gerrit.server.index.group.GroupField.UUID;
 
+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.GerritServerConfig;
 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.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.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
 import com.google.inject.Inject;
@@ -38,6 +39,7 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.index.Term;
@@ -55,7 +57,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-public class LuceneGroupIndex extends AbstractLuceneIndex<AccountGroup.UUID, AccountGroup>
+public class LuceneGroupIndex extends AbstractLuceneIndex<AccountGroup.UUID, InternalGroup>
     implements GroupIndex {
   private static final Logger log = LoggerFactory.getLogger(LuceneGroupIndex.class);
 
@@ -63,7 +65,7 @@
 
   private static final String UUID_SORT_FIELD = sortFieldName(UUID);
 
-  private static Term idTerm(AccountGroup group) {
+  private static Term idTerm(InternalGroup group) {
     return idTerm(group.getGroupUUID());
   }
 
@@ -72,15 +74,15 @@
   }
 
   private final GerritIndexWriterConfig indexWriterConfig;
-  private final QueryBuilder<AccountGroup> queryBuilder;
+  private final QueryBuilder<InternalGroup> queryBuilder;
   private final Provider<GroupCache> groupCache;
 
-  private static Directory dir(Schema<AccountGroup> schema, Config cfg, SitePaths sitePaths)
+  private static Directory dir(Schema<?> schema, Config cfg, SitePaths sitePaths)
       throws IOException {
     if (LuceneIndexModule.isInMemoryTest(cfg)) {
       return new RAMDirectory();
     }
-    Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS + "_", schema);
+    Path indexDir = LuceneVersionManager.getDir(sitePaths, GROUPS, schema);
     return FSDirectory.open(indexDir);
   }
 
@@ -89,7 +91,7 @@
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
       Provider<GroupCache> groupCache,
-      @Assisted Schema<AccountGroup> schema)
+      @Assisted Schema<InternalGroup> schema)
       throws IOException {
     super(
         schema,
@@ -106,10 +108,9 @@
   }
 
   @Override
-  public void replace(AccountGroup group) throws IOException {
+  public void replace(InternalGroup group) throws IOException {
     try {
-      // No parts of FillArgs are currently required, just use null.
-      replace(idTerm(group), toDocument(group, null)).get();
+      replace(idTerm(group), toDocument(group)).get();
     } catch (ExecutionException | InterruptedException e) {
       throw new IOException(e);
     }
@@ -125,7 +126,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(
         opts,
@@ -133,7 +134,7 @@
         new Sort(new SortField(UUID_SORT_FIELD, SortField.Type.STRING, false)));
   }
 
-  private class QuerySource implements DataSource<AccountGroup> {
+  private class QuerySource implements DataSource<InternalGroup> {
     private final QueryOptions opts;
     private final Query query;
     private final Sort sort;
@@ -150,27 +151,28 @@
     }
 
     @Override
-    public ResultSet<AccountGroup> read() throws OrmException {
+    public ResultSet<InternalGroup> read() throws OrmException {
       IndexSearcher searcher = null;
       try {
         searcher = acquire();
         int realLimit = opts.start() + opts.limit();
         TopFieldDocs docs = searcher.search(query, realLimit, sort);
-        List<AccountGroup> result = new ArrayList<>(docs.scoreDocs.length);
+        List<InternalGroup> result = new ArrayList<>(docs.scoreDocs.length);
         for (int i = opts.start(); i < docs.scoreDocs.length; i++) {
           ScoreDoc sd = docs.scoreDocs[i];
           Document doc = searcher.doc(sd.doc, IndexUtils.groupFields(opts));
-          result.add(toAccountGroup(doc));
+          Optional<InternalGroup> internalGroup = toInternalGroup(doc);
+          internalGroup.ifPresent(result::add);
         }
-        final List<AccountGroup> r = Collections.unmodifiableList(result);
-        return new ResultSet<AccountGroup>() {
+        final List<InternalGroup> r = Collections.unmodifiableList(result);
+        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;
           }
 
@@ -193,7 +195,7 @@
     }
   }
 
-  private AccountGroup toAccountGroup(Document doc) {
+  private Optional<InternalGroup> toInternalGroup(Document doc) {
     AccountGroup.UUID uuid = new AccountGroup.UUID(doc.getField(UUID.getName()).stringValue());
     // Use the GroupCache rather than depending on any stored fields in the
     // document (of which there shouldn't be any).
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index a78504f..c159962 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -15,10 +15,10 @@
 package com.google.gerrit.lucene;
 
 import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.AbstractIndexModule;
-import com.google.gerrit.server.index.AbstractVersionManager;
-import com.google.gerrit.server.index.IndexConfig;
+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;
@@ -36,7 +36,7 @@
     return new LuceneIndexModule(versions, threads);
   }
 
-  public static LuceneIndexModule latestVersionWithOnlineUpgrade() {
+  public static LuceneIndexModule latestVersion() {
     return new LuceneIndexModule(null, 0);
   }
 
@@ -64,7 +64,7 @@
   }
 
   @Override
-  protected Class<? extends AbstractVersionManager> getVersionManager() {
+  protected Class<? extends VersionManager> getVersionManager() {
     return LuceneVersionManager.class;
   }
 
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
index 441a4b7..aabce35 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -15,14 +15,15 @@
 package com.google.gerrit.lucene;
 
 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.GerritServerConfig;
 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;
@@ -36,29 +37,29 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class LuceneVersionManager extends AbstractVersionManager implements LifecycleListener {
+public class LuceneVersionManager extends VersionManager {
   private static final Logger log = LoggerFactory.getLogger(LuceneVersionManager.class);
 
-  static Path getDir(SitePaths sitePaths, String prefix, Schema<?> schema) {
-    return sitePaths.index_dir.resolve(String.format("%s%04d", prefix, schema.getVersion()));
+  static Path getDir(SitePaths sitePaths, String name, Schema<?> schema) {
+    return sitePaths.index_dir.resolve(String.format("%s_%04d", name, schema.getVersion()));
   }
 
   @Inject
   LuceneVersionManager(
       @GerritServerConfig Config cfg,
       SitePaths sitePaths,
+      DynamicSet<OnlineUpgradeListener> listeners,
       Collection<IndexDefinition<?, ?, ?>> defs) {
-    super(cfg, sitePaths, defs);
+    super(sitePaths, listeners, defs, VersionManager.getOnlineUpgrade(cfg));
   }
 
   @Override
-  protected <K, V, I extends Index<K, V>>
-      TreeMap<Integer, AbstractVersionManager.Version<V>> scanVersions(
-          IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
-    TreeMap<Integer, AbstractVersionManager.Version<V>> versions = new TreeMap<>();
+  protected <K, V, I extends Index<K, V>> TreeMap<Integer, VersionManager.Version<V>> scanVersions(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    TreeMap<Integer, VersionManager.Version<V>> versions = new TreeMap<>();
     for (Schema<V> schema : def.getSchemas().values()) {
       // This part is Lucene-specific.
-      Path p = getDir(sitePaths, def.getName() + "_", schema);
+      Path p = getDir(sitePaths, def.getName(), schema);
       boolean isDir = Files.isDirectory(p);
       if (Files.exists(p) && !isDir) {
         log.warn("Not a directory: {}", p.toAbsolutePath());
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
index 74111a0..6aab7c7 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/QueryBuilder.java
@@ -21,18 +21,18 @@
 import static org.apache.lucene.search.BooleanClause.Occur.SHOULD;
 
 import com.google.common.collect.Lists;
-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.Schema;
-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.FieldType;
+import com.google.gerrit.index.Schema;
+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 java.util.Date;
 import java.util.List;
 import org.apache.lucene.analysis.Analyzer;
diff --git a/gerrit-main/src/main/java/Main.java b/gerrit-main/src/main/java/Main.java
index 8c9deb1..0eca665 100644
--- a/gerrit-main/src/main/java/Main.java
+++ b/gerrit-main/src/main/java/Main.java
@@ -19,7 +19,7 @@
   // to jump into the real main code.
   //
 
-  public static void main(final String[] argv) throws Exception {
+  public static void main(String[] argv) throws Exception {
     if (onSupportedJavaVersion()) {
       com.google.gerrit.launcher.GerritLauncher.main(argv);
 
diff --git a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
index ab69dde..68b28a9d 100644
--- a/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
+++ b/gerrit-oauth/src/main/java/com/google/gerrit/httpd/auth/oauth/OAuthSession.java
@@ -32,7 +32,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.auth.oauth.OAuthTokenCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -46,6 +46,7 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -193,7 +194,7 @@
       log.info("OAuth2: linking claimed identity to {}", claimedId.get().toString());
       try {
         accountManager.link(claimedId.get(), req);
-      } catch (OrmException e) {
+      } catch (OrmException | ConfigInvalidException e) {
         log.error(
             "Cannot link: "
                 + user.getExternalId()
@@ -213,7 +214,7 @@
       throws AccountException, IOException {
     try {
       accountManager.link(identifiedUser.get().getAccountId(), areq);
-    } catch (OrmException e) {
+    } catch (OrmException | ConfigInvalidException e) {
       log.error(
           "Cannot link: "
               + user.getExternalId()
diff --git a/gerrit-oauth/src/main/resources/com/google/gerrit/httpd/auth/oauth/LoginForm.html b/gerrit-oauth/src/main/resources/com/google/gerrit/httpd/auth/oauth/LoginForm.html
index f7814c0..67c40c3 100644
--- a/gerrit-oauth/src/main/resources/com/google/gerrit/httpd/auth/oauth/LoginForm.html
+++ b/gerrit-oauth/src/main/resources/com/google/gerrit/httpd/auth/oauth/LoginForm.html
@@ -24,7 +24,7 @@
     </style>
     <style id="gerrit_sitecss" type="text/css"></style>
   </head>
-  <body>
+  <body class="login" id="login_oauth">
     <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-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
index 6202cfc..b083d01 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/LoginForm.java
@@ -53,9 +53,10 @@
 import org.w3c.dom.Element;
 
 /** Handles OpenID based login flow. */
-@SuppressWarnings("serial")
 @Singleton
 class LoginForm extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private static final Logger log = LoggerFactory.getLogger(LoginForm.class);
   private static final ImmutableMap<String, String> ALL_PROVIDERS =
       ImmutableMap.of(
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
index 0fdd20a..878f9ee 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OAuthSessionOverOpenID.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 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.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -44,6 +44,7 @@
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -169,7 +170,7 @@
           log.debug("Claimed account already exists: link to it.");
           try {
             accountManager.link(claimedId.get(), areq);
-          } catch (OrmException e) {
+          } catch (OrmException | ConfigInvalidException e) {
             log.error(
                 "Cannot link: "
                     + user.getExternalId()
@@ -188,7 +189,7 @@
         try {
           log.debug("Linking \"{}\" to \"{}\"", user.getExternalId(), accountId);
           accountManager.link(accountId, areq);
-        } catch (OrmException e) {
+        } catch (OrmException | ConfigInvalidException e) {
           log.error("Cannot link: " + user.getExternalId() + " to user identity: " + accountId);
           rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
           return;
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
index 1406267..1b0fe9e 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdLoginServlet.java
@@ -23,25 +23,24 @@
 import javax.servlet.http.HttpServletResponse;
 
 /** Handles the {@code /OpenID} URL for web based single-sign-on. */
-@SuppressWarnings("serial")
 @Singleton
 class OpenIdLoginServlet extends HttpServlet {
+  private static final long serialVersionUID = 1L;
+
   private final OpenIdServiceImpl impl;
 
   @Inject
-  OpenIdLoginServlet(final OpenIdServiceImpl i) {
+  OpenIdLoginServlet(OpenIdServiceImpl i) {
     impl = i;
   }
 
   @Override
-  public void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  public void doGet(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     doPost(req, rsp);
   }
 
   @Override
-  public void doPost(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  public void doPost(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     try {
       CacheHeaders.setNotCacheable(rsp);
       impl.doAuth(req, rsp);
diff --git a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
index c4db3c7..fd42e82 100644
--- a/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
+++ b/gerrit-openid/src/main/java/com/google/gerrit/httpd/auth/openid/OpenIdServiceImpl.java
@@ -26,7 +26,7 @@
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.ConfigUtil;
@@ -99,12 +99,12 @@
 
   @Inject
   OpenIdServiceImpl(
-      final DynamicItem<WebSession> cf,
-      final Provider<IdentifiedUser> iu,
+      DynamicItem<WebSession> cf,
+      Provider<IdentifiedUser> iu,
       CanonicalWebUrl up,
-      @GerritServerConfig final Config config,
-      final AuthConfig ac,
-      final AccountManager am,
+      @GerritServerConfig Config config,
+      AuthConfig ac,
+      AccountManager am,
       ProxyProperties proxyProperties) {
 
     if (proxyProperties.getProxyUrl() != null) {
@@ -139,9 +139,9 @@
   DiscoveryResult discover(
       HttpServletRequest req,
       String openidIdentifier,
-      final SignInMode mode,
-      final boolean remember,
-      final String returnToken) {
+      SignInMode mode,
+      boolean remember,
+      String returnToken) {
     final State state;
     state = init(req, openidIdentifier, mode, remember, returnToken);
     if (state == null) {
@@ -183,7 +183,7 @@
     return new DiscoveryResult(aReq.getDestinationUrl(false), aReq.getParameterMap());
   }
 
-  private boolean requestRegistration(final AuthRequest aReq) {
+  private boolean requestRegistration(AuthRequest aReq) {
     if (AuthRequest.SELECT_ID.equals(aReq.getIdentity())) {
       // We don't know anything about the identity, as the provider
       // will offer the user a way to indicate their identity. Skip
@@ -204,7 +204,7 @@
   }
 
   /** Called by {@link OpenIdLoginServlet} doGet, doPost */
-  void doAuth(final HttpServletRequest req, final HttpServletResponse rsp) throws Exception {
+  void doAuth(HttpServletRequest req, HttpServletResponse rsp) throws Exception {
     if (OMODE_CANCEL.equals(req.getParameter(OPENID_MODE))) {
       cancel(req, rsp);
       return;
@@ -459,7 +459,7 @@
     }
   }
 
-  private boolean isSignIn(final SignInMode mode) {
+  private boolean isSignIn(SignInMode mode) {
     switch (mode) {
       case SIGN_IN:
       case REGISTER:
@@ -470,7 +470,7 @@
     }
   }
 
-  private static SignInMode signInMode(final HttpServletRequest req) {
+  private static SignInMode signInMode(HttpServletRequest req) {
     try {
       return SignInMode.valueOf(req.getParameter(P_MODE));
     } catch (RuntimeException e) {
@@ -478,8 +478,7 @@
     }
   }
 
-  private void callback(
-      final boolean isNew, final HttpServletRequest req, final HttpServletResponse rsp)
+  private void callback(final boolean isNew, HttpServletRequest req, HttpServletResponse rsp)
       throws IOException {
     String token = req.getParameter(P_TOKEN);
     if (token == null || token.isEmpty() || token.startsWith("/SignInFailure,")) {
@@ -500,8 +499,7 @@
     rsp.sendRedirect(rdr.toString());
   }
 
-  private void cancel(final HttpServletRequest req, final HttpServletResponse rsp)
-      throws IOException {
+  private void cancel(HttpServletRequest req, HttpServletResponse rsp) throws IOException {
     if (isSignIn(signInMode(req))) {
       webSession.get().logout();
     }
@@ -509,7 +507,7 @@
   }
 
   private void cancelWithError(
-      final HttpServletRequest req, final HttpServletResponse rsp, final String errorDetail)
+      final HttpServletRequest req, HttpServletResponse rsp, String errorDetail)
       throws IOException {
     final SignInMode mode = signInMode(req);
     if (isSignIn(mode)) {
@@ -559,8 +557,8 @@
     return new State(discovered, retTo, contextUrl);
   }
 
-  boolean isAllowedOpenID(final String id) {
-    for (final OpenIdProviderPattern pattern : allowedOpenIDs) {
+  boolean isAllowedOpenID(String id) {
+    for (OpenIdProviderPattern pattern : allowedOpenIDs) {
       if (pattern.matches(id)) {
         return true;
       }
@@ -573,7 +571,7 @@
     final UrlEncoded retTo;
     final String contextUrl;
 
-    State(final DiscoveryInformation d, final UrlEncoded r, final String c) {
+    State(DiscoveryInformation d, UrlEncoded r, String c) {
       discovered = d;
       retTo = r;
       contextUrl = c;
diff --git a/gerrit-openid/src/main/resources/com/google/gerrit/httpd/auth/openid/LoginForm.html b/gerrit-openid/src/main/resources/com/google/gerrit/httpd/auth/openid/LoginForm.html
index 07e09f5..4923143 100644
--- a/gerrit-openid/src/main/resources/com/google/gerrit/httpd/auth/openid/LoginForm.html
+++ b/gerrit-openid/src/main/resources/com/google/gerrit/httpd/auth/openid/LoginForm.html
@@ -39,7 +39,7 @@
     </style>
     <style id="gerrit_sitecss" type="text/css"></style>
   </head>
-  <body>
+  <body class="login" id="login_openid">
     <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-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java b/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
index 48890dd..33dd609 100644
--- a/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
+++ b/gerrit-patch-commonsnet/src/main/java/org/apache/commons/net/smtp/AuthSMTPClient.java
@@ -38,15 +38,15 @@
 public class AuthSMTPClient extends SMTPClient {
   private String authTypes;
 
-  public AuthSMTPClient(final String charset) {
+  public AuthSMTPClient(String charset) {
     super(charset);
   }
 
-  public void enableSSL(final boolean verify) {
+  public void enableSSL(boolean verify) {
     _socketFactory_ = sslFactory(verify);
   }
 
-  public boolean startTLS(final String hostname, final int port, final boolean verify)
+  public boolean startTLS(String hostname, int port, boolean verify)
       throws SocketException, IOException {
     if (sendCommand("STARTTLS") != 220) {
       return false;
@@ -74,7 +74,7 @@
     return true;
   }
 
-  private static SSLSocketFactory sslFactory(final boolean verify) {
+  private static SSLSocketFactory sslFactory(boolean verify) {
     if (verify) {
       return (SSLSocketFactory) SSLSocketFactory.getDefault();
     }
@@ -168,7 +168,7 @@
     '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f'
   };
 
-  private String toHex(final byte[] b) {
+  private String toHex(byte[] b) {
     final StringBuilder sec = new StringBuilder();
     for (byte c : b) {
       final int u = (c >> 4) & 0xf;
@@ -186,7 +186,7 @@
     return SMTPReply.isPositiveCompletion(sendCommand("AUTH", cmd));
   }
 
-  private static String encodeBase64(final byte[] data) {
+  private static String encodeBase64(byte[] data) {
     return new String(Base64.encodeBase64(data), UTF_8);
   }
 }
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/EditDeserializer.java b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/EditDeserializer.java
index 8090f60..9435979 100644
--- a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/EditDeserializer.java
+++ b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/EditDeserializer.java
@@ -29,8 +29,7 @@
 
 public class EditDeserializer implements JsonDeserializer<Edit>, JsonSerializer<Edit> {
   @Override
-  public Edit deserialize(
-      final JsonElement json, final Type typeOfT, final JsonDeserializationContext context)
+  public Edit deserialize(final JsonElement json, Type typeOfT, JsonDeserializationContext context)
       throws JsonParseException {
     if (json.isJsonNull()) {
       return null;
@@ -60,7 +59,7 @@
     return new ReplaceEdit(get(o, 0), get(o, 1), get(o, 2), get(o, 3), l);
   }
 
-  private static int get(final JsonArray a, final int idx) throws JsonParseException {
+  private static int get(JsonArray a, int idx) throws JsonParseException {
     final JsonElement v = a.get(idx);
     if (!v.isJsonPrimitive()) {
       throw new JsonParseException("Expected array of 4 for Edit type");
@@ -73,8 +72,7 @@
   }
 
   @Override
-  public JsonElement serialize(
-      final Edit src, final Type typeOfSrc, final JsonSerializationContext context) {
+  public JsonElement serialize(final Edit src, Type typeOfSrc, JsonSerializationContext context) {
     if (src == null) {
       return JsonNull.INSTANCE;
     }
@@ -88,7 +86,7 @@
     return a;
   }
 
-  private void add(final JsonArray a, final Edit src) {
+  private void add(JsonArray a, Edit src) {
     a.add(new JsonPrimitive(src.getBeginA()));
     a.add(new JsonPrimitive(src.getEndA()));
     a.add(new JsonPrimitive(src.getBeginB()));
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
index ce8a9f3..184cb36 100644
--- a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
+++ b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/diff/Edit_JsonSerializer.java
@@ -46,7 +46,7 @@
   }
 
   @Override
-  public void printJson(final StringBuilder sb, final Edit o) {
+  public void printJson(StringBuilder sb, Edit o) {
     sb.append('[');
     append(sb, o);
     if (o instanceof ReplaceEdit) {
@@ -58,7 +58,7 @@
     sb.append(']');
   }
 
-  private void append(final StringBuilder sb, final Edit o) {
+  private void append(StringBuilder sb, Edit o) {
     sb.append(o.getBeginA());
     sb.append(',');
     sb.append(o.getEndA());
diff --git a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/lib/ObjectIdSerialization.java b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/lib/ObjectIdSerialization.java
index 6617793..c98da64 100644
--- a/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/lib/ObjectIdSerialization.java
+++ b/gerrit-patch-jgit/src/main/java/org/eclipse/jgit/lib/ObjectIdSerialization.java
@@ -20,8 +20,7 @@
 import org.eclipse.jgit.util.IO;
 
 public class ObjectIdSerialization {
-  public static void writeCanBeNull(final OutputStream out, final AnyObjectId id)
-      throws IOException {
+  public static void writeCanBeNull(OutputStream out, AnyObjectId id) throws IOException {
     if (id != null) {
       out.write((byte) 1);
       writeNotNull(out, id);
@@ -30,11 +29,11 @@
     }
   }
 
-  public static void writeNotNull(final OutputStream out, final AnyObjectId id) throws IOException {
+  public static void writeNotNull(OutputStream out, AnyObjectId id) throws IOException {
     id.copyRawTo(out);
   }
 
-  public static ObjectId readCanBeNull(final InputStream in) throws IOException {
+  public static ObjectId readCanBeNull(InputStream in) throws IOException {
     switch (in.read()) {
       case 0:
         return null;
@@ -45,7 +44,7 @@
     }
   }
 
-  public static ObjectId readNotNull(final InputStream in) throws IOException {
+  public static ObjectId readNotNull(InputStream in) throws IOException {
     final byte[] b = new byte[20];
     IO.readFully(in, b, 0, 20);
     return ObjectId.fromRaw(b);
diff --git a/gerrit-pgm/BUILD b/gerrit-pgm/BUILD
index ee977fa..71ea5bd 100644
--- a/gerrit-pgm/BUILD
+++ b/gerrit-pgm/BUILD
@@ -8,6 +8,7 @@
 INIT_API_SRCS = glob([SRCS + "init/api/*.java"])
 
 BASE_JETTY_DEPS = [
+    "//gerrit-common:annotations",
     "//gerrit-common:server",
     "//gerrit-extension-api:api",
     "//gerrit-gwtexpui:linker_server",
@@ -27,6 +28,10 @@
 
 DEPS = BASE_JETTY_DEPS + [
     "//gerrit-reviewdb:server",
+    "//gerrit-server:metrics",
+    "//gerrit-server:module",
+    "//gerrit-server:receive",
+    "//lib:gwtorm",
     "//lib/log:jsonevent-layout",
 ]
 
@@ -34,7 +39,7 @@
     name = "init-api",
     srcs = INIT_API_SRCS,
     visibility = ["//visibility:public"],
-    deps = DEPS + ["//gerrit-common:annotations"],
+    deps = DEPS,
 )
 
 java_library(
@@ -45,14 +50,13 @@
     deps = DEPS + [
         ":init-api",
         ":util",
-        "//gerrit-common:annotations",
+        "//gerrit-index:index",
         "//gerrit-elasticsearch:elasticsearch",
         "//gerrit-launcher:launcher",  # We want this dep to be provided_deps
         "//gerrit-lucene:lucene",
         "//lib:args4j",
         "//lib:derby",
         "//lib:gwtjsonrpc",
-        "//lib:gwtorm",
         "//lib:h2",
         "//lib/commons:validator",
         "//lib/mina:sshd",
@@ -64,7 +68,6 @@
     "//gerrit-cache-mem:mem",
     "//gerrit-util-cli:cli",
     "//lib:args4j",
-    "//lib:gwtorm",
     "//lib/commons:dbcp",
 ]
 
@@ -116,11 +119,11 @@
     "//gerrit-cache-mem:mem",
     "//gerrit-elasticsearch:elasticsearch",
     "//gerrit-gpg:gpg",
+    "//gerrit-index:index",
     "//gerrit-lucene:lucene",
     "//gerrit-oauth:oauth",
     "//gerrit-openid:openid",
     "//lib:args4j",
-    "//lib:gwtorm",
     "//lib:protobuf",
     "//lib:servlet-api-3_1-without-neverlink",
     "//lib/prolog:cafeteria",
@@ -158,6 +161,7 @@
     name = "pgm_tests",
     srcs = glob(["src/test/java/**/*.java"]),
     deps = [
+        ":http-jetty",
         ":init",
         ":init-api",
         ":pgm",
@@ -165,6 +169,7 @@
         "//gerrit-server:server",
         "//lib:guava",
         "//lib:junit",
+        "//lib:truth",
         "//lib/easymock",
         "//lib/guice",
         "//lib/jgit/org.eclipse.jgit:jgit",
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index 8327513..f818d68 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.pgm;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
+import com.google.gerrit.audit.AuditModule;
 import com.google.gerrit.common.EventBroker;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
@@ -51,6 +54,7 @@
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.StartupChecks;
+import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -66,20 +70,25 @@
 import com.google.gerrit.server.config.RestCacheAdminModule;
 import com.google.gerrit.server.events.StreamEventsApiListener;
 import com.google.gerrit.server.git.GarbageCollectionModule;
-import com.google.gerrit.server.git.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.index.DummyIndexModule;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.OnlineUpgrader;
+import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
 import com.google.gerrit.server.mail.receive.MailReceiver;
 import com.google.gerrit.server.mail.send.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
+import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
+import com.google.gerrit.server.notedb.rebuild.OnlineNoteDbMigrator;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginModule;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
 import com.google.gerrit.server.schema.JdbcAccountPatchReviewStore;
@@ -113,6 +122,7 @@
 import javax.servlet.http.HttpServletRequest;
 import org.eclipse.jgit.lib.Config;
 import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -163,6 +173,15 @@
   @Option(name = "--stop-only", usage = "Stop the daemon", hidden = true)
   private boolean stopOnly;
 
+  @Option(
+      name = "--migrate-to-note-db",
+      usage = "Automatically migrate changes to NoteDb",
+      handler = ExplicitBooleanOptionHandler.class)
+  private boolean migrateToNoteDb;
+
+  @Option(name = "--trial", usage = "(With --migrate-to-note-db) " + MigrateToNoteDb.TRIAL_USAGE)
+  private boolean trial;
+
   private final LifecycleManager manager = new LifecycleManager();
   private Injector dbInjector;
   private Injector cfgInjector;
@@ -172,9 +191,11 @@
   private Injector webInjector;
   private Injector httpdInjector;
   private Path runFile;
-  private boolean test;
+  private boolean inMemoryTest;
   private AbstractModule luceneModule;
   private Module emailModule;
+  private Module testSysModule;
+  private Module auditEventModule;
 
   private Runnable serverStarted;
   private IndexType indexType;
@@ -192,6 +213,11 @@
     sshd = enable;
   }
 
+  @VisibleForTesting
+  public boolean getEnableSshd() {
+    return sshd;
+  }
+
   public void setEnableHttpd(boolean enable) {
     httpd = enable;
   }
@@ -233,12 +259,9 @@
     try {
       start();
       RuntimeShutdown.add(
-          new Runnable() {
-            @Override
-            public void run() {
-              log.info("caught shutdown, cleaning up");
-              stop();
-            }
+          () -> {
+            log.info("caught shutdown, cleaning up");
+            stop();
           });
 
       log.info("Gerrit Code Review " + myVersion() + " ready");
@@ -280,7 +303,7 @@
   @VisibleForTesting
   public void setDatabaseForTesting(List<Module> modules) {
     dbInjector = Guice.createInjector(Stage.PRODUCTION, modules);
-    test = true;
+    inMemoryTest = true;
     headless = true;
   }
 
@@ -290,9 +313,19 @@
   }
 
   @VisibleForTesting
+  public void setAuditEventModuleForTesting(Module module) {
+    auditEventModule = module;
+  }
+
+  @VisibleForTesting
   public void setLuceneModule(LuceneIndexModule m) {
     luceneModule = m;
-    test = true;
+    inMemoryTest = true;
+  }
+
+  @VisibleForTesting
+  public void setAdditionalSysModuleForTesting(@Nullable Module m) {
+    testSysModule = m;
   }
 
   @VisibleForTesting
@@ -357,19 +390,6 @@
     modules.add(new DropWizardMetricMaker.RestModule());
     modules.add(new LogFileCompressor.Module());
 
-    // Plugin module needs to be inserted *before* the index module.
-    // There is the concept of LifecycleModule, in Gerrit's own extension
-    // to Guice, which has these:
-    //  listener().to(SomeClassImplementingLifecycleListener.class);
-    // and the start() methods of each such listener are executed in the
-    // order they are declared.
-    // Makes sure that PluginLoader.start() is executed before the
-    // LuceneIndexModule.start() so that plugins get loaded and the respective
-    // Guice modules installed so that the on-line reindexing will happen
-    // with the proper classes (e.g. group backends, custom Prolog
-    // predicates) and the associated rules ready to be evaluated.
-    modules.add(new PluginModule());
-
     // Index module shutdown must happen before work queue shutdown, otherwise
     // work queue can get stuck waiting on index futures that will never return.
     modules.add(createIndexModule());
@@ -378,7 +398,7 @@
     modules.add(new StreamEventsApiListener.Module());
     modules.add(new EventBroker.Module());
     modules.add(
-        test
+        inMemoryTest
             ? new InMemoryAccountPatchReviewStore.Module()
             : new JdbcAccountPatchReviewStore.Module(config));
     modules.add(new ReceiveCommitsExecutorModule());
@@ -387,6 +407,7 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new SearchingChangeCacheImpl.Module(slave));
     modules.add(new InternalAccountDirectory.Module());
+    modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultMemoryCacheModule());
     modules.add(new H2CacheModule());
     modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
@@ -395,7 +416,18 @@
     } else {
       modules.add(new SmtpEmailSender.Module());
     }
+    if (auditEventModule != null) {
+      modules.add(auditEventModule);
+    } else {
+      modules.add(new AuditModule());
+    }
     modules.add(new SignedTokenEmailTokenVerifier.Module());
+    modules.add(new PluginModule());
+    if (VersionManager.getOnlineUpgrade(config)
+        // Schema upgrade is handled by OnlineNoteDbMigrator in this case.
+        && !migrateToNoteDb()) {
+      modules.add(new OnlineUpgrader.Module());
+    }
     modules.add(new PluginRestApiModule());
     modules.add(new RestCacheAdminModule());
     modules.add(new GpgModule(config));
@@ -428,7 +460,7 @@
           protected void configure() {
             bind(GerritOptions.class)
                 .toInstance(new GerritOptions(config, headless, slave, polyGerritDev));
-            if (test) {
+            if (inMemoryTest) {
               bind(String.class)
                   .annotatedWith(SecureStoreClassName.class)
                   .toInstance(DefaultSecureStore.class.getName());
@@ -438,12 +470,24 @@
         });
     modules.add(new GarbageCollectionModule());
     if (!slave) {
+      modules.add(new AccountDeactivator.Module());
       modules.add(new ChangeCleanupRunner.Module());
     }
+    modules.addAll(LibModuleLoader.loadModules(cfgInjector));
+    if (migrateToNoteDb()) {
+      modules.add(new OnlineNoteDbMigrator.Module(trial));
+    }
+    if (testSysModule != null) {
+      modules.add(testSysModule);
+    }
     return cfgInjector.createChildInjector(
         ModuleOverloader.override(modules, LibModuleLoader.loadModules(cfgInjector)));
   }
 
+  private boolean migrateToNoteDb() {
+    return migrateToNoteDb || NoteDbMigrator.getAutoMigrate(checkNotNull(config));
+  }
+
   private Module createIndexModule() {
     if (slave) {
       return new DummyIndexModule();
@@ -453,9 +497,9 @@
     }
     switch (indexType) {
       case LUCENE:
-        return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+        return LuceneIndexModule.latestVersion();
       case ELASTICSEARCH:
-        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
+        return ElasticIndexModule.latestVersion();
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
@@ -481,7 +525,7 @@
   private Injector createSshInjector() {
     final List<Module> modules = new ArrayList<>();
     modules.add(sysInjector.getInstance(SshModule.class));
-    if (!test) {
+    if (!inMemoryTest) {
       modules.add(new SshHostKeyModule());
     }
     modules.add(
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
index b1a50d7..004486b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Gsql.java
@@ -45,16 +45,13 @@
     manager.add(dbInjector);
     manager.start();
     RuntimeShutdown.add(
-        new Runnable() {
-          @Override
-          public void run() {
-            try {
-              System.in.close();
-            } catch (IOException e) {
-              // Ignored
-            }
-            manager.stop();
+        () -> {
+          try {
+            System.in.close();
+          } catch (IOException e) {
+            // Ignored
           }
+          manager.stop();
         });
     final QueryShell shell = shellFactory().create(System.in, System.out);
     shell.setOutputFormat(format);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java
index e740ec8..e1a7bd4 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/JythonShell.java
@@ -165,7 +165,7 @@
     execFile(GerritLauncher.getHomeDirectory(), STARTUP_FILE);
   }
 
-  protected void execResource(final String p) {
+  protected void execResource(String p) {
     try (InputStream in = JythonShell.class.getClassLoader().getResourceAsStream(p)) {
       if (in != null) {
         execStream(in, "resource " + p);
@@ -177,7 +177,7 @@
     }
   }
 
-  protected void execFile(final File parent, final String p) {
+  protected void execFile(File parent, String p) {
     try {
       File script = new File(parent, p);
       if (script.canExecute()) {
@@ -200,7 +200,7 @@
     }
   }
 
-  protected void execStream(final InputStream in, final String p) {
+  protected void execStream(InputStream in, String p) {
     try {
       runMethod0(
           console,
@@ -213,7 +213,7 @@
     }
   }
 
-  private static UnsupportedOperationException noShell(final String m, Throwable why) {
+  private static UnsupportedOperationException noShell(String m, Throwable why) {
     final String prefix = "Cannot create Jython shell: ";
     final String postfix = "\n     (You might need to install jython.jar in the lib directory)";
     return new UnsupportedOperationException(prefix + m + postfix, why);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
index fecd57b..385f198 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/LocalUsernamesToLowerCase.java
@@ -14,17 +14,18 @@
 
 package com.google.gerrit.pgm;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
 
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.account.ExternalIdsBatchUpdate;
+import com.google.gerrit.server.account.externalids.DisabledExternalIdCache;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.account.externalids.ExternalIdsBatchUpdate;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.schema.SchemaVersionCheck;
-import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import java.util.Collection;
@@ -37,7 +38,7 @@
   private final LifecycleManager manager = new LifecycleManager();
   private final TextProgressMonitor monitor = new TextProgressMonitor();
 
-  @Inject private SchemaFactory<ReviewDb> database;
+  @Inject private ExternalIds externalIds;
 
   @Inject private ExternalIdsBatchUpdate externalIdsBatchUpdate;
 
@@ -46,19 +47,29 @@
     Injector dbInjector = createDbInjector(MULTI_USER);
     manager.add(dbInjector, dbInjector.createChildInjector(SchemaVersionCheck.module()));
     manager.start();
-    dbInjector.injectMembers(this);
+    dbInjector
+        .createChildInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                // The LocalUsernamesToLowerCase program needs to access all external IDs only
+                // once to update them. After the update they are not accessed again. Hence the
+                // LocalUsernamesToLowerCase program doesn't benefit from caching external IDs and
+                // the external ID cache can be disabled.
+                install(DisabledExternalIdCache.module());
+              }
+            })
+        .injectMembers(this);
 
-    try (ReviewDb db = database.open()) {
-      Collection<ExternalId> todo = ExternalId.from(db.accountExternalIds().all().toList());
-      monitor.beginTask("Converting local usernames", todo.size());
+    Collection<ExternalId> todo = externalIds.all();
+    monitor.beginTask("Converting local usernames", todo.size());
 
-      for (ExternalId extId : todo) {
-        convertLocalUserToLowerCase(extId);
-        monitor.update(1);
-      }
-
-      externalIdsBatchUpdate.commit(db);
+    for (ExternalId extId : todo) {
+      convertLocalUserToLowerCase(extId);
+      monitor.update(1);
     }
+
+    externalIdsBatchUpdate.commit("Convert local usernames to lower case");
     monitor.endTask();
 
     int exitCode = reindexAccounts();
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateToNoteDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateToNoteDb.java
new file mode 100644
index 0000000..ff23747
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/MigrateToNoteDb.java
@@ -0,0 +1,218 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.pgm.util.BatchProgramModule;
+import com.google.gerrit.pgm.util.RuntimeShutdown;
+import com.google.gerrit.pgm.util.SiteProgram;
+import com.google.gerrit.pgm.util.ThreadLimiter;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.ChangeResource;
+import com.google.gerrit.server.git.GarbageCollection;
+import com.google.gerrit.server.index.DummyIndexModule;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.notedb.rebuild.GcAllUsers;
+import com.google.gerrit.server.notedb.rebuild.NoteDbMigrator;
+import com.google.gerrit.server.schema.DataSourceType;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.List;
+import org.kohsuke.args4j.Option;
+import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
+
+public class MigrateToNoteDb extends SiteProgram {
+  static final String TRIAL_USAGE =
+      "Trial mode: migrate changes and turn on reading from NoteDb, but leave ReviewDb as the"
+          + " source of truth";
+
+  private static final int ISSUE_8022_THREAD_LIMIT = 4;
+
+  @Option(name = "--threads", usage = "Number of threads to use for rebuilding NoteDb")
+  private Integer threads;
+
+  @Option(
+      name = "--project",
+      usage =
+          "Only rebuild these projects, do no other migration; incompatible with --change;"
+              + " recommended for debugging only")
+  private List<String> projects = new ArrayList<>();
+
+  @Option(
+      name = "--change",
+      usage =
+          "Only rebuild these changes, do no other migration; incompatible with --project;"
+              + " recommended for debugging only")
+  private List<Integer> changes = new ArrayList<>();
+
+  @Option(
+      name = "--force",
+      usage =
+          "Force rebuilding changes where ReviewDb is still the source of truth, even if they"
+              + " were previously migrated")
+  private boolean force;
+
+  @Option(name = "--trial", usage = TRIAL_USAGE)
+  private boolean trial;
+
+  @Option(
+      name = "--sequence-gap",
+      usage =
+          "gap in change sequence numbers between last ReviewDb number and first NoteDb number;"
+              + " negative indicates using the value of noteDb.changes.initialSequenceGap (default"
+              + " 1000)")
+  private int sequenceGap;
+
+  @Option(
+      name = "--reindex",
+      usage =
+          "Reindex all changes after migration; defaults to false in trial mode, true otherwise",
+      handler = ExplicitBooleanOptionHandler.class)
+  private Boolean reindex;
+
+  private Injector dbInjector;
+  private Injector sysInjector;
+  private LifecycleManager dbManager;
+  private LifecycleManager sysManager;
+
+  @Inject private GcAllUsers gcAllUsers;
+  @Inject private Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
+
+  @Override
+  public int run() throws Exception {
+    RuntimeShutdown.add(this::stop);
+    try {
+      mustHaveValidSite();
+      dbInjector = createDbInjector(MULTI_USER);
+
+      dbManager = new LifecycleManager();
+      dbManager.add(dbInjector);
+      dbManager.start();
+
+      threads = limitThreads();
+
+      sysInjector = createSysInjector();
+      sysInjector.injectMembers(this);
+      sysManager = new LifecycleManager();
+      sysManager.add(sysInjector);
+      sysManager.start();
+
+      try (NoteDbMigrator migrator =
+          migratorBuilderProvider
+              .get()
+              .setThreads(threads)
+              .setProgressOut(System.err)
+              .setProjects(projects.stream().map(Project.NameKey::new).collect(toList()))
+              .setChanges(changes.stream().map(Change.Id::new).collect(toList()))
+              .setTrialMode(trial)
+              .setForceRebuild(force)
+              .setSequenceGap(sequenceGap)
+              .build()) {
+        if (!projects.isEmpty() || !changes.isEmpty()) {
+          migrator.rebuild();
+        } else {
+          migrator.migrate();
+        }
+      }
+      try (PrintWriter w = new PrintWriter(new OutputStreamWriter(System.out, UTF_8), true)) {
+        gcAllUsers.run(w);
+      }
+    } finally {
+      stop();
+    }
+
+    boolean reindex = firstNonNull(this.reindex, !trial);
+    if (!reindex) {
+      return 0;
+    }
+    // Reindex all indices, to save the user from having to run yet another program by hand while
+    // their server is offline.
+    List<String> reindexArgs =
+        ImmutableList.of(
+            "--site-path",
+            getSitePath().toString(),
+            "--threads",
+            Integer.toString(threads),
+            "--index",
+            ChangeSchemaDefinitions.NAME);
+    System.out.println("Migration complete, reindexing changes with:");
+    System.out.println("  reindex " + reindexArgs.stream().collect(joining(" ")));
+    Reindex reindexPgm = new Reindex();
+    return reindexPgm.main(reindexArgs.stream().toArray(String[]::new));
+  }
+
+  private int limitThreads() {
+    if (threads != null) {
+      return threads;
+    }
+    int actualThreads;
+    int procs = Runtime.getRuntime().availableProcessors();
+    DataSourceType dsType = dbInjector.getInstance(DataSourceType.class);
+    if (dsType.getDriver().equals("org.h2.Driver") && procs > ISSUE_8022_THREAD_LIMIT) {
+      System.out.println(
+          "Not using more than "
+              + ISSUE_8022_THREAD_LIMIT
+              + " threads due to http://crbug.com/gerrit/8022");
+      System.out.println("Can be increased by passing --threads, but may cause errors");
+      actualThreads = ISSUE_8022_THREAD_LIMIT;
+    } else {
+      actualThreads = procs;
+    }
+    actualThreads = ThreadLimiter.limitThreads(dbInjector, actualThreads);
+    return actualThreads;
+  }
+
+  private Injector createSysInjector() {
+    return dbInjector.createChildInjector(
+        new FactoryModule() {
+          @Override
+          public void configure() {
+            install(dbInjector.getInstance(BatchProgramModule.class));
+            install(new DummyIndexModule());
+            factory(ChangeResource.Factory.class);
+            factory(GarbageCollection.Factory.class);
+          }
+        });
+  }
+
+  private void stop() {
+    try {
+      LifecycleManager m = sysManager;
+      sysManager = null;
+      if (m != null) {
+        m.stop();
+      }
+    } finally {
+      LifecycleManager m = dbManager;
+      dbManager = null;
+      if (m != null) {
+        m.stop();
+      }
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
index c11dae1..0732b28 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/ProtobufImport.java
@@ -84,13 +84,7 @@
     Injector dbInjector = createDbInjector(SINGLE_USER);
     manager.add(dbInjector);
     manager.start();
-    RuntimeShutdown.add(
-        new Runnable() {
-          @Override
-          public void run() {
-            manager.stop();
-          }
-        });
+    RuntimeShutdown.add(manager::stop);
     dbInjector.injectMembers(this);
 
     ProgressMonitor progress = new TextProgressMonitor();
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
deleted file mode 100644
index de8d0cb..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/RebuildNoteDb.java
+++ /dev/null
@@ -1,299 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
-import static com.google.gerrit.server.schema.DataSourceProvider.Context.MULTI_USER;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.base.Predicates;
-import com.google.common.base.Stopwatch;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Ordering;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListenableFuture;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.gerrit.common.FormatUtil;
-import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.pgm.util.BatchProgramModule;
-import com.google.gerrit.pgm.util.SiteProgram;
-import com.google.gerrit.pgm.util.ThreadLimiter;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.change.ChangeResource;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.WorkQueue;
-import com.google.gerrit.server.index.DummyIndexModule;
-import com.google.gerrit.server.index.change.ReindexAfterUpdate;
-import com.google.gerrit.server.notedb.ChangeBundleReader;
-import com.google.gerrit.server.notedb.NoteDbUpdateManager;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
-import com.google.gerrit.server.update.ChainedReceiveCommands;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import java.io.BufferedWriter;
-import java.io.IOException;
-import java.io.OutputStreamWriter;
-import java.io.PrintWriter;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.Callable;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.NullProgressMonitor;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class RebuildNoteDb extends SiteProgram {
-  private static final Logger log = LoggerFactory.getLogger(RebuildNoteDb.class);
-
-  @Option(name = "--threads", usage = "Number of threads to use for rebuilding NoteDb")
-  private int threads = Runtime.getRuntime().availableProcessors();
-
-  @Option(name = "--project", usage = "Projects to rebuild; recommended for debugging only")
-  private List<String> projects = new ArrayList<>();
-
-  @Option(
-      name = "--change",
-      usage = "Individual change numbers to rebuild; recommended for debugging only")
-  private List<Integer> changes = new ArrayList<>();
-
-  private Injector dbInjector;
-  private Injector sysInjector;
-
-  @Inject private AllUsersName allUsersName;
-
-  @Inject private ChangeRebuilder rebuilder;
-
-  @Inject @GerritServerConfig private Config cfg;
-
-  @Inject private GitRepositoryManager repoManager;
-
-  @Inject private NoteDbUpdateManager.Factory updateManagerFactory;
-
-  @Inject private NotesMigration notesMigration;
-
-  @Inject private SchemaFactory<ReviewDb> schemaFactory;
-
-  @Inject private WorkQueue workQueue;
-
-  @Inject private ChangeBundleReader bundleReader;
-
-  @Override
-  public int run() throws Exception {
-    mustHaveValidSite();
-    dbInjector = createDbInjector(MULTI_USER);
-    threads = ThreadLimiter.limitThreads(dbInjector, threads);
-
-    LifecycleManager dbManager = new LifecycleManager();
-    dbManager.add(dbInjector);
-    dbManager.start();
-
-    sysInjector = createSysInjector();
-    sysInjector.injectMembers(this);
-    if (!notesMigration.enabled()) {
-      throw die("NoteDb is not enabled.");
-    }
-    LifecycleManager sysManager = new LifecycleManager();
-    sysManager.add(sysInjector);
-    sysManager.start();
-
-    ListeningExecutorService executor = newExecutor();
-    System.out.println("Rebuilding the NoteDb");
-
-    ImmutableListMultimap<Project.NameKey, Change.Id> changesByProject = getChangesByProject();
-    boolean ok;
-    Stopwatch sw = Stopwatch.createStarted();
-    try (Repository allUsersRepo = repoManager.openRepository(allUsersName)) {
-      deleteRefs(RefNames.REFS_DRAFT_COMMENTS, allUsersRepo);
-
-      List<ListenableFuture<Boolean>> futures = new ArrayList<>();
-      List<Project.NameKey> projectNames =
-          Ordering.usingToString().sortedCopy(changesByProject.keySet());
-      for (final Project.NameKey project : projectNames) {
-        ListenableFuture<Boolean> future =
-            executor.submit(
-                new Callable<Boolean>() {
-                  @Override
-                  public Boolean call() {
-                    try (ReviewDb db = unwrapDb(schemaFactory.open())) {
-                      return rebuildProject(db, changesByProject, project, allUsersRepo);
-                    } catch (Exception e) {
-                      log.error("Error rebuilding project " + project, e);
-                      return false;
-                    }
-                  }
-                });
-        futures.add(future);
-      }
-
-      try {
-        ok = Iterables.all(Futures.allAsList(futures).get(), Predicates.equalTo(true));
-      } catch (InterruptedException | ExecutionException e) {
-        log.error("Error rebuilding projects", e);
-        ok = false;
-      }
-    }
-
-    double t = sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
-    System.out.format(
-        "Rebuild %d changes in %.01fs (%.01f/s)\n",
-        changesByProject.size(), t, changesByProject.size() / t);
-    return ok ? 0 : 1;
-  }
-
-  private static void execute(BatchRefUpdate bru, Repository repo) throws IOException {
-    try (RevWalk rw = new RevWalk(repo)) {
-      bru.execute(rw, NullProgressMonitor.INSTANCE);
-    }
-    for (ReceiveCommand command : bru.getCommands()) {
-      if (command.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException(
-            String.format("Command %s failed: %s", command.toString(), command.getResult()));
-      }
-    }
-  }
-
-  private void deleteRefs(String prefix, Repository allUsersRepo) throws IOException {
-    RefDatabase refDb = allUsersRepo.getRefDatabase();
-    Map<String, Ref> allRefs = refDb.getRefs(prefix);
-    BatchRefUpdate bru = refDb.newBatchUpdate();
-    for (Map.Entry<String, Ref> ref : allRefs.entrySet()) {
-      bru.addCommand(
-          new ReceiveCommand(
-              ref.getValue().getObjectId(), ObjectId.zeroId(), prefix + ref.getKey()));
-    }
-    execute(bru, allUsersRepo);
-  }
-
-  private Injector createSysInjector() {
-    return dbInjector.createChildInjector(
-        new FactoryModule() {
-          @Override
-          public void configure() {
-            install(dbInjector.getInstance(BatchProgramModule.class));
-            DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
-                .to(ReindexAfterUpdate.class);
-            install(new DummyIndexModule());
-            factory(ChangeResource.Factory.class);
-          }
-        });
-  }
-
-  private ListeningExecutorService newExecutor() {
-    if (threads > 0) {
-      return MoreExecutors.listeningDecorator(workQueue.createQueue(threads, "RebuildChange"));
-    }
-    return MoreExecutors.newDirectExecutorService();
-  }
-
-  private ImmutableListMultimap<Project.NameKey, Change.Id> getChangesByProject()
-      throws OrmException {
-    // Memorize all changes so we can close the db connection and allow
-    // rebuilder threads to use the full connection pool.
-    ListMultimap<Project.NameKey, Change.Id> changesByProject =
-        MultimapBuilder.hashKeys().arrayListValues().build();
-    try (ReviewDb db = schemaFactory.open()) {
-      if (projects.isEmpty() && !changes.isEmpty()) {
-        Iterable<Change> todo =
-            unwrapDb(db).changes().get(Iterables.transform(changes, Change.Id::new));
-        for (Change c : todo) {
-          changesByProject.put(c.getProject(), c.getId());
-        }
-      } else {
-        for (Change c : unwrapDb(db).changes().all()) {
-          boolean include = false;
-          if (projects.isEmpty() && changes.isEmpty()) {
-            include = true;
-          } else if (!projects.isEmpty() && projects.contains(c.getProject().get())) {
-            include = true;
-          } else if (!changes.isEmpty() && changes.contains(c.getId().get())) {
-            include = true;
-          }
-          if (include) {
-            changesByProject.put(c.getProject(), c.getId());
-          }
-        }
-      }
-      return ImmutableListMultimap.copyOf(changesByProject);
-    }
-  }
-
-  private boolean rebuildProject(
-      ReviewDb db,
-      ImmutableListMultimap<Project.NameKey, Change.Id> allChanges,
-      Project.NameKey project,
-      Repository allUsersRepo)
-      throws IOException, OrmException {
-    checkArgument(allChanges.containsKey(project));
-    boolean ok = true;
-    ProgressMonitor pm =
-        new TextProgressMonitor(
-            new PrintWriter(new BufferedWriter(new OutputStreamWriter(System.out, UTF_8))));
-    pm.beginTask(FormatUtil.elide(project.get(), 50), allChanges.get(project).size());
-    try (NoteDbUpdateManager manager = updateManagerFactory.create(project);
-        ObjectInserter allUsersInserter = allUsersRepo.newObjectInserter();
-        ObjectReader reader = allUsersInserter.newReader();
-        RevWalk allUsersRw = new RevWalk(reader)) {
-      manager.setAllUsersRepo(
-          allUsersRepo, allUsersRw, allUsersInserter, new ChainedReceiveCommands(allUsersRepo));
-      for (Change.Id changeId : allChanges.get(project)) {
-        try {
-          rebuilder.buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
-        } catch (NoPatchSetsException e) {
-          log.warn(e.getMessage());
-        } catch (Throwable t) {
-          log.error("Failed to rebuild change " + changeId, t);
-          ok = false;
-        }
-        pm.update(1);
-      }
-      manager.execute();
-    } finally {
-      pm.endTask();
-    }
-    return ok;
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
index d73207b..cdaaf17 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
@@ -22,6 +22,9 @@
 import com.google.gerrit.common.Die;
 import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
 import com.google.gerrit.pgm.util.BatchProgramModule;
@@ -29,11 +32,8 @@
 import com.google.gerrit.pgm.util.ThreadLimiter;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.Index;
-import com.google.gerrit.server.index.IndexDefinition;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
-import com.google.gerrit.server.index.SiteIndexer;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLog.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
index 9f54634..d7bc720 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLog.java
@@ -14,10 +14,18 @@
 
 package com.google.gerrit.pgm.http.jetty;
 
+import static com.google.gerrit.httpd.restapi.RestApiServlet.XD_AUTHORIZATION;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.httpd.GetUserFilter;
 import com.google.gerrit.server.util.SystemLog;
 import com.google.inject.Inject;
+import java.util.Iterator;
 import org.apache.log4j.AsyncAppender;
 import org.apache.log4j.Level;
 import org.apache.log4j.Logger;
@@ -31,6 +39,7 @@
 class HttpLog extends AbstractLifeCycle implements RequestLog {
   private static final Logger log = Logger.getLogger(HttpLog.class);
   private static final String LOG_NAME = "httpd_log";
+  private static final ImmutableSet<String> REDACT_PARAM = ImmutableSet.of(XD_AUTHORIZATION);
 
   interface HttpLogFactory {
     HttpLog get();
@@ -49,7 +58,7 @@
   private final AsyncAppender async;
 
   @Inject
-  HttpLog(final SystemLog systemLog) {
+  HttpLog(SystemLog systemLog) {
     async = systemLog.createAsyncAppender(LOG_NAME, new HttpLogLayout());
   }
 
@@ -62,7 +71,7 @@
   }
 
   @Override
-  public void log(final Request req, final Response rsp) {
+  public void log(Request req, Response rsp) {
     final LoggingEvent event =
         new LoggingEvent( //
             Logger.class.getName(), // fqnOfCategoryClass
@@ -78,12 +87,9 @@
             );
 
     String uri = req.getRequestURI();
-    String qs = req.getQueryString();
-    if (qs != null) {
-      uri = uri + "?" + qs;
-    }
+    uri = redactQueryString(uri, req.getQueryString());
 
-    String user = (String) req.getAttribute(GetUserFilter.REQ_ATTR_KEY);
+    String user = (String) req.getAttribute(GetUserFilter.USER_ATTR_KEY);
     if (user != null) {
       event.setProperty(P_USER, user);
     }
@@ -100,6 +106,31 @@
     async.append(event);
   }
 
+  @VisibleForTesting
+  static String redactQueryString(String uri, String qs) {
+    if (Strings.isNullOrEmpty(qs)) {
+      return uri;
+    }
+
+    StringBuilder b = new StringBuilder(uri);
+    boolean first = true;
+    for (String kvPair : Splitter.on('&').split(qs)) {
+      Iterator<String> i = Splitter.on('=').limit(2).split(kvPair).iterator();
+      String key = i.next();
+      b.append(first ? '?' : '&').append(key);
+      first = false;
+      if (i.hasNext()) {
+        b.append('=');
+        if (REDACT_PARAM.contains(Url.decode(key))) {
+          b.append('*');
+        } else {
+          b.append(i.next());
+        }
+      }
+    }
+    return b.toString();
+  }
+
   private static void set(LoggingEvent event, String key, String val) {
     if (val != null && !val.isEmpty()) {
       event.setProperty(key, val);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
index bfa4d64..2eea88d 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/HttpLogLayout.java
@@ -96,7 +96,7 @@
     }
   }
 
-  private void formatDate(final long now, final StringBuilder sbuf) {
+  private void formatDate(long now, StringBuilder sbuf) {
     final long rounded = now - (int) (now % 1000);
     if (rounded != lastTimeMillis) {
       synchronized (dateFormat) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyEnv.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyEnv.java
index ebca467..1d3e1702 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyEnv.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyEnv.java
@@ -19,7 +19,7 @@
 public class JettyEnv {
   final Injector webInjector;
 
-  public JettyEnv(final Injector webInjector) {
+  public JettyEnv(Injector webInjector) {
     this.webInjector = webInjector;
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyModule.java
index d356d96..c818276 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyModule.java
@@ -21,7 +21,7 @@
 public class JettyModule extends LifecycleModule {
   private final JettyEnv env;
 
-  public JettyModule(final JettyEnv env) {
+  public JettyModule(JettyEnv env) {
     this.env = env;
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 0103aae..25a28a4 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -77,7 +77,7 @@
     private final Config cfg;
 
     @Inject
-    Lifecycle(final JettyServer server, @GerritServerConfig final Config cfg) {
+    Lifecycle(JettyServer server, @GerritServerConfig Config cfg) {
       this.server = server;
       this.cfg = cfg;
     }
@@ -304,7 +304,7 @@
     return config;
   }
 
-  static boolean isReverseProxied(final URI[] listenUrls) {
+  static boolean isReverseProxied(URI[] listenUrls) {
     for (URI u : listenUrls) {
       if ("http".equals(u.getScheme()) || "https".equals(u.getScheme())) {
         return false;
@@ -313,7 +313,7 @@
     return true;
   }
 
-  static URI[] listenURLs(final Config cfg) {
+  static URI[] listenURLs(Config cfg) {
     String[] urls = cfg.getStringList("httpd", null, "listenurl");
     if (urls.length == 0) {
       urls = new String[] {"http://*:8080/"};
@@ -359,7 +359,7 @@
     return pool;
   }
 
-  private Handler makeContext(final JettyEnv env, final Config cfg) {
+  private Handler makeContext(JettyEnv env, Config cfg) {
     final Set<String> paths = new HashSet<>();
     for (URI u : listenURLs(cfg)) {
       String p = u.getPath();
@@ -392,8 +392,7 @@
     return r;
   }
 
-  private ContextHandler makeContext(
-      final String contextPath, final JettyEnv env, final Config cfg) {
+  private ContextHandler makeContext(final String contextPath, JettyEnv env, Config cfg) {
     final ServletContextHandler app = new ServletContextHandler();
 
     // This enables the use of sessions in Jetty, feature available
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
index ebf3686..4d2bb41 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/http/jetty/ProjectQoSFilter.java
@@ -19,9 +19,9 @@
 import static javax.servlet.http.HttpServletResponse.SC_SERVICE_UNAVAILABLE;
 
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.QueueProvider;
-import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
 import com.google.gerrit.sshd.CommandExecutorQueueProvider;
 import com.google.inject.Inject;
@@ -29,6 +29,8 @@
 import com.google.inject.Singleton;
 import com.google.inject.servlet.ServletModule;
 import java.io.IOException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import javax.servlet.Filter;
@@ -68,7 +70,6 @@
   private static final Pattern URI_PATTERN = Pattern.compile(FILTER_RE);
 
   public static class Module extends ServletModule {
-
     @Override
     protected void configureServlets() {
       bind(QueueProvider.class).to(CommandExecutorQueueProvider.class);
@@ -76,18 +77,20 @@
     }
   }
 
+  private final AccountLimits.Factory limitsFactory;
   private final Provider<CurrentUser> user;
   private final QueueProvider queue;
-
   private final ServletContext context;
   private final long maxWait;
 
   @Inject
   ProjectQoSFilter(
-      final Provider<CurrentUser> user,
+      AccountLimits.Factory limitsFactory,
+      Provider<CurrentUser> user,
       QueueProvider queue,
-      final ServletContext context,
-      @GerritServerConfig final Config cfg) {
+      ServletContext context,
+      @GerritServerConfig Config cfg) {
+    this.limitsFactory = limitsFactory;
     this.user = user;
     this.queue = queue;
     this.context = context;
@@ -101,18 +104,16 @@
     final HttpServletResponse rsp = (HttpServletResponse) response;
     final Continuation cont = ContinuationSupport.getContinuation(req);
 
-    WorkQueue.Executor executor = getExecutor();
-
     if (cont.isInitial()) {
-      TaskThunk task = new TaskThunk(executor, cont, req);
+      TaskThunk task = new TaskThunk(cont, req);
       if (maxWait > 0) {
         cont.setTimeout(maxWait);
       }
       cont.suspend(rsp);
-      cont.addContinuationListener(task);
       cont.setAttribute(TASK, task);
-      executor.submit(task);
 
+      Future<?> f = getExecutor().submit(task);
+      cont.addContinuationListener(new Listener(f));
     } else if (cont.isExpired()) {
       rsp.sendError(SC_SERVICE_UNAVAILABLE);
 
@@ -135,8 +136,9 @@
     }
   }
 
-  private WorkQueue.Executor getExecutor() {
-    return queue.getQueue(user.get().getCapabilities().getQueueType());
+  private ScheduledThreadPoolExecutor getExecutor() {
+    QueueProvider.QueueType qt = limitsFactory.create(user.get()).getQueueType();
+    return queue.getQueue(qt);
   }
 
   @Override
@@ -145,18 +147,30 @@
   @Override
   public void destroy() {}
 
-  private final class TaskThunk implements CancelableRunnable, ContinuationListener {
+  private static final class Listener implements ContinuationListener {
+    final Future<?> future;
 
-    private final WorkQueue.Executor executor;
+    Listener(Future<?> future) {
+      this.future = future;
+    }
+
+    @Override
+    public void onComplete(Continuation self) {}
+
+    @Override
+    public void onTimeout(Continuation self) {
+      future.cancel(true);
+    }
+  }
+
+  private final class TaskThunk implements CancelableRunnable {
     private final Continuation cont;
     private final String name;
     private final Object lock = new Object();
     private boolean done;
     private Thread worker;
 
-    TaskThunk(
-        final WorkQueue.Executor executor, final Continuation cont, final HttpServletRequest req) {
-      this.executor = executor;
+    TaskThunk(Continuation cont, HttpServletRequest req) {
       this.cont = cont;
       this.name = generateName(req);
     }
@@ -201,14 +215,6 @@
     }
 
     @Override
-    public void onComplete(Continuation self) {}
-
-    @Override
-    public void onTimeout(Continuation self) {
-      executor.remove(this);
-    }
-
-    @Override
     public String toString() {
       return name;
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java
new file mode 100644
index 0000000..2beb50a
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AccountsOnInit.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import org.eclipse.jgit.dircache.DirCache;
+import org.eclipse.jgit.dircache.DirCacheEditor;
+import org.eclipse.jgit.dircache.DirCacheEditor.PathEdit;
+import org.eclipse.jgit.dircache.DirCacheEntry;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.FileMode;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.util.FS;
+
+public class AccountsOnInit {
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String allUsers;
+
+  @Inject
+  public AccountsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+    this.flags = flags;
+    this.site = site;
+    this.allUsers = allUsers.get();
+  }
+
+  public void insert(Account account) throws IOException {
+    File path = getPath();
+    if (path != null) {
+      try (Repository repo = new FileRepository(path);
+          ObjectInserter oi = repo.newObjectInserter()) {
+        PersonIdent ident =
+            new PersonIdent(
+                new GerritPersonIdentProvider(flags.cfg).get(), account.getRegisteredOn());
+
+        Config accountConfig = new Config();
+        AccountConfig.writeToConfig(account, accountConfig);
+
+        DirCache newTree = DirCache.newInCore();
+        DirCacheEditor editor = newTree.editor();
+        final ObjectId blobId =
+            oi.insert(Constants.OBJ_BLOB, accountConfig.toText().getBytes(UTF_8));
+        editor.add(
+            new PathEdit(AccountConfig.ACCOUNT_CONFIG) {
+              @Override
+              public void apply(DirCacheEntry ent) {
+                ent.setFileMode(FileMode.REGULAR_FILE);
+                ent.setObjectId(blobId);
+              }
+            });
+        editor.finish();
+
+        ObjectId treeId = newTree.writeTree(oi);
+
+        CommitBuilder cb = new CommitBuilder();
+        cb.setTreeId(treeId);
+        cb.setCommitter(ident);
+        cb.setAuthor(ident);
+        cb.setMessage("Create Account");
+        ObjectId id = oi.insert(cb);
+        oi.flush();
+
+        String refName = RefNames.refsUsers(account.getId());
+        RefUpdate ru = repo.updateRef(refName);
+        ru.setExpectedOldObjectId(ObjectId.zeroId());
+        ru.setNewObjectId(id);
+        ru.setRefLogIdent(ident);
+        ru.setRefLogMessage("Create Account", false);
+        Result result = ru.update();
+        if (result != Result.NEW) {
+          throw new IOException(
+              String.format("Failed to update ref %s: %s", refName, result.name()));
+        }
+        account.setMetaId(id.name());
+      }
+    }
+  }
+
+  public boolean hasAnyAccount() throws IOException {
+    File path = getPath();
+    if (path == null) {
+      return false;
+    }
+
+    try (Repository repo = new FileRepository(path)) {
+      return Accounts.hasAnyAccount(repo);
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    checkArgument(basePath != null, "gerrit.basePath must be configured");
+    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AllUsersNameOnInitProvider.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AllUsersNameOnInitProvider.java
deleted file mode 100644
index 2ace787..0000000
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/AllUsersNameOnInitProvider.java
+++ /dev/null
@@ -1,37 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.pgm.init;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.base.Strings;
-import com.google.gerrit.pgm.init.api.Section;
-import com.google.gerrit.server.config.AllUsersNameProvider;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-
-public class AllUsersNameOnInitProvider implements Provider<String> {
-  private final String name;
-
-  @Inject
-  AllUsersNameOnInitProvider(Section.Factory sections) {
-    String n = sections.get("gerrit", null).get("allUsers");
-    name = MoreObjects.firstNonNull(Strings.emptyToNull(n), AllUsersNameProvider.DEFAULT);
-  }
-
-  @Override
-  public String get() {
-    return name;
-  }
-}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
index ae5a598..a5b4f46 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.plugins.JarScanner;
+import com.google.gerrit.server.schema.ReviewDbFactory;
 import com.google.gerrit.server.schema.SchemaUpdater;
 import com.google.gerrit.server.schema.UpdateUI;
 import com.google.gerrit.server.securestore.SecureStore;
@@ -73,6 +74,7 @@
 import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
+import java.util.Set;
 import javax.sql.DataSource;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -129,6 +131,7 @@
     init.flags.dev = isDev() && init.site.isNew;
     init.flags.skipPlugins = skipPlugins();
     init.flags.deleteCaches = getDeleteCaches();
+    init.flags.isNew = init.site.isNew;
 
     final SiteRun run;
     try {
@@ -371,7 +374,7 @@
         SitePaths site,
         InitFlags flags,
         SchemaUpdater schemaUpdater,
-        SchemaFactory<ReviewDb> schema,
+        @ReviewDbFactory SchemaFactory<ReviewDb> schema,
         GitRepositoryManager repositoryManager) {
       this.ui = ui;
       this.site = site;
@@ -386,14 +389,25 @@
       schemaUpdater.update(
           new UpdateUI() {
             @Override
-            public void message(String msg) {
-              System.err.println(msg);
+            public void message(String message) {
+              System.err.println(message);
               System.err.flush();
             }
 
             @Override
-            public boolean yesno(boolean def, String msg) {
-              return ui.yesno(def, msg);
+            public boolean yesno(boolean defaultValue, String message) {
+              return ui.yesno(defaultValue, message);
+            }
+
+            @Override
+            public void waitForUser() {
+              ui.waitForUser();
+            }
+
+            @Override
+            public String readString(
+                String defaultValue, Set<String> allowedValues, String message) {
+              return ui.readString(defaultValue, allowedValues, message);
             }
 
             @Override
@@ -437,11 +451,11 @@
     }
   }
 
-  private SiteRun createSiteRun(final SiteInit init) {
+  private SiteRun createSiteRun(SiteInit init) {
     return createSysInjector(init).getInstance(SiteRun.class);
   }
 
-  private Injector createSysInjector(final SiteInit init) {
+  private Injector createSysInjector(SiteInit init) {
     if (sysInjector == null) {
       final List<Module> modules = new ArrayList<>();
       modules.add(
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Browser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Browser.java
index 8868a31..2e49e13 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Browser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Browser.java
@@ -29,7 +29,7 @@
   private final Config cfg;
 
   @Inject
-  Browser(@GerritServerConfig final Config cfg) {
+  Browser(@GerritServerConfig Config cfg) {
     this.cfg = cfg;
   }
 
@@ -37,7 +37,7 @@
     open(null /* root page */);
   }
 
-  public void open(final String link) throws Exception {
+  public void open(String link) throws Exception {
     String url = cfg.getString("gerrit", null, "canonicalWebUrl");
     if (url == null) {
       url = cfg.getString("httpd", null, "listenUrl");
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
index b80bf35..44f883a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DatabaseConfigModule.java
@@ -22,7 +22,7 @@
 
   private final SitePaths site;
 
-  public DatabaseConfigModule(final SitePaths site) {
+  public DatabaseConfigModule(SitePaths site) {
     this.site = site;
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DerbyInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DerbyInitializer.java
index 5db4287..3aad0f4 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DerbyInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/DerbyInitializer.java
@@ -27,7 +27,7 @@
   private final SitePaths site;
 
   @Inject
-  DerbyInitializer(final SitePaths site) {
+  DerbyInitializer(SitePaths site) {
     this.site = site;
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
index 5f992bf..ab491f7c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/ExternalIdsOnInit.java
@@ -14,15 +14,81 @@
 
 package com.google.gerrit.pgm.init;
 
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
-
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.GerritPersonIdentProvider;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
 import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.util.FS;
 
 public class ExternalIdsOnInit {
-  public synchronized void insert(ReviewDb db, Collection<ExternalId> extIds) throws OrmException {
-    db.accountExternalIds().insert(toAccountExternalIds(extIds));
+  private final InitFlags flags;
+  private final SitePaths site;
+  private final String allUsers;
+
+  @Inject
+  public ExternalIdsOnInit(InitFlags flags, SitePaths site, AllUsersNameOnInitProvider allUsers) {
+    this.flags = flags;
+    this.site = site;
+    this.allUsers = allUsers.get();
+  }
+
+  public synchronized void insert(String commitMessage, Collection<ExternalId> extIds)
+      throws OrmException, IOException, ConfigInvalidException {
+
+    File path = getPath();
+    if (path != null) {
+      try (Repository repo = new FileRepository(path);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = repo.newObjectInserter()) {
+        ObjectId rev = ExternalIdReader.readRevision(repo);
+
+        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+        for (ExternalId extId : extIds) {
+          ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
+        }
+
+        PersonIdent serverIdent = new GerritPersonIdentProvider(flags.cfg).get();
+        ExternalIdsUpdate.commit(
+            new Project.NameKey(allUsers),
+            repo,
+            rw,
+            ins,
+            rev,
+            noteMap,
+            commitMessage,
+            serverIdent,
+            serverIdent,
+            null,
+            GitReferenceUpdated.DISABLED);
+      }
+    }
+  }
+
+  private File getPath() {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    if (basePath == null) {
+      throw new IllegalStateException("gerrit.basePath must be configured");
+    }
+    return FileKey.resolve(basePath.resolve(allUsers).toFile(), FS.DETECTED);
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/GroupsOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/GroupsOnInit.java
new file mode 100644
index 0000000..4923fab
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/GroupsOnInit.java
@@ -0,0 +1,109 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+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.client.AccountGroupName;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import java.util.List;
+
+/**
+ * A database accessor for calls related to groups.
+ *
+ * <p>All calls which read or write group related details to the database <strong>during
+ * init</strong> (either ReviewDb or NoteDb) are gathered here. For non-init cases, use {@code
+ * Groups} or {@code GroupsUpdate} instead.
+ *
+ * <p>All methods of this class refer to <em>internal</em> groups.
+ */
+public class GroupsOnInit {
+
+  /**
+   * Returns the {@code AccountGroup} for the specified name.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupName the name of the group
+   * @return the {@code AccountGroup} which has the specified name
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   * @throws NoSuchGroupException if a group with such a name doesn't exist
+   */
+  public AccountGroup getExistingGroup(ReviewDb db, AccountGroup.NameKey groupName)
+      throws OrmException, NoSuchGroupException {
+    AccountGroupName accountGroupName = db.accountGroupNames().get(groupName);
+    if (accountGroupName == null) {
+      throw new NoSuchGroupException(groupName.toString());
+    }
+
+    AccountGroup.Id groupId = accountGroupName.getId();
+    AccountGroup group = db.accountGroups().get(groupId);
+    if (group == null) {
+      throw new NoSuchGroupException(groupName.toString());
+    }
+    return group;
+  }
+
+  /**
+   * Adds an account as member to a group. The account is only added as a new member if it isn't
+   * already a member of the group.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the account exists! It also doesn't
+   * update the account index!
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param groupUuid the UUID of the group
+   * @param accountId the ID of the account to add
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws NoSuchGroupException if the specified group doesn't exist
+   */
+  public void addGroupMember(ReviewDb db, AccountGroup.UUID groupUuid, Account.Id accountId)
+      throws OrmException, NoSuchGroupException {
+    AccountGroup group = getExistingGroup(db, groupUuid);
+    AccountGroup.Id groupId = group.getId();
+
+    if (isMember(db, groupId, accountId)) {
+      return;
+    }
+
+    db.accountGroupMembers()
+        .insert(
+            ImmutableList.of(
+                new AccountGroupMember(new AccountGroupMember.Key(accountId, groupId))));
+  }
+
+  private static AccountGroup getExistingGroup(ReviewDb db, AccountGroup.UUID groupUuid)
+      throws OrmException, NoSuchGroupException {
+    List<AccountGroup> accountGroups = db.accountGroups().byUUID(groupUuid).toList();
+    if (accountGroups.size() == 1) {
+      return Iterables.getOnlyElement(accountGroups);
+    } else if (accountGroups.isEmpty()) {
+      throw new NoSuchGroupException(groupUuid);
+    } else {
+      throw new OrmDuplicateKeyException("Duplicate group UUID " + groupUuid);
+    }
+  }
+
+  private static boolean isMember(ReviewDb db, AccountGroup.Id groupId, Account.Id accountId)
+      throws OrmException {
+    AccountGroupMember.Key key = new AccountGroupMember.Key(accountId, groupId);
+    return db.accountGroupMembers().get(key) != null;
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/H2Initializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/H2Initializer.java
index 1f3fd0f..63aa6ec 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/H2Initializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/H2Initializer.java
@@ -27,7 +27,7 @@
   private final SitePaths site;
 
   @Inject
-  H2Initializer(final SitePaths site) {
+  H2Initializer(SitePaths site) {
     this.site = site;
   }
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java
index bc39799..713392d 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/HANAInitializer.java
@@ -16,27 +16,17 @@
 
 import static com.google.gerrit.pgm.init.api.InitUtil.username;
 
-import com.google.common.primitives.Ints;
-import com.google.gerrit.pgm.init.api.InitUtil;
 import com.google.gerrit.pgm.init.api.Section;
 
 public class HANAInitializer implements DatabaseConfigInitializer {
 
   @Override
   public void initConfig(Section databaseSection) {
-    final String defInstanceNumber = "00";
+    final String defPort = "(hana default)";
     databaseSection.string("Server hostname", "hostname", "localhost");
-    databaseSection.string("Instance number", "instance", defInstanceNumber, false);
-    String instance = databaseSection.get("instance");
-    Integer instanceNumber = Ints.tryParse(instance);
-    if (instanceNumber == null || instanceNumber < 0 || instanceNumber > 99) {
-      instanceIsInvalid();
-    }
+    databaseSection.string("Server port", "port", defPort, true);
+    databaseSection.string("Database name", "database", null);
     databaseSection.string("Database username", "username", username());
     databaseSection.password("username", "password");
   }
-
-  private void instanceIsInvalid() {
-    throw InitUtil.die("database.instance must be in the range of 00 to 99");
-  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
index 0fadff4..d02de6c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitAdminUser.java
@@ -17,21 +17,26 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.pgm.init.api.SequencesOnInit;
 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.client.AccountGroupName;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 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.AllUsersName;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -39,29 +44,41 @@
 import java.nio.file.Path;
 import java.nio.file.Paths;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import org.apache.commons.validator.routines.EmailValidator;
 
 public class InitAdminUser implements InitStep {
-  private final ConsoleUI ui;
   private final InitFlags flags;
+  private final ConsoleUI ui;
+  private final AllUsersNameOnInitProvider allUsers;
+  private final AccountsOnInit accounts;
   private final VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory;
   private final ExternalIdsOnInit externalIds;
+  private final SequencesOnInit sequencesOnInit;
+  private final GroupsOnInit groupsOnInit;
   private SchemaFactory<ReviewDb> dbFactory;
-  private AccountIndexCollection indexCollection;
+  private AccountIndexCollection accountIndexCollection;
+  private GroupIndexCollection groupIndexCollection;
 
   @Inject
   InitAdminUser(
       InitFlags flags,
       ConsoleUI ui,
+      AllUsersNameOnInitProvider allUsers,
+      AccountsOnInit accounts,
       VersionedAuthorizedKeysOnInit.Factory authorizedKeysFactory,
-      ExternalIdsOnInit externalIds) {
+      ExternalIdsOnInit externalIds,
+      SequencesOnInit sequencesOnInit,
+      GroupsOnInit groupsOnInit) {
     this.flags = flags;
     this.ui = ui;
+    this.allUsers = allUsers;
+    this.accounts = accounts;
     this.authorizedKeysFactory = authorizedKeysFactory;
     this.externalIds = externalIds;
+    this.sequencesOnInit = sequencesOnInit;
+    this.groupsOnInit = groupsOnInit;
   }
 
   @Override
@@ -73,8 +90,13 @@
   }
 
   @Inject(optional = true)
-  void set(AccountIndexCollection indexCollection) {
-    this.indexCollection = indexCollection;
+  void set(AccountIndexCollection accountIndexCollection) {
+    this.accountIndexCollection = accountIndexCollection;
+  }
+
+  @Inject(optional = true)
+  void set(GroupIndexCollection groupIndexCollection) {
+    this.groupIndexCollection = groupIndexCollection;
   }
 
   @Override
@@ -85,10 +107,10 @@
     }
 
     try (ReviewDb db = dbFactory.open()) {
-      if (db.accounts().anyAccounts().toList().isEmpty()) {
+      if (!accounts.hasAnyAccount()) {
         ui.header("Gerrit Administrator");
         if (ui.yesno(true, "Create administrator user")) {
-          Account.Id id = new Account.Id(db.nextAccountId());
+          Account.Id id = new Account.Id(sequencesOnInit.nextAccountId(db));
           String username = ui.readString("admin", "username");
           String name = ui.readString("Administrator", "name");
           String httpPassword = ui.readString("secret", "HTTP password");
@@ -101,18 +123,16 @@
           if (email != null) {
             extIds.add(ExternalId.createEmail(id, email));
           }
-          externalIds.insert(db, extIds);
+          externalIds.insert("Add external IDs for initial admin user", extIds);
 
           Account a = new Account(id, TimeUtil.nowTs());
           a.setFullName(name);
           a.setPreferredEmail(email);
-          db.accounts().insert(Collections.singleton(a));
+          accounts.insert(a);
 
-          AccountGroupName adminGroupName =
-              db.accountGroupNames().get(new AccountGroup.NameKey("Administrators"));
-          AccountGroupMember m =
-              new AccountGroupMember(new AccountGroupMember.Key(id, adminGroupName.getId()));
-          db.accountGroupMembers().insert(Collections.singleton(m));
+          AccountGroup adminGroup =
+              groupsOnInit.getExistingGroup(db, new AccountGroup.NameKey("Administrators"));
+          groupsOnInit.addGroupMember(db, adminGroup.getGroupUUID(), id);
 
           if (sshKey != null) {
             VersionedAuthorizedKeysOnInit authorizedKeys = authorizedKeysFactory.create(id).load();
@@ -120,13 +140,17 @@
             authorizedKeys.save("Add SSH key for initial admin user\n");
           }
 
-          AccountGroup adminGroup = db.accountGroups().get(adminGroupName.getId());
           AccountState as =
-              new AccountState(
-                  a, Collections.singleton(adminGroup.getGroupUUID()), extIds, new HashMap<>());
-          for (AccountIndex accountIndex : indexCollection.getWriteIndexes()) {
+              new AccountState(new AllUsersName(allUsers.get()), a, extIds, new HashMap<>());
+          for (AccountIndex accountIndex : accountIndexCollection.getWriteIndexes()) {
             accountIndex.replace(as);
           }
+
+          InternalGroup adminInternalGroup =
+              InternalGroup.create(adminGroup, ImmutableSet.of(id), ImmutableSet.of());
+          for (GroupIndex groupIndex : groupIndexCollection.getWriteIndexes()) {
+            groupIndex.replace(adminInternalGroup);
+          }
         }
       }
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
index 3958069..dea45a71 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitContainer.java
@@ -41,7 +41,7 @@
   private final Section container;
 
   @Inject
-  InitContainer(final ConsoleUI ui, final SitePaths site, final Section.Factory sections) {
+  InitContainer(ConsoleUI ui, SitePaths site, Section.Factory sections) {
     this.ui = ui;
     this.site = site;
     this.container = sections.get("container", null);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
index 349ab55..558716c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDatabase.java
@@ -14,15 +14,19 @@
 
 package com.google.gerrit.pgm.init;
 
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
 import static com.google.inject.Stage.PRODUCTION;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Sets;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.GerritServerIdProvider;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.notedb.NotesMigrationState;
 import com.google.inject.Binding;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
@@ -35,6 +39,7 @@
 import java.lang.annotation.Annotation;
 import java.util.List;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
 
 /** Initialize the {@code database} configuration section. */
 @Singleton
@@ -42,24 +47,36 @@
   private final ConsoleUI ui;
   private final SitePaths site;
   private final Libraries libraries;
+  private final InitFlags flags;
   private final Section database;
   private final Section idSection;
+  private final Section noteDbChanges;
 
   @Inject
   InitDatabase(
-      final ConsoleUI ui,
-      final SitePaths site,
-      final Libraries libraries,
-      final Section.Factory sections) {
+      ConsoleUI ui,
+      SitePaths site,
+      Libraries libraries,
+      InitFlags flags,
+      Section.Factory sections) {
     this.ui = ui;
     this.site = site;
     this.libraries = libraries;
+    this.flags = flags; // Don't grab any flags yet; they aren't initialized until BaseInit#run.
     this.database = sections.get("database", null);
     this.idSection = sections.get(GerritServerIdProvider.SECTION, null);
+    this.noteDbChanges = sections.get(SECTION_NOTE_DB, CHANGES.key());
   }
 
   @Override
   public void run() {
+    initSqlDb();
+    if (flags.isNew) {
+      initNoteDb();
+    }
+  }
+
+  private void initSqlDb() {
     ui.header("SQL Database");
 
     Set<String> allowedValues = Sets.newTreeSet();
@@ -103,4 +120,21 @@
       idSection.set(GerritServerIdProvider.KEY, GerritServerIdProvider.generate());
     }
   }
+
+  private void initNoteDb() {
+    ui.header("NoteDb Database");
+    ui.message(
+        "Use NoteDb for change metadata?\n"
+            + "  See documentation:\n"
+            + "  https://gerrit-review.googlesource.com/Documentation/note-db.html\n");
+    if (!ui.yesno(true, "Enable")) {
+      return;
+    }
+
+    Config defaultConfig = new Config();
+    NotesMigrationState.FINAL.setConfigValues(defaultConfig);
+    for (String name : defaultConfig.getNames(SECTION_NOTE_DB, CHANGES.key())) {
+      noteDbChanges.set(name, defaultConfig.getString(SECTION_NOTE_DB, CHANGES.key(), name));
+    }
+  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java
new file mode 100644
index 0000000..b2cced9
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitExperimental.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init;
+
+import com.google.gerrit.extensions.client.UiType;
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Locale;
+
+@Singleton
+class InitExperimental implements InitStep {
+  private final ConsoleUI ui;
+  private final Section gerrit;
+
+  @Inject
+  InitExperimental(ConsoleUI ui, Section.Factory sections) {
+    this.ui = ui;
+    this.gerrit = sections.get("gerrit", null);
+  }
+
+  @Override
+  public void run() {
+    ui.header("Experimental features");
+    if (!ui.yesno(false, "Enable any experimental features")) {
+      return;
+    }
+
+    initUis();
+  }
+
+  private void initUis() {
+    boolean pg = ui.yesno(true, "Default to PolyGerrit UI");
+    UiType uiType = pg ? UiType.POLYGERRIT : UiType.GWT;
+    gerrit.set("ui", uiType.name().toLowerCase(Locale.US));
+    if (pg) {
+      gerrit.set("enableGwtUi", Boolean.toString(ui.yesno(true, "Enable GWT UI")));
+    }
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java
index fc42f9d..e57b6b9 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitGitManager.java
@@ -31,7 +31,7 @@
   private final Section gerrit;
 
   @Inject
-  InitGitManager(final ConsoleUI ui, final Section.Factory sections) {
+  InitGitManager(ConsoleUI ui, Section.Factory sections) {
     this.ui = ui;
     this.gerrit = sections.get("gerrit", null);
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
index 2e67bfb..0de08f2 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -23,7 +24,6 @@
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
 import com.google.gerrit.server.index.IndexUtils;
-import com.google.gerrit.server.index.SchemaDefinitions;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitLabels.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitLabels.java
index 60fd60f..c7309f8 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitLabels.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitLabels.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.pgm.init;
 
+import static com.google.gerrit.common.data.LabelFunction.MAX_WITH_BLOCK;
+
 import com.google.gerrit.pgm.init.api.AllProjectsConfig;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -54,7 +56,7 @@
   public void postRun() throws Exception {
     Config cfg = allProjectsConfig.load().getConfig();
     if (installVerified) {
-      cfg.setString(KEY_LABEL, LABEL_VERIFIED, KEY_FUNCTION, "MaxWithBlock");
+      cfg.setString(KEY_LABEL, LABEL_VERIFIED, KEY_FUNCTION, MAX_WITH_BLOCK.getFunctionName());
       cfg.setStringList(
           KEY_LABEL,
           LABEL_VERIFIED,
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
index f6b7e6a..f75d2dc 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.gerrit.extensions.config.FactoryModule;
-import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
@@ -37,7 +36,6 @@
   @Override
   protected void configure() {
     bind(SitePaths.class);
-    bind(InitFlags.class);
     bind(Libraries.class);
     bind(LibraryDownloader.class);
     factory(Section.Factory.class);
@@ -64,6 +62,7 @@
     step().to(InitCache.class);
     step().to(InitPlugins.class);
     step().to(InitDev.class);
+    step().to(InitExperimental.class);
   }
 
   protected LinkedBindingBuilder<InitStep> step() {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
index 4526a87..c1d142b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitPluginStepsLoader.java
@@ -21,7 +21,7 @@
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.plugins.JarPluginProvider;
-import com.google.gerrit.server.plugins.PluginLoader;
+import com.google.gerrit.server.plugins.PluginUtil;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -43,8 +43,7 @@
   final ConsoleUI ui;
 
   @Inject
-  public InitPluginStepsLoader(
-      final ConsoleUI ui, final SitePaths sitePaths, final Injector initInjector) {
+  public InitPluginStepsLoader(final ConsoleUI ui, SitePaths sitePaths, Injector initInjector) {
     this.pluginsDir = sitePaths.plugins_dir;
     this.initInjector = initInjector;
     this.ui = ui;
@@ -101,7 +100,7 @@
   private Injector getPluginInjector(Path jarPath) throws IOException {
     final String pluginName =
         MoreObjects.firstNonNull(
-            JarPluginProvider.getJarPluginName(jarPath), PluginLoader.nameOf(jarPath));
+            JarPluginProvider.getJarPluginName(jarPath), PluginUtil.nameOf(jarPath));
     return initInjector.createChildInjector(
         new AbstractModule() {
           @Override
@@ -113,7 +112,7 @@
 
   private List<Path> scanJarsInPluginsDirectory() {
     try {
-      return PluginLoader.listPlugins(pluginsDir, ".jar");
+      return PluginUtil.listPlugins(pluginsDir, ".jar");
     } catch (IOException e) {
       ui.message("WARN: Cannot list %s: %s", pluginsDir.toAbsolutePath(), e.getMessage());
       return ImmutableList.of();
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java
index 97359b3..666b549 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitSendEmail.java
@@ -34,7 +34,7 @@
   private final SitePaths site;
 
   @Inject
-  InitSendEmail(final ConsoleUI ui, final SitePaths site, final Section.Factory sections) {
+  InitSendEmail(ConsoleUI ui, SitePaths site, Section.Factory sections) {
     this.ui = ui;
     this.sendemail = sections.get("sendemail", null);
     this.site = site;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
index 3259f96..c599e99 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/Libraries.java
@@ -122,7 +122,7 @@
     return val;
   }
 
-  private static String read(final String p) throws IOException {
+  private static String read(String p) throws IOException {
     try (InputStream in = Libraries.class.getClassLoader().getResourceAsStream(p)) {
       if (in == null) {
         throw new FileNotFoundException("Cannot load resource " + p);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java
index c0b5c75..0b31ee2 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/LibraryDownloader.java
@@ -65,16 +65,16 @@
     this.needs = new ArrayList<>(2);
   }
 
-  void setName(final String name) {
+  void setName(String name) {
     this.name = name;
   }
 
-  void setJarUrl(final String url) {
+  void setJarUrl(String url) {
     this.jarUrl = url;
     download = jarUrl.startsWith("http");
   }
 
-  void setSHA1(final String sha1) {
+  void setSHA1(String sha1) {
     this.sha1 = sha1;
   }
 
@@ -230,7 +230,7 @@
     Files.copy(p, dst);
   }
 
-  private static Path url2file(final String urlString) throws IOException {
+  private static Path url2file(String urlString) throws IOException {
     final URL url = new URL(urlString);
     try {
       return Paths.get(url.toURI());
@@ -250,6 +250,7 @@
     } catch (IOException err) {
       deleteDst();
       System.err.println(" !! FAIL !!");
+      System.err.println(err);
       System.err.flush();
       throw err;
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 243ea09..be61061 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -165,7 +165,7 @@
     chmod(0444, ex);
   }
 
-  private static List<InitStep> stepsOf(final Injector injector) {
+  private static List<InitStep> stepsOf(Injector injector) {
     final ArrayList<InitStep> r = new ArrayList<>();
     for (Binding<InitStep> b : all(injector)) {
       r.add(b.getProvider().get());
@@ -173,7 +173,7 @@
     return r;
   }
 
-  private static List<Binding<InitStep>> all(final Injector injector) {
+  private static List<Binding<InitStep>> all(Injector injector) {
     return injector.findBindingsByType(new TypeLiteral<InitStep>() {});
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
index d5d7e78..f994432 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/UpgradeFrom2_0_x.java
@@ -162,8 +162,7 @@
     savePublic(cfg);
   }
 
-  private boolean convertUrl(final Section database, String url)
-      throws UnsupportedEncodingException {
+  private boolean convertUrl(Section database, String url) throws UnsupportedEncodingException {
     String username = null;
     String password = null;
 
@@ -243,14 +242,14 @@
     return false;
   }
 
-  private void sethost(final Section database, final InetSocketAddress addr) {
+  private void sethost(Section database, InetSocketAddress addr) {
     database.set("hostname", SocketUtil.hostname(addr));
     if (0 < addr.getPort()) {
       database.set("port", String.valueOf(addr.getPort()));
     }
   }
 
-  private void setuser(final Section database, String username, String password) {
+  private void setuser(Section database, String username, String password) {
     if (username != null && !username.isEmpty()) {
       database.set("username", username);
     }
@@ -278,7 +277,7 @@
         throw new IOException("Cannot read " + name, e);
       }
       final Properties dbprop = new Properties();
-      for (final Map.Entry<Object, Object> e : srvprop.entrySet()) {
+      for (Map.Entry<Object, Object> e : srvprop.entrySet()) {
         final String key = (String) e.getKey();
         if (key.startsWith("database.")) {
           dbprop.put(key.substring("database.".length()), e.getValue());
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
index e0b0c1c..8a1a5fa 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/VersionedAuthorizedKeysOnInit.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.pgm.init.api.AllUsersNameOnInitProvider;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.VersionedMetaDataOnInit;
 import com.google.gerrit.reviewdb.client.Account;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllUsersNameOnInitProvider.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllUsersNameOnInitProvider.java
new file mode 100644
index 0000000..e02120f
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/AllUsersNameOnInitProvider.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init.api;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class AllUsersNameOnInitProvider implements Provider<String> {
+  private final String name;
+
+  @Inject
+  AllUsersNameOnInitProvider(Section.Factory sections) {
+    String n = sections.get("gerrit", null).get("allUsers");
+    name = MoreObjects.firstNonNull(Strings.emptyToNull(n), AllUsersNameProvider.DEFAULT);
+  }
+
+  @Override
+  public String get() {
+    return name;
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
index 7f723b7..444f64f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/ConsoleUI.java
@@ -27,7 +27,7 @@
   }
 
   /** Get a UI instance, possibly forcing batch mode. */
-  public static ConsoleUI getInstance(final boolean batchMode) {
+  public static ConsoleUI getInstance(boolean batchMode) {
     Console console = batchMode ? null : System.console();
     return console != null ? new Interactive(console) : new Batch();
   }
@@ -87,7 +87,7 @@
   private static class Interactive extends ConsoleUI {
     private final Console console;
 
-    Interactive(final Console console) {
+    Interactive(Console console) {
       this.console = console;
     }
 
@@ -164,7 +164,7 @@
           console.printf("error: '%s' is not a valid choice\n", r);
         }
         console.printf("       Supported options are:\n");
-        for (final String v : allowedValues) {
+        for (String v : allowedValues) {
           console.printf("         %s\n", v.toLowerCase());
         }
       }
@@ -207,7 +207,7 @@
         if (r.isEmpty()) {
           return def;
         }
-        for (final T e : options) {
+        for (T e : options) {
           if (e.toString().equalsIgnoreCase(r)) {
             return e;
           }
@@ -216,7 +216,7 @@
           console.printf("error: '%s' is not a valid choice\n", r);
         }
         console.printf("       Supported options are:\n");
-        for (final T e : options) {
+        for (T e : options) {
           console.printf("         %s\n", e.toString().toLowerCase());
         }
       }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
new file mode 100644
index 0000000..2f94bdb
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/GitRepositoryManagerOnInit.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init.api;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.SortedSet;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.RepositoryCache.FileKey;
+import org.eclipse.jgit.util.FS;
+
+@Singleton
+public class GitRepositoryManagerOnInit implements GitRepositoryManager {
+  private final InitFlags flags;
+  private final SitePaths site;
+
+  @Inject
+  GitRepositoryManagerOnInit(InitFlags flags, SitePaths site) {
+    this.flags = flags;
+    this.site = site;
+  }
+
+  @Override
+  public Repository openRepository(Project.NameKey name)
+      throws RepositoryNotFoundException, IOException {
+    return new FileRepository(getPath(name));
+  }
+
+  @Override
+  public Repository createRepository(Project.NameKey name) {
+    throw new UnsupportedOperationException("not implemented");
+  }
+
+  @Override
+  public SortedSet<Project.NameKey> list() {
+    throw new UnsupportedOperationException("not implemented");
+  }
+
+  private File getPath(Project.NameKey name) {
+    Path basePath = site.resolve(flags.cfg.getString("gerrit", null, "basePath"));
+    if (basePath == null) {
+      throw new IllegalStateException("gerrit.basePath must be configured");
+    }
+    return FileKey.resolve(basePath.resolve(name.get()).toFile(), FS.DETECTED);
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
index 691243f..7bb1366 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
@@ -31,6 +31,9 @@
   /** Recursively delete the site path if initialization fails. */
   public boolean deleteOnFailure;
 
+  /** Site is being newly created */
+  public boolean isNew;
+
   /** Run the daemon (and open the web UI in a browser) after initialization. */
   public boolean autoStart;
 
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java
index b80cb22..656f53a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitUtil.java
@@ -46,7 +46,7 @@
     return new Die(why, cause);
   }
 
-  public static void savePublic(final FileBasedConfig sec) throws IOException {
+  public static void savePublic(FileBasedConfig sec) throws IOException {
     if (modified(sec)) {
       sec.save();
     }
@@ -79,7 +79,7 @@
     return SystemReader.getInstance().getHostname();
   }
 
-  public static boolean isLocal(final String hostname) {
+  public static boolean isLocal(String hostname) {
     try {
       return InetAddress.getByName(hostname).isLoopbackAddress();
     } catch (UnknownHostException e) {
@@ -127,7 +127,7 @@
     }
   }
 
-  private static InputStream open(final Class<?> sibling, final String name) {
+  private static InputStream open(Class<?> sibling, String name) {
     final InputStream in = sibling.getResourceAsStream(name);
     if (in == null) {
       String pkg = sibling.getName();
@@ -186,12 +186,12 @@
     return new URI(url);
   }
 
-  public static boolean isAnyAddress(final URI u) {
+  public static boolean isAnyAddress(URI u) {
     return u.getHost() == null
         && (u.getAuthority().equals("*") || u.getAuthority().startsWith("*:"));
   }
 
-  public static int portOf(final URI uri) {
+  public static int portOf(URI uri) {
     int port = uri.getPort();
     if (port < 0) {
       port = "https".equals(uri.getScheme()) ? 443 : 80;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java
index d52005f..c1c8745 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/Section.java
@@ -58,7 +58,7 @@
     return flags.cfg.getString(section, subsection, name);
   }
 
-  public void set(final String name, final String value) {
+  public void set(String name, String value) {
     final ArrayList<String> all = new ArrayList<>();
     all.addAll(Arrays.asList(flags.cfg.getStringList(section, subsection, name)));
 
@@ -78,7 +78,7 @@
     }
   }
 
-  public <T extends Enum<?>> void set(final String name, final T value) {
+  public <T extends Enum<?>> void set(String name, T value) {
     if (value != null) {
       set(name, value.name());
     } else {
@@ -90,12 +90,11 @@
     set(name, (String) null);
   }
 
-  public String string(final String title, final String name, final String dv) {
+  public String string(String title, String name, String dv) {
     return string(title, name, dv, false);
   }
 
-  public String string(
-      final String title, final String name, final String dv, final boolean nullIfDefault) {
+  public String string(final String title, String name, String dv, boolean nullIfDefault) {
     final String ov = get(name);
     String nv = ui.readString(ov != null ? ov : dv, "%s", title);
     if (nullIfDefault && nv.equals(dv)) {
@@ -107,7 +106,7 @@
     return nv;
   }
 
-  public Path path(final String title, final String name, final String defValue) {
+  public Path path(String title, String name, String defValue) {
     return site.resolve(string(title, name, defValue));
   }
 
@@ -129,7 +128,7 @@
   }
 
   public <T extends Enum<?>, A extends EnumSet<? extends T>> T select(
-      String title, String name, T defValue, A allowedValues, final boolean nullIfDefault) {
+      String title, String name, T defValue, A allowedValues, boolean nullIfDefault) {
     final boolean set = get(name) != null;
     T oldValue = flags.cfg.getEnum(section, subsection, name, defValue);
     T newValue = ui.readEnum(oldValue, allowedValues, "%s", title);
@@ -146,8 +145,7 @@
     return newValue;
   }
 
-  public String select(
-      final String title, final String name, final String dv, Set<String> allowedValues) {
+  public String select(final String title, String name, String dv, Set<String> allowedValues) {
     final String ov = get(name);
     String nv = ui.readString(ov != null ? ov : dv, allowedValues, "%s", title);
     if (!eq(ov, nv)) {
@@ -156,7 +154,7 @@
     return nv;
   }
 
-  public String password(final String username, final String password) {
+  public String password(String username, String password) {
     final String ov = getSecure(password);
 
     String user = flags.sec.get(section, subsection, username);
@@ -219,7 +217,7 @@
     return section;
   }
 
-  private static boolean eq(final String a, final String b) {
+  private static boolean eq(String a, String b) {
     if (a == null && b == null) {
       return true;
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
new file mode 100644
index 0000000..e1cef62
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/SequencesOnInit.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.init.api;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SequencesOnInit {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersNameOnInitProvider allUsersName;
+
+  @Inject
+  SequencesOnInit(GitRepositoryManagerOnInit repoManager, AllUsersNameOnInitProvider allUsersName) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  public int nextAccountId(ReviewDb db) throws OrmException {
+    @SuppressWarnings("deprecation")
+    RepoSequence.Seed accountSeed = () -> db.nextAccountId();
+    RepoSequence accountSeq =
+        new RepoSequence(
+            repoManager,
+            GitReferenceUpdated.DISABLED,
+            new Project.NameKey(allUsersName.get()),
+            Sequences.NAME_ACCOUNTS,
+            accountSeed,
+            1);
+    return accountSeq.next();
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
index fd825b8..c34b423 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/VersionedMetaDataOnInit.java
@@ -128,6 +128,8 @@
       case REJECTED:
       case REJECTED_CURRENT_BRANCH:
       case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
       default:
         throw new IOException(
             "Failed to update " + getRefName() + " of " + project + ": " + r.name());
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java
index 5273dfb..6b1ee26 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexManagerOnInit.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.pgm.init.index;
 
 import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.index.IndexDefinition;
 import com.google.inject.Inject;
 import com.google.inject.name.Named;
 import java.util.Collection;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
index 0358f13..b417d05 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/index/IndexModuleOnInit.java
@@ -19,10 +19,10 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.index.IndexDefinition;
-import com.google.gerrit.server.index.SchemaDefinitions;
 import com.google.gerrit.server.index.SingleVersionModule;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/AbstractProgram.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/AbstractProgram.java
index 825bd70..fca5551 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/AbstractProgram.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/AbstractProgram.java
@@ -38,7 +38,7 @@
     return n.toLowerCase();
   }
 
-  public final int main(final String[] argv) throws Exception {
+  public final int main(String[] argv) throws Exception {
     final CmdLineParser clp = new CmdLineParser(OptionHandlers.empty(), this);
     try {
       clp.parseArgument(argv);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
index 5cc0ca0..13f120a 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/BatchProgramModule.java
@@ -20,23 +20,23 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
+import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.rules.PrologModule;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountByEmailCacheImpl;
 import com.google.gerrit.server.account.AccountCacheImpl;
-import com.google.gerrit.server.account.AccountVisibility;
 import com.google.gerrit.server.account.AccountVisibilityProvider;
 import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.FakeRealm;
 import com.google.gerrit.server.account.GroupCacheImpl;
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.Realm;
+import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.cache.CacheRemovalListener;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -57,21 +57,24 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.extensions.events.RevisionCreated;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.group.GroupModule;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
 import com.google.gerrit.server.notedb.NoteDbModule;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.patch.PatchListCacheImpl;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.CommentLinkProvider;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -116,6 +119,10 @@
     bind(new TypeLiteral<List<CommentLinkInfo>>() {})
         .toProvider(CommentLinkProvider.class)
         .in(SINGLETON);
+    bind(new TypeLiteral<DynamicMap<ChangeQueryProcessor.ChangeAttributeFactory>>() {})
+        .toInstance(DynamicMap.<ChangeQueryProcessor.ChangeAttributeFactory>emptyMap());
+    bind(new TypeLiteral<DynamicMap<RestView<CommitResource>>>() {})
+        .toInstance(DynamicMap.<RestView<CommitResource>>emptyMap());
     bind(String.class)
         .annotatedWith(CanonicalWebUrl.class)
         .toProvider(CanonicalWebUrlProvider.class);
@@ -131,6 +138,7 @@
     factory(MergeUtil.Factory.class);
     factory(PatchSetInserter.Factory.class);
     factory(RebaseChangeOp.Factory.class);
+    factory(VisibleRefFilter.Factory.class);
 
     // As Reindex is a batch program, don't assume the index is available for
     // the change cache.
@@ -145,17 +153,16 @@
     bind(new TypeLiteral<Set<AccountGroup.UUID>>() {})
         .annotatedWith(GitReceivePackGroups.class)
         .toInstance(Collections.<AccountGroup.UUID>emptySet());
-    bind(ChangeControl.Factory.class);
-    factory(ProjectControl.AssistedFactory.class);
 
     install(new BatchGitModule());
+    install(new DefaultPermissionBackendModule());
     install(new DefaultMemoryCacheModule());
     install(new H2CacheModule());
+    install(new ExternalIdModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
     install(new PrologModule());
-    install(AccountByEmailCacheImpl.module());
-    install(AccountCacheImpl.module(false));
+    install(AccountCacheImpl.module());
     install(GroupCacheImpl.module());
     install(GroupIncludeCacheImpl.module());
     install(ProjectCacheImpl.module());
@@ -164,9 +171,9 @@
     install(MergeabilityCacheImpl.module());
     install(TagCache.module());
     factory(CapabilityCollection.Factory.class);
-    factory(CapabilityControl.Factory.class);
-    factory(ChangeData.Factory.class);
+    factory(ChangeData.AssistedFactory.class);
     factory(ProjectState.Factory.class);
+    factory(SubmitRuleEvaluator.Factory.class);
 
     bind(ChangeJson.Factory.class).toProvider(Providers.<ChangeJson.Factory>of(null));
     bind(EventUtil.class).toProvider(Providers.<EventUtil>of(null));
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java
index e5076c9..afb2fb4 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/ErrorLogFile.java
@@ -49,8 +49,7 @@
     root.addAppender(dst);
   }
 
-  public static LifecycleListener start(final Path sitePath, final Config config)
-      throws IOException {
+  public static LifecycleListener start(Path sitePath, Config config) throws IOException {
     Path logdir =
         FileUtil.mkdirsOrDie(new SitePaths(sitePath).logs_dir, "Cannot create log directory");
     if (SystemLog.shouldConfigure()) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
index 7eed2ef..c9df7e7 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/RuntimeShutdown.java
@@ -23,7 +23,7 @@
   private static final ShutdownCallback cb = new ShutdownCallback();
 
   /** Add a task to be performed when graceful shutdown is requested. */
-  public static void add(final Runnable task) {
+  public static void add(Runnable task) {
     if (!cb.add(task)) {
       // If the shutdown has already begun we cannot enqueue a new
       // task. Instead trigger the task in the caller, without any
@@ -55,7 +55,7 @@
       setName("ShutdownCallback");
     }
 
-    boolean add(final Runnable newTask) {
+    boolean add(Runnable newTask) {
       synchronized (this) {
         if (!shutdownStarted && !shutdownComplete) {
           if (tasks.isEmpty()) {
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
index ebb04ac..b9e2ae6 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/util/SiteProgram.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.server.config.GerritServerConfigModule;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
-import com.google.gerrit.server.notedb.ConfigNotesMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.schema.DataSourceModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.DataSourceType;
@@ -83,7 +83,7 @@
     this.sitePath = sitePath;
   }
 
-  protected SiteProgram(Path sitePath, final Provider<DataSource> dsProvider) {
+  protected SiteProgram(Path sitePath, Provider<DataSource> dsProvider) {
     this.sitePath = sitePath;
     this.dsProvider = dsProvider;
   }
@@ -106,9 +106,8 @@
   }
 
   /** @return provides database connectivity and site path. */
-  protected Injector createDbInjector(
-      final boolean enableMetrics, final DataSourceProvider.Context context) {
-    final List<Module> modules = new ArrayList<>();
+  protected Injector createDbInjector(boolean enableMetrics, DataSourceProvider.Context context) {
+    List<Module> modules = new ArrayList<>();
 
     Module sitePathModule =
         new AbstractModule() {
@@ -169,7 +168,7 @@
       throw new ProvisionException("database.type must be defined");
     }
 
-    final DataSourceType dst =
+    DataSourceType dst =
         Guice.createInjector(new DataSourceModule(), configModule, sitePathModule)
             .getInstance(Key.get(DataSourceType.class, Names.named(dbType.toLowerCase())));
 
@@ -183,12 +182,12 @@
     modules.add(new DatabaseModule());
     modules.add(new SchemaModule());
     modules.add(cfgInjector.getInstance(GitRepositoryManagerModule.class));
-    modules.add(new ConfigNotesMigration.Module());
+    modules.add(new NotesMigration.Module());
 
     try {
       return Guice.createInjector(PRODUCTION, modules);
     } catch (CreationException ce) {
-      final Message first = ce.getErrorMessages().iterator().next();
+      Message first = ce.getErrorMessages().iterator().next();
       Throwable why = first.getCause();
 
       if (why instanceof SQLException) {
@@ -204,7 +203,7 @@
         throw die(CONNECTION_ERROR, why);
       }
 
-      final StringBuilder buf = new StringBuilder();
+      StringBuilder buf = new StringBuilder();
       if (why != null) {
         buf.append(why.getMessage());
         why = why.getCause();
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
index 3de01d9..f519692 100755
--- a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/gerrit.sh
@@ -45,6 +45,10 @@
 #   If set to "0" disables using start-stop-daemon.  This may need to
 #   be set on SuSE systems.
 
+if test -f /lib/lsb/init-functions ; then
+  . /lib/lsb/init-functions
+fi
+
 usage() {
     me=`basename "$0"`
     echo >&2 "Usage: $me {start|stop|restart|check|status|run|supervise|threads} [-d site]"
diff --git a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
index 26ac9d6..3d3545b 100644
--- a/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
+++ b/gerrit-pgm/src/main/resources/com/google/gerrit/pgm/init/libraries.config
@@ -13,15 +13,15 @@
 # limitations under the License.
 
 [library "mysqlDriver"]
-  name = MySQL Connector/J 5.1.41
-  url = https://repo1.maven.org/maven2/mysql/mysql-connector-java/5.1.41/mysql-connector-java-5.1.41.jar
-  sha1 = b0878056f15616989144d6114d36d3942321d0d1
+  name = MySQL Connector/J 5.1.43
+  url = https://repo1.maven.org/maven2/mysql/mysql-connector-java/5.1.43/mysql-connector-java-5.1.43.jar
+  sha1 = dee9103eec0d877f3a21c82d4d9e9f4fbd2d6e0a
   remove = mysql-connector-java-.*[.]jar
 
 [library "mariadbDriver"]
-  name = MariaDB Connector/J 1.5.9
-  url = https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/1.5.9/mariadb-java-client-1.5.9.jar
-  sha1 = 75d4d6e4cdb9a551a102e92a14c640768174e214
+  name = MariaDB Connector/J 2.3.0
+  url = https://repo1.maven.org/maven2/org/mariadb/jdbc/mariadb-java-client/2.3.0/mariadb-java-client-2.3.0.jar
+  sha1 = c2b1a6002a169757d0649449288e9b3b776af76b
   remove = mariadb-java-client-.*[.]jar
 
 [library "oracleDriver"]
diff --git a/gerrit-pgm/src/test/java/com/google/gerrit/pgm/http/jetty/HttpLogRedactTest.java b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/http/jetty/HttpLogRedactTest.java
new file mode 100644
index 0000000..7ed7f81
--- /dev/null
+++ b/gerrit-pgm/src/test/java/com/google/gerrit/pgm/http/jetty/HttpLogRedactTest.java
@@ -0,0 +1,46 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.pgm.http.jetty;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class HttpLogRedactTest {
+  @Test
+  public void includeQueryString() {
+    assertThat(HttpLog.redactQueryString("/changes/", null)).isEqualTo("/changes/");
+    assertThat(HttpLog.redactQueryString("/changes/", "")).isEqualTo("/changes/");
+    assertThat(HttpLog.redactQueryString("/changes/", "x")).isEqualTo("/changes/?x");
+    assertThat(HttpLog.redactQueryString("/changes/", "x=y")).isEqualTo("/changes/?x=y");
+  }
+
+  @Test
+  public void redactAuth() {
+    assertThat(HttpLog.redactQueryString("/changes/", "query=status:open"))
+        .isEqualTo("/changes/?query=status:open");
+
+    assertThat(HttpLog.redactQueryString("/changes/", "query=status:open&access_token=foo"))
+        .isEqualTo("/changes/?query=status:open&access_token=*");
+
+    assertThat(HttpLog.redactQueryString("/changes/", "access_token=foo"))
+        .isEqualTo("/changes/?access_token=*");
+
+    assertThat(
+            HttpLog.redactQueryString(
+                "/changes/", "query=status:open&access_token=foo&access_token=bar"))
+        .isEqualTo("/changes/?query=status:open&access_token=*&access_token=*");
+  }
+}
diff --git a/gerrit-plugin-api/BUILD b/gerrit-plugin-api/BUILD
index 10f474d..5d9ac72 100644
--- a/gerrit-plugin-api/BUILD
+++ b/gerrit-plugin-api/BUILD
@@ -8,14 +8,18 @@
 ]
 
 EXPORTS = [
-    "//gerrit-antlr:query_exception",
-    "//gerrit-antlr:query_parser",
+    "//gerrit-index:index",
+    "//gerrit-index:query_exception",
+    "//gerrit-index:query_parser",
     "//gerrit-common:annotations",
     "//gerrit-common:server",
     "//gerrit-extension-api:api",
     "//gerrit-gwtexpui:server",
+    "//gerrit-server:metrics",
     "//gerrit-reviewdb:server",
-    "//gerrit-server/src/main/prolog:common",
+    "//gerrit-server:prolog-common",
+    "//gerrit-util-cli:cli",
+    "//lib/commons:compress",
     "//lib/commons:dbcp",
     "//lib/commons:lang",
     "//lib/dropwizard:dropwizard-core",
@@ -38,6 +42,7 @@
     "//lib:args4j",
     "//lib:blame-cache",
     "//lib:guava",
+    "//lib:guava-retrying",
     "//lib:gson",
     "//lib:gwtorm",
     "//lib:icu4j",
@@ -74,12 +79,12 @@
     main_class = "Dummy",
     visibility = ["//visibility:public"],
     runtime_deps = [
-        "//gerrit-antlr:libquery_exception-src.jar",
-        "//gerrit-antlr:libquery_parser-src.jar",
         "//gerrit-common:libannotations-src.jar",
         "//gerrit-extension-api:libapi-src.jar",
         "//gerrit-gwtexpui:libserver-src.jar",
         "//gerrit-httpd:libhttpd-src.jar",
+        "//gerrit-index:libquery_exception-src.jar",
+        "//gerrit-index:libquery_parser-src.jar",
         "//gerrit-pgm:libinit-api-src.jar",
         "//gerrit-reviewdb:libserver-src.jar",
         "//gerrit-server:libserver-src.jar",
@@ -90,8 +95,8 @@
 java_doc(
     name = "plugin-api-javadoc",
     libs = PLUGIN_API + [
-        "//gerrit-antlr:query_exception",
-        "//gerrit-antlr:query_parser",
+        "//gerrit-index:query_exception",
+        "//gerrit-index:query_parser",
         "//gerrit-common:annotations",
         "//gerrit-common:server",
         "//gerrit-extension-api:api",
diff --git a/gerrit-plugin-api/pom.xml b/gerrit-plugin-api/pom.xml
index acdbcd1..5e73e18 100644
--- a/gerrit-plugin-api/pom.xml
+++ b/gerrit-plugin-api/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-api</artifactId>
-  <version>2.14.18</version>
+  <version>2.15.8-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin API</name>
   <description>API for Gerrit Plugins</description>
diff --git a/gerrit-plugin-gwtui/pom.xml b/gerrit-plugin-gwtui/pom.xml
index 2b47516..825d75c 100644
--- a/gerrit-plugin-gwtui/pom.xml
+++ b/gerrit-plugin-gwtui/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-plugin-gwtui</artifactId>
-  <version>2.14.18</version>
+  <version>2.15.8-SNAPSHOT</version>
   <packaging>jar</packaging>
   <name>Gerrit Code Review - Plugin GWT UI</name>
   <description>Common Classes for Gerrit GWT UI Plugins</description>
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
index 7c478c1..bfcb3d6 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/Plugin.java
@@ -121,12 +121,15 @@
    *
    * @param extensionPoint the UI extension point for which the panel should be registered.
    * @param entry callback function invoked to create the panel widgets.
+   * @param name the name of the panel which can be used to specify panel ordering via project
+   *     config
    */
-  public void panel(GerritUiExtensionPoint extensionPoint, Panel.EntryPoint entry) {
-    panel(extensionPoint.name(), wrap(entry));
+  public final void panel(
+      GerritUiExtensionPoint extensionPoint, Panel.EntryPoint entry, String name) {
+    panel(extensionPoint.name(), wrap(entry), name);
   }
 
-  private native void panel(String i, JavaScriptObject e) /*-{ this.panel(i, e) }-*/;
+  private native void panel(String i, JavaScriptObject e, String n) /*-{ this.panel(i, e, n) }-*/;
 
   protected Plugin() {}
 
diff --git a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java
index 13e19ae..df5be2c 100644
--- a/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java
+++ b/gerrit-plugin-gwtui/src/main/java/com/google/gerrit/plugin/client/ui/GroupSuggestOracle.java
@@ -40,7 +40,7 @@
   }
 
   @Override
-  public void requestSuggestions(final Request req, final Callback done) {
+  public void requestSuggestions(Request req, Callback done) {
     if (req.getQuery().length() < chars) {
       responseEmptySuggestion(req, done);
       return;
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/EditList.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/EditList.java
index 1b06f0f..61c807c 100644
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/EditList.java
+++ b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/EditList.java
@@ -24,8 +24,7 @@
   private final int aSize;
   private final int bSize;
 
-  public EditList(
-      final List<Edit> edits, final int contextLines, final int aSize, final int bSize) {
+  public EditList(final List<Edit> edits, int contextLines, int aSize, int bSize) {
     this.edits = edits;
     this.context = contextLines;
     this.aSize = aSize;
@@ -65,7 +64,7 @@
     };
   }
 
-  private int findCombinedEnd(final int i) {
+  private int findCombinedEnd(int i) {
     int end = i + 1;
     while (end < edits.size() && (combineA(end) || combineB(end))) {
       end++;
@@ -73,14 +72,14 @@
     return end - 1;
   }
 
-  private boolean combineA(final int i) {
+  private boolean combineA(int i) {
     final Edit s = edits.get(i);
     final Edit e = edits.get(i - 1);
     // + 1 to prevent '... skipping 1 common line ...' messages.
     return s.getBeginA() - e.getEndA() <= 2 * context + 1;
   }
 
-  private boolean combineB(final int i) {
+  private boolean combineB(int i) {
     final int s = edits.get(i).getBeginB();
     final int e = edits.get(i - 1).getEndB();
     // + 1 to prevent '... skipping 1 common line ...' messages.
@@ -98,7 +97,7 @@
     private final int aEnd;
     private final int bEnd;
 
-    private Hunk(final int ci, final int ei) {
+    private Hunk(int ci, int ei) {
       curIdx = ci;
       endIdx = ei;
       curEdit = edits.get(curIdx);
@@ -172,7 +171,7 @@
       return aCur < aEnd || bCur < bEnd;
     }
 
-    private boolean in(final Edit edit) {
+    private boolean in(Edit edit) {
       return aCur < edit.getEndA() || bCur < edit.getEndB();
     }
   }
diff --git a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java
index 1dce0a0..348f9b2 100644
--- a/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java
+++ b/gerrit-prettify/src/main/java/com/google/gerrit/prettify/common/SparseFileContent.java
@@ -16,13 +16,10 @@
 
 import java.util.ArrayList;
 import java.util.List;
-import org.eclipse.jgit.diff.Edit;
 
 public class SparseFileContent {
-  protected String path;
   protected List<Range> ranges;
   protected int size;
-  protected boolean missingNewlineAtEnd;
 
   private transient int currentRangeIdx;
 
@@ -34,40 +31,11 @@
     return size;
   }
 
-  public void setSize(final int s) {
+  public void setSize(int s) {
     size = s;
   }
 
-  public boolean isMissingNewlineAtEnd() {
-    return missingNewlineAtEnd;
-  }
-
-  public void setMissingNewlineAtEnd(final boolean missing) {
-    missingNewlineAtEnd = missing;
-  }
-
-  public String getPath() {
-    return path;
-  }
-
-  public void setPath(String filePath) {
-    path = filePath;
-  }
-
-  public boolean isWholeFile() {
-    if (size == 0) {
-      return true;
-
-    } else if (1 == ranges.size()) {
-      Range r = ranges.get(0);
-      return r.base == 0 && r.end() == size;
-
-    } else {
-      return false;
-    }
-  }
-
-  public String get(final int idx) {
+  public String get(int idx) {
     final String line = getLine(idx);
     if (line == null) {
       throw new ArrayIndexOutOfBoundsException(idx);
@@ -75,7 +43,7 @@
     return line;
   }
 
-  public boolean contains(final int idx) {
+  public boolean contains(int idx) {
     return getLine(idx) != null;
   }
 
@@ -83,7 +51,7 @@
     return ranges.isEmpty() ? size() : ranges.get(0).base;
   }
 
-  public int next(final int idx) {
+  public int next(int idx) {
     // Most requests are sequential in nature, fetching the next
     // line from the current range, or the immediate next range.
     //
@@ -138,18 +106,7 @@
     return size();
   }
 
-  public int mapIndexToLine(int arrayIndex) {
-    final int origIndex = arrayIndex;
-    for (Range r : ranges) {
-      if (arrayIndex < r.lines.size()) {
-        return r.base + arrayIndex;
-      }
-      arrayIndex -= r.lines.size();
-    }
-    throw new ArrayIndexOutOfBoundsException(origIndex);
-  }
-
-  private String getLine(final int idx) {
+  private String getLine(int idx) {
     // Most requests are sequential in nature, fetching the next
     // line from the current range, or the next range.
     //
@@ -191,7 +148,7 @@
     return null;
   }
 
-  public void addLine(final int i, final String content) {
+  public void addLine(int i, String content) {
     final Range r;
     if (!ranges.isEmpty() && i == last().end()) {
       r = last();
@@ -206,58 +163,6 @@
     return ranges.get(ranges.size() - 1);
   }
 
-  public String asString() {
-    final StringBuilder b = new StringBuilder();
-    for (Range r : ranges) {
-      for (String l : r.lines) {
-        b.append(l);
-        b.append('\n');
-      }
-    }
-    if (0 < b.length() && isMissingNewlineAtEnd()) {
-      b.setLength(b.length() - 1);
-    }
-    return b.toString();
-  }
-
-  public SparseFileContent apply(SparseFileContent a, List<Edit> edits) {
-    EditList list = new EditList(edits, size, a.size(), size);
-    ArrayList<String> lines = new ArrayList<>(size);
-    for (final EditList.Hunk hunk : list.getHunks()) {
-      while (hunk.next()) {
-        if (hunk.isContextLine()) {
-          if (contains(hunk.getCurB())) {
-            lines.add(get(hunk.getCurB()));
-          } else {
-            lines.add(a.get(hunk.getCurA()));
-          }
-          hunk.incBoth();
-          continue;
-        }
-
-        if (hunk.isDeletedA()) {
-          hunk.incA();
-        }
-
-        if (hunk.isInsertedB()) {
-          lines.add(get(hunk.getCurB()));
-          hunk.incB();
-        }
-      }
-    }
-
-    Range range = new Range();
-    range.lines = lines;
-
-    SparseFileContent r = new SparseFileContent();
-    r.setSize(lines.size());
-    r.setMissingNewlineAtEnd(isMissingNewlineAtEnd());
-    r.setPath(getPath());
-    r.ranges.add(range);
-
-    return r;
-  }
-
   @Override
   public String toString() {
     final StringBuilder b = new StringBuilder();
@@ -275,14 +180,14 @@
     protected int base;
     protected List<String> lines;
 
-    private Range(final int b) {
+    private Range(int b) {
       base = b;
       lines = new ArrayList<>();
     }
 
     protected Range() {}
 
-    private String get(final int i) {
+    private String get(int i) {
       return lines.get(i - base);
     }
 
@@ -290,7 +195,7 @@
       return base + lines.size();
     }
 
-    private boolean contains(final int i) {
+    private boolean contains(int i) {
       return base <= i && i < end();
     }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
index d1fcbe0..a1c16e1 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Account.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.reviewdb.client;
 
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_DRAFT_COMMENTS;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_STARRED_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS;
 
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
@@ -80,6 +82,10 @@
       }
       if (name.startsWith(REFS_USERS)) {
         return fromRefPart(name.substring(REFS_USERS.length()));
+      } else if (name.startsWith(REFS_DRAFT_COMMENTS)) {
+        return parseAfterShardedRefPart(name.substring(REFS_DRAFT_COMMENTS.length()));
+      } else if (name.startsWith(REFS_STARRED_CHANGES)) {
+        return parseAfterShardedRefPart(name.substring(REFS_STARRED_CHANGES.length()));
       }
       return null;
     }
@@ -95,6 +101,11 @@
       return id != null ? new Account.Id(id) : null;
     }
 
+    public static Id parseAfterShardedRefPart(String name) {
+      Integer id = RefNames.parseAfterShardedRefPart(name);
+      return id != null ? new Account.Id(id) : null;
+    }
+
     /**
      * Parse an Account.Id out of the last part of a ref name.
      *
@@ -147,12 +158,18 @@
   /** <i>stored in git, used for caching</i> the user's preferences. */
   private GeneralPreferencesInfo generalPreferences;
 
+  /**
+   * ID of the user branch from which the account was read, {@code null} if the account was read
+   * from ReviewDb.
+   */
+  private String metaId;
+
   protected Account() {}
 
   /**
    * Create a new account.
    *
-   * @param newId unique id, see {@link com.google.gerrit.reviewdb.server.ReviewDb#nextAccountId()}.
+   * @param newId unique id, see {@link com.google.gerrit.server.Sequences#nextAccountId()}.
    * @param registeredOn when the account was registered.
    */
   public Account(Account.Id newId, Timestamp registeredOn) {
@@ -246,6 +263,14 @@
     generalPreferences = p;
   }
 
+  public String getMetaId() {
+    return metaId;
+  }
+
+  public void setMetaId(String metaId) {
+    this.metaId = metaId;
+  }
+
   public boolean isActive() {
     return !inactive;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
deleted file mode 100644
index 3c8f2fa..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountExternalId.java
+++ /dev/null
@@ -1,186 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.client;
-
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gwtorm.client.Column;
-import com.google.gwtorm.client.StringKey;
-import java.util.Objects;
-
-/** Association of an external account identifier to a local {@link Account}. */
-public final class AccountExternalId {
-  /**
-   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
-   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
-   *
-   * <p>The name {@code gerrit:} was a very poor choice.
-   */
-  public static final String SCHEME_GERRIT = "gerrit:";
-
-  /** Scheme used for randomly created identities constructed by a UUID. */
-  public static final String SCHEME_UUID = "uuid:";
-
-  /** Scheme used to represent only an email address. */
-  public static final String SCHEME_MAILTO = "mailto:";
-
-  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
-  public static final String SCHEME_USERNAME = "username:";
-
-  /** Scheme used for GPG public keys. */
-  public static final String SCHEME_GPGKEY = "gpgkey:";
-
-  /** Scheme for external auth used during authentication, e.g. OAuth Token */
-  public static final String SCHEME_EXTERNAL = "external:";
-
-  public static class Key extends StringKey<com.google.gwtorm.client.Key<?>> {
-    private static final long serialVersionUID = 1L;
-
-    @Column(id = 1)
-    protected String externalId;
-
-    protected Key() {}
-
-    public Key(String scheme, final String identity) {
-      if (!scheme.endsWith(":")) {
-        scheme += ":";
-      }
-      externalId = scheme + identity;
-    }
-
-    public Key(final String e) {
-      externalId = e;
-    }
-
-    @Override
-    public String get() {
-      return externalId;
-    }
-
-    @Override
-    protected void set(String newValue) {
-      externalId = newValue;
-    }
-
-    public String getScheme() {
-      int c = externalId.indexOf(':');
-      return 0 < c ? externalId.substring(0, c) : null;
-    }
-  }
-
-  @Column(id = 1, name = Column.NONE)
-  protected Key key;
-
-  @Column(id = 2)
-  protected Account.Id accountId;
-
-  @Column(id = 3, notNull = false)
-  protected String emailAddress;
-
-  // Encoded version of the hashed and salted password, to be interpreted by the
-  // {@link HashedPassword} class.
-  @Column(id = 4, notNull = false)
-  protected String password;
-
-  /** <i>computed value</i> is this identity trusted by the site administrator? */
-  protected boolean trusted;
-
-  /** <i>computed value</i> can this identity be removed from the account? */
-  protected boolean canDelete;
-
-  protected AccountExternalId() {}
-
-  /**
-   * Create a new binding to an external identity.
-   *
-   * @param who the account this binds to.
-   * @param k the binding key.
-   */
-  public AccountExternalId(final Account.Id who, final AccountExternalId.Key k) {
-    accountId = who;
-    key = k;
-  }
-
-  public AccountExternalId.Key getKey() {
-    return key;
-  }
-
-  /** Get local id of this account, to link with in other entities */
-  public Account.Id getAccountId() {
-    return accountId;
-  }
-
-  public String getExternalId() {
-    return key.externalId;
-  }
-
-  public String getEmailAddress() {
-    return emailAddress;
-  }
-
-  public void setEmailAddress(final String e) {
-    emailAddress = e;
-  }
-
-  public boolean isScheme(final String scheme) {
-    final String id = getExternalId();
-    return id != null && id.startsWith(scheme);
-  }
-
-  public String getSchemeRest() {
-    String scheme = key.getScheme();
-    return null != scheme ? getExternalId().substring(scheme.length() + 1) : null;
-  }
-
-  public void setPassword(String hashed) {
-    password = hashed;
-  }
-
-  public String getPassword() {
-    return password;
-  }
-
-  public boolean isTrusted() {
-    return trusted;
-  }
-
-  public void setTrusted(final boolean t) {
-    trusted = t;
-  }
-
-  public boolean canDelete() {
-    return canDelete;
-  }
-
-  public void setCanDelete(final boolean t) {
-    canDelete = t;
-  }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o instanceof AccountExternalId) {
-      AccountExternalId extId = (AccountExternalId) o;
-      return Objects.equals(key, extId.key)
-          && Objects.equals(accountId, extId.accountId)
-          && Objects.equals(emailAddress, extId.emailAddress)
-          && Objects.equals(password, extId.password);
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hash(key, accountId, emailAddress, password);
-  }
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java
index c3b2908..74dadc5 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroup.java
@@ -17,9 +17,22 @@
 import com.google.gwtorm.client.Column;
 import com.google.gwtorm.client.IntKey;
 import com.google.gwtorm.client.StringKey;
+import java.sql.Timestamp;
 
 /** Named group of one or more accounts, typically used for access controls. */
 public final class AccountGroup {
+  /**
+   * Time when the audit subsystem was implemented, used as the default value for {@link #createdOn}
+   * when one couldn't be determined from the audit log.
+   */
+  // Can't use Instant here because GWT. This is verified against a readable time in the tests,
+  // which don't need to compile under GWT.
+  private static final long AUDIT_CREATION_INSTANT_MS = 1244489460000L;
+
+  public static Timestamp auditCreationInstantTs() {
+    return new Timestamp(AUDIT_CREATION_INSTANT_MS);
+  }
+
   /** Group name key */
   public static class NameKey extends StringKey<com.google.gwtorm.client.Key<?>> {
     private static final long serialVersionUID = 1L;
@@ -29,7 +42,7 @@
 
     protected NameKey() {}
 
-    public NameKey(final String n) {
+    public NameKey(String n) {
       name = n;
     }
 
@@ -53,7 +66,7 @@
 
     protected UUID() {}
 
-    public UUID(final String n) {
+    public UUID(String n) {
       uuid = n;
     }
 
@@ -68,7 +81,7 @@
     }
 
     /** Parse an AccountGroup.UUID out of a string representation. */
-    public static UUID parse(final String str) {
+    public static UUID parse(String str) {
       final UUID r = new UUID();
       r.fromString(str);
       return r;
@@ -89,7 +102,7 @@
 
     protected Id() {}
 
-    public Id(final int id) {
+    public Id(int id) {
       this.id = id;
     }
 
@@ -104,7 +117,7 @@
     }
 
     /** Parse an AccountGroup.Id out of a string representation. */
-    public static Id parse(final String str) {
+    public static Id parse(String str) {
       final Id r = new Id();
       r.fromString(str);
       return r;
@@ -145,17 +158,22 @@
   @Column(id = 10)
   protected UUID ownerGroupUUID;
 
+  @Column(id = 11, notNull = false)
+  protected Timestamp createdOn;
+
   protected AccountGroup() {}
 
   public AccountGroup(
-      final AccountGroup.NameKey newName,
-      final AccountGroup.Id newId,
-      final AccountGroup.UUID uuid) {
+      AccountGroup.NameKey newName,
+      AccountGroup.Id newId,
+      AccountGroup.UUID uuid,
+      Timestamp createdOn) {
     name = newName;
     groupId = newId;
     visibleToAll = false;
     groupUUID = uuid;
     ownerGroupUUID = groupUUID;
+    this.createdOn = createdOn;
   }
 
   public AccountGroup.Id getId() {
@@ -170,7 +188,7 @@
     return name;
   }
 
-  public void setNameKey(final AccountGroup.NameKey nameKey) {
+  public void setNameKey(AccountGroup.NameKey nameKey) {
     name = nameKey;
   }
 
@@ -178,7 +196,7 @@
     return description;
   }
 
-  public void setDescription(final String d) {
+  public void setDescription(String d) {
     description = d;
   }
 
@@ -186,11 +204,11 @@
     return ownerGroupUUID;
   }
 
-  public void setOwnerGroupUUID(final AccountGroup.UUID uuid) {
+  public void setOwnerGroupUUID(AccountGroup.UUID uuid) {
     ownerGroupUUID = uuid;
   }
 
-  public void setVisibleToAll(final boolean visibleToAll) {
+  public void setVisibleToAll(boolean visibleToAll) {
     this.visibleToAll = visibleToAll;
   }
 
@@ -205,4 +223,12 @@
   public void setGroupUUID(AccountGroup.UUID uuid) {
     groupUUID = uuid;
   }
+
+  public Timestamp getCreatedOn() {
+    return createdOn != null ? createdOn : auditCreationInstantTs();
+  }
+
+  public void setCreatedOn(Timestamp createdOn) {
+    this.createdOn = createdOn;
+  }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
index b4bf783..99ff35be 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupById.java
@@ -33,7 +33,7 @@
       includeUUID = new AccountGroup.UUID();
     }
 
-    public Key(final AccountGroup.Id g, final AccountGroup.UUID u) {
+    public Key(AccountGroup.Id g, AccountGroup.UUID u) {
       groupId = g;
       includeUUID = u;
     }
@@ -62,7 +62,7 @@
 
   protected AccountGroupById() {}
 
-  public AccountGroupById(final AccountGroupById.Key k) {
+  public AccountGroupById(AccountGroupById.Key k) {
     key = k;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
index d1e72af..a127a70 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupByIdAud.java
@@ -37,7 +37,7 @@
       includeUUID = new AccountGroup.UUID();
     }
 
-    public Key(final AccountGroup.Id g, final AccountGroup.UUID u, final Timestamp t) {
+    public Key(AccountGroup.Id g, AccountGroup.UUID u, Timestamp t) {
       groupId = g;
       includeUUID = u;
       addedOn = t;
@@ -76,8 +76,7 @@
 
   protected AccountGroupByIdAud() {}
 
-  public AccountGroupByIdAud(
-      final AccountGroupById m, final Account.Id adder, final Timestamp when) {
+  public AccountGroupByIdAud(final AccountGroupById m, Account.Id adder, Timestamp when) {
     final AccountGroup.Id group = m.getGroupId();
     final AccountGroup.UUID include = m.getIncludeUUID();
     key = new AccountGroupByIdAud.Key(group, include, when);
@@ -92,7 +91,7 @@
     return removedOn == null;
   }
 
-  public void removed(final Account.Id deleter, final Timestamp when) {
+  public void removed(Account.Id deleter, Timestamp when) {
     removedBy = deleter;
     removedOn = when;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
index ce6999f..ce5b347 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMember.java
@@ -33,7 +33,7 @@
       groupId = new AccountGroup.Id();
     }
 
-    public Key(final Account.Id a, final AccountGroup.Id g) {
+    public Key(Account.Id a, AccountGroup.Id g) {
       accountId = a;
       groupId = g;
     }
@@ -58,7 +58,7 @@
 
   protected AccountGroupMember() {}
 
-  public AccountGroupMember(final AccountGroupMember.Key k) {
+  public AccountGroupMember(AccountGroupMember.Key k) {
     key = k;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
index 4f3992d..da19351 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountGroupMemberAudit.java
@@ -37,7 +37,7 @@
       groupId = new AccountGroup.Id();
     }
 
-    public Key(final Account.Id a, final AccountGroup.Id g, final Timestamp t) {
+    public Key(Account.Id a, AccountGroup.Id g, Timestamp t) {
       accountId = a;
       groupId = g;
       addedOn = t;
@@ -76,8 +76,7 @@
 
   protected AccountGroupMemberAudit() {}
 
-  public AccountGroupMemberAudit(
-      final AccountGroupMember m, final Account.Id adder, Timestamp addedOn) {
+  public AccountGroupMemberAudit(final AccountGroupMember m, Account.Id adder, Timestamp addedOn) {
     final Account.Id who = m.getAccountId();
     final AccountGroup.Id group = m.getAccountGroupId();
     key = new AccountGroupMemberAudit.Key(who, group, addedOn);
@@ -92,7 +91,7 @@
     return removedOn == null;
   }
 
-  public void removed(final Account.Id deleter, final Timestamp when) {
+  public void removed(Account.Id deleter, Timestamp when) {
     removedBy = deleter;
     removedOn = when;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
index 3645dac..372d644 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/AccountSshKey.java
@@ -30,7 +30,7 @@
       accountId = new Account.Id();
     }
 
-    public Id(final Account.Id a, final int s) {
+    public Id(Account.Id a, int s) {
       accountId = a;
       seq = s;
     }
@@ -63,7 +63,7 @@
 
   protected AccountSshKey() {}
 
-  public AccountSshKey(final AccountSshKey.Id i, final String pub) {
+  public AccountSshKey(AccountSshKey.Id i, String pub) {
     id = i;
     sshPublicKey = pub.replace("\n", "").replace("\r", "");
     valid = id.isValid();
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
index d0df7c6..fd8bbfd 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Branch.java
@@ -33,12 +33,12 @@
       projectName = new Project.NameKey();
     }
 
-    public NameKey(final Project.NameKey proj, final String branchName) {
+    public NameKey(Project.NameKey proj, String branchName) {
       projectName = proj;
       set(branchName);
     }
 
-    public NameKey(String proj, final String branchName) {
+    public NameKey(String proj, String branchName) {
       this(new Project.NameKey(proj), branchName);
     }
 
@@ -68,7 +68,7 @@
 
   protected Branch() {}
 
-  public Branch(final Branch.NameKey newName) {
+  public Branch(Branch.NameKey newName) {
     name = newName;
   }
 
@@ -88,7 +88,7 @@
     return revision;
   }
 
-  public void setRevision(final RevId id) {
+  public void setRevision(RevId id) {
     revision = id;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
index 9655edd..201315e 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
@@ -102,7 +102,7 @@
 
     protected Id() {}
 
-    public Id(final int id) {
+    public Id(int id) {
       this.id = id;
     }
 
@@ -130,7 +130,7 @@
     }
 
     /** Parse a Change.Id out of a string representation. */
-    public static Id parse(final String str) {
+    public static Id parse(String str) {
       final Id r = new Id();
       r.fromString(str);
       return r;
@@ -262,7 +262,7 @@
 
     protected Key() {}
 
-    public Key(final String id) {
+    public Key(String id) {
       this.id = id;
     }
 
@@ -291,7 +291,7 @@
     }
 
     /** Parse a Change.Key out of a string representation. */
-    public static Key parse(final String str) {
+    public static Key parse(String str) {
       final Key r = new Key();
       r.fromString(str);
       return r;
@@ -302,8 +302,6 @@
   private static final char MIN_OPEN = 'a';
   /** Database constant for {@link Status#NEW}. */
   public static final char STATUS_NEW = 'n';
-  /** Database constant for {@link Status#DRAFT}. */
-  public static final char STATUS_DRAFT = 'd';
   /** Maximum database status constant for an open change. */
   private static final char MAX_OPEN = 'z';
 
@@ -341,26 +339,9 @@
     NEW(STATUS_NEW, ChangeStatus.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.
-     *
-     * <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(STATUS_DRAFT, ChangeStatus.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
-     * set. Draft comments however may be published, supporting a post-submit review.
      */
     MERGED(STATUS_MERGED, ChangeStatus.MERGED),
 
@@ -416,12 +397,20 @@
       return changeStatus;
     }
 
-    public static Status forCode(final char c) {
-      for (final Status s : Status.values()) {
+    public static Status forCode(char c) {
+      for (Status s : Status.values()) {
         if (s.code == c) {
           return s;
         }
       }
+
+      // TODO(davido): Remove in 3.0, after all sites upgraded to version,
+      // where DRAFT status was removed. This code path is still needed,
+      // when changes are deserialized from the secondary index, during
+      // the online migration to the new schema version wasn't completed.
+      if (c == 'd') {
+        return Status.NEW;
+      }
       return null;
     }
 
@@ -512,6 +501,22 @@
   @Column(id = 19, notNull = false)
   protected Account.Id assignee;
 
+  /** Whether the change is private. */
+  @Column(id = 20)
+  protected boolean isPrivate;
+
+  /** Whether the change is work in progress. */
+  @Column(id = 21)
+  protected boolean workInProgress;
+
+  /** Whether the change has started review. */
+  @Column(id = 22)
+  protected boolean reviewStarted;
+
+  /** References a change that this change reverts. */
+  @Column(id = 23, notNull = false)
+  protected Id revertOf;
+
   /** @see com.google.gerrit.server.notedb.NoteDbChangeState */
   @Column(id = 101, notNull = false, length = Integer.MAX_VALUE)
   protected String noteDbState;
@@ -548,7 +553,11 @@
     originalSubject = other.originalSubject;
     submissionId = other.submissionId;
     topic = other.topic;
+    isPrivate = other.isPrivate;
+    workInProgress = other.workInProgress;
+    reviewStarted = other.reviewStarted;
     noteDbState = other.noteDbState;
+    revertOf = other.revertOf;
   }
 
   /** Legacy 32 bit integer identity for a change. */
@@ -566,7 +575,7 @@
     return changeKey;
   }
 
-  public void setKey(final Change.Key k) {
+  public void setKey(Change.Key k) {
     changeKey = k;
   }
 
@@ -638,7 +647,7 @@
     return null;
   }
 
-  public void setCurrentPatchSet(final PatchSetInfo ps) {
+  public void setCurrentPatchSet(PatchSetInfo ps) {
     if (originalSubject == null && subject != null) {
       // Change was created before schema upgrade. Use the last subject
       // associated with this change, as the most recent discussion will
@@ -694,6 +703,38 @@
     this.topic = topic;
   }
 
+  public boolean isPrivate() {
+    return isPrivate;
+  }
+
+  public void setPrivate(boolean isPrivate) {
+    this.isPrivate = isPrivate;
+  }
+
+  public boolean isWorkInProgress() {
+    return workInProgress;
+  }
+
+  public void setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+  }
+
+  public boolean hasReviewStarted() {
+    return reviewStarted;
+  }
+
+  public void setReviewStarted(boolean reviewStarted) {
+    this.reviewStarted = reviewStarted;
+  }
+
+  public void setRevertOf(Id revertOf) {
+    this.revertOf = revertOf;
+  }
+
+  public Id getRevertOf() {
+    return this.revertOf;
+  }
+
   public String getNoteDbState() {
     return noteDbState;
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
index caf20c7..edc022f 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
@@ -34,7 +34,7 @@
       changeId = new Change.Id();
     }
 
-    public Key(final Change.Id change, final String uuid) {
+    public Key(Change.Id change, String uuid) {
       this.changeId = change;
       this.uuid = uuid;
     }
@@ -84,8 +84,7 @@
 
   protected ChangeMessage() {}
 
-  public ChangeMessage(
-      final ChangeMessage.Key k, final Account.Id a, final Timestamp wo, final PatchSet.Id psid) {
+  public ChangeMessage(final ChangeMessage.Key k, Account.Id a, Timestamp wo, PatchSet.Id psid) {
     key = k;
     author = a;
     writtenOn = wo;
@@ -101,7 +100,7 @@
     return author;
   }
 
-  public void setAuthor(final Account.Id accountId) {
+  public void setAuthor(Account.Id accountId) {
     if (author != null) {
       throw new IllegalStateException("Cannot modify author once assigned");
     }
@@ -129,7 +128,7 @@
     return message;
   }
 
-  public void setMessage(final String s) {
+  public void setMessage(String s) {
     message = s;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
index cadd52c..4b3c652 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.reviewdb.client;
 
 import java.sql.Timestamp;
+import java.util.Comparator;
 import java.util.Objects;
 
 /**
@@ -130,7 +131,13 @@
     }
   }
 
-  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.startChar)
+            .thenComparingInt(range -> range.endLine)
+            .thenComparingInt(range -> range.endChar);
+
     public int startLine; // 1-based, inclusive
     public int startChar; // 0-based, inclusive
     public int endLine; // 1-based, exclusive
@@ -186,6 +193,11 @@
           .append('}')
           .toString();
     }
+
+    @Override
+    public int compareTo(Range otherRange) {
+      return RANGE_COMPARATOR.compare(this, otherRange);
+    }
   }
 
   public Key key;
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
index 9d61186..6a3b69c 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/CurrentSchemaVersion.java
@@ -35,7 +35,7 @@
     }
 
     @Override
-    protected void set(final String newValue) {
+    protected void set(String newValue) {
       assert get().equals(newValue);
     }
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java
index c38078e..e69cab2 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/LabelId.java
@@ -31,7 +31,7 @@
 
   public LabelId() {}
 
-  public LabelId(final String n) {
+  public LabelId(String n) {
     id = n;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
index 269b6d4..0492c6c 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Patch.java
@@ -49,7 +49,7 @@
       patchSetId = new PatchSet.Id();
     }
 
-    public Key(final PatchSet.Id ps, final String name) {
+    public Key(PatchSet.Id ps, String name) {
       this.patchSetId = ps;
       this.fileName = name;
     }
@@ -70,7 +70,7 @@
     }
 
     /** Parse a Patch.Id out of a string representation. */
-    public static Key parse(final String str) {
+    public static Key parse(String str) {
       final Key r = new Key();
       r.fromString(str);
       return r;
@@ -103,7 +103,7 @@
 
     private final char code;
 
-    ChangeType(final char c) {
+    ChangeType(char c) {
       code = c;
     }
 
@@ -116,8 +116,8 @@
       return s != null && s.length() == 1 && s.charAt(0) == code;
     }
 
-    public static ChangeType forCode(final char c) {
-      for (final ChangeType s : ChangeType.values()) {
+    public static ChangeType forCode(char c) {
+      for (ChangeType s : ChangeType.values()) {
         if (s.code == c) {
           return s;
         }
@@ -156,7 +156,7 @@
 
     private final char code;
 
-    PatchType(final char c) {
+    PatchType(char c) {
       code = c;
     }
 
@@ -165,8 +165,8 @@
       return code;
     }
 
-    public static PatchType forCode(final char c) {
-      for (final PatchType s : PatchType.values()) {
+    public static PatchType forCode(char c) {
+      for (PatchType s : PatchType.values()) {
         if (s.code == c) {
           return s;
         }
@@ -203,7 +203,7 @@
 
   protected Patch() {}
 
-  public Patch(final Patch.Key newId) {
+  public Patch(Patch.Key newId) {
     key = newId;
     setChangeType(ChangeType.MODIFIED);
     setPatchType(PatchType.UNIFIED);
@@ -217,7 +217,7 @@
     return nbrComments;
   }
 
-  public void setCommentCount(final int n) {
+  public void setCommentCount(int n) {
     nbrComments = n;
   }
 
@@ -225,7 +225,7 @@
     return nbrDrafts;
   }
 
-  public void setDraftCount(final int n) {
+  public void setDraftCount(int n) {
     nbrDrafts = n;
   }
 
@@ -249,7 +249,7 @@
     return ChangeType.forCode(changeType);
   }
 
-  public void setChangeType(final ChangeType type) {
+  public void setChangeType(ChangeType type) {
     changeType = type.getCode();
   }
 
@@ -257,7 +257,7 @@
     return PatchType.forCode(patchType);
   }
 
-  public void setPatchType(final PatchType type) {
+  public void setPatchType(PatchType type) {
     patchType = type.getCode();
   }
 
@@ -269,7 +269,7 @@
     return sourceFileName;
   }
 
-  public void setSourceFileName(final String n) {
+  public void setSourceFileName(String n) {
     sourceFileName = n;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
index 90552b8..de953dc 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
@@ -48,7 +48,7 @@
       patchKey = new Patch.Key();
     }
 
-    public Key(final Patch.Key p, final String uuid) {
+    public Key(Patch.Key p, String uuid) {
       this.patchKey = p;
       this.uuid = uuid;
     }
@@ -84,7 +84,7 @@
 
     private final char code;
 
-    Status(final char c) {
+    Status(char c) {
       code = c;
     }
 
@@ -92,8 +92,8 @@
       return code;
     }
 
-    public static Status forCode(final char c) {
-      for (final Status s : Status.values()) {
+    public static Status forCode(char c) {
+      for (Status s : Status.values()) {
         if (s.code == c) {
           return s;
         }
@@ -247,7 +247,7 @@
     return Status.forCode(status);
   }
 
-  public void setStatus(final Status s) {
+  public void setStatus(Status s) {
     status = s.getCode();
   }
 
@@ -255,7 +255,7 @@
     return side;
   }
 
-  public void setSide(final short s) {
+  public void setSide(short s) {
     side = s;
   }
 
@@ -263,7 +263,7 @@
     return message;
   }
 
-  public void setMessage(final String s) {
+  public void setMessage(String s) {
     message = s;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
index aa61511..4536b67 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
@@ -86,7 +86,7 @@
       changeId = new Change.Id();
     }
 
-    public Id(final Change.Id change, final int id) {
+    public Id(Change.Id change, int id) {
       this.changeId = change;
       this.patchSetId = id;
     }
@@ -111,7 +111,7 @@
     }
 
     /** Parse a PatchSet.Id out of a string representation. */
-    public static Id parse(final String str) {
+    public static Id parse(String str) {
       final Id r = new Id();
       r.fromString(str);
       return r;
@@ -168,8 +168,7 @@
   @Column(id = 4)
   protected Timestamp createdOn;
 
-  @Column(id = 5)
-  protected boolean draft;
+  // @Column(id = 5)
 
   /**
    * Opaque group identifier, usually assigned during creation.
@@ -200,7 +199,7 @@
 
   protected PatchSet() {}
 
-  public PatchSet(final PatchSet.Id k) {
+  public PatchSet(PatchSet.Id k) {
     id = k;
   }
 
@@ -209,7 +208,6 @@
     this.revision = src.revision;
     this.uploader = src.uploader;
     this.createdOn = src.createdOn;
-    this.draft = src.draft;
     this.groups = src.groups;
     this.pushCertificate = src.pushCertificate;
     this.description = src.description;
@@ -227,7 +225,7 @@
     return revision;
   }
 
-  public void setRevision(final RevId i) {
+  public void setRevision(RevId i) {
     revision = i;
   }
 
@@ -235,7 +233,7 @@
     return uploader;
   }
 
-  public void setUploader(final Account.Id who) {
+  public void setUploader(Account.Id who) {
     uploader = who;
   }
 
@@ -243,18 +241,10 @@
     return createdOn;
   }
 
-  public void setCreatedOn(final Timestamp ts) {
+  public void setCreatedOn(Timestamp ts) {
     createdOn = ts;
   }
 
-  public boolean isDraft() {
-    return draft;
-  }
-
-  public void setDraft(boolean draftStatus) {
-    draft = draftStatus;
-  }
-
   public List<String> getGroups() {
     if (groups == null) {
       return Collections.emptyList();
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
index ef2732b..0f3e4e1 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
@@ -40,7 +40,7 @@
       categoryId = new LabelId();
     }
 
-    public Key(final PatchSet.Id ps, final Account.Id a, final LabelId c) {
+    public Key(PatchSet.Id ps, Account.Id a, LabelId c) {
       this.patchSetId = ps;
       this.accountId = a;
       this.categoryId = c;
@@ -111,7 +111,7 @@
     setGranted(ts);
   }
 
-  public PatchSetApproval(final PatchSet.Id psId, final PatchSetApproval src) {
+  public PatchSetApproval(PatchSet.Id psId, PatchSetApproval src) {
     key = new PatchSetApproval.Key(psId, src.getAccountId(), src.getLabelId());
     value = src.getValue();
     granted = src.granted;
@@ -153,7 +153,7 @@
     return value;
   }
 
-  public void setValue(final short v) {
+  public void setValue(short v) {
     value = v;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
index 4970db1..f949013 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetInfo.java
@@ -22,7 +22,7 @@
     public RevId id;
     public String shortMessage;
 
-    public ParentInfo(final RevId id, final String shortMessage) {
+    public ParentInfo(RevId id, String shortMessage) {
       this.id = id;
       this.shortMessage = shortMessage;
     }
@@ -55,7 +55,7 @@
 
   protected PatchSetInfo() {}
 
-  public PatchSetInfo(final PatchSet.Id k) {
+  public PatchSetInfo(PatchSet.Id k) {
     key = k;
   }
 
@@ -67,7 +67,7 @@
     return subject;
   }
 
-  public void setSubject(final String s) {
+  public void setSubject(String s) {
     if (s != null && s.length() > 255) {
       subject = s.substring(0, 255);
     } else {
@@ -79,7 +79,7 @@
     return message;
   }
 
-  public void setMessage(final String m) {
+  public void setMessage(String m) {
     message = m;
   }
 
@@ -87,7 +87,7 @@
     return author;
   }
 
-  public void setAuthor(final UserIdentity u) {
+  public void setAuthor(UserIdentity u) {
     author = u;
   }
 
@@ -95,11 +95,11 @@
     return committer;
   }
 
-  public void setCommitter(final UserIdentity u) {
+  public void setCommitter(UserIdentity u) {
     committer = u;
   }
 
-  public void setParents(final List<ParentInfo> p) {
+  public void setParents(List<ParentInfo> p) {
     parents = p;
   }
 
@@ -107,7 +107,7 @@
     return parents;
   }
 
-  public void setRevId(final String s) {
+  public void setRevId(String s) {
     revId = s;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
index ba83c58..b98359f 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Project.java
@@ -31,7 +31,7 @@
 
     protected NameKey() {}
 
-    public NameKey(final String n) {
+    public NameKey(String n) {
       name = n;
     }
 
@@ -59,11 +59,15 @@
     }
 
     /** Parse a Project.NameKey out of a string representation. */
-    public static NameKey parse(final String str) {
+    public static NameKey parse(String str) {
       final NameKey r = new NameKey();
       r.fromString(str);
       return r;
     }
+
+    public static String asStringOrNull(NameKey key) {
+      return key == null ? null : key.get();
+    }
   }
 
   protected NameKey name;
@@ -98,6 +102,12 @@
   protected InheritableBoolean requireSignedPush;
 
   protected InheritableBoolean rejectImplicitMerges;
+  protected InheritableBoolean privateByDefault;
+  protected InheritableBoolean workInProgressByDefault;
+
+  protected InheritableBoolean enableReviewerByEmail;
+
+  protected InheritableBoolean matchAuthorToCommitterDate;
 
   protected Project() {}
 
@@ -112,6 +122,10 @@
     createNewChangeForAllNotInTarget = InheritableBoolean.INHERIT;
     enableSignedPush = InheritableBoolean.INHERIT;
     requireSignedPush = InheritableBoolean.INHERIT;
+    privateByDefault = InheritableBoolean.INHERIT;
+    workInProgressByDefault = InheritableBoolean.INHERIT;
+    enableReviewerByEmail = InheritableBoolean.INHERIT;
+    matchAuthorToCommitterDate = InheritableBoolean.INHERIT;
   }
 
   public Project.NameKey getNameKey() {
@@ -126,7 +140,7 @@
     return description;
   }
 
-  public void setDescription(final String d) {
+  public void setDescription(String d) {
     description = d;
   }
 
@@ -154,19 +168,51 @@
     return rejectImplicitMerges;
   }
 
-  public void setUseContributorAgreements(final InheritableBoolean u) {
+  public InheritableBoolean getPrivateByDefault() {
+    return privateByDefault;
+  }
+
+  public void setPrivateByDefault(InheritableBoolean privateByDefault) {
+    this.privateByDefault = privateByDefault;
+  }
+
+  public InheritableBoolean getWorkInProgressByDefault() {
+    return workInProgressByDefault;
+  }
+
+  public void setWorkInProgressByDefault(InheritableBoolean workInProgressByDefault) {
+    this.workInProgressByDefault = workInProgressByDefault;
+  }
+
+  public InheritableBoolean getEnableReviewerByEmail() {
+    return enableReviewerByEmail;
+  }
+
+  public void setEnableReviewerByEmail(InheritableBoolean enable) {
+    enableReviewerByEmail = enable;
+  }
+
+  public InheritableBoolean getMatchAuthorToCommitterDate() {
+    return matchAuthorToCommitterDate;
+  }
+
+  public void setMatchAuthorToCommitterDate(InheritableBoolean match) {
+    matchAuthorToCommitterDate = match;
+  }
+
+  public void setUseContributorAgreements(InheritableBoolean u) {
     useContributorAgreements = u;
   }
 
-  public void setUseSignedOffBy(final InheritableBoolean sbo) {
+  public void setUseSignedOffBy(InheritableBoolean sbo) {
     useSignedOffBy = sbo;
   }
 
-  public void setUseContentMerge(final InheritableBoolean cm) {
+  public void setUseContentMerge(InheritableBoolean cm) {
     useContentMerge = cm;
   }
 
-  public void setRequireChangeID(final InheritableBoolean cid) {
+  public void setRequireChangeID(InheritableBoolean cid) {
     requireChangeID = cid;
   }
 
@@ -194,7 +240,7 @@
     requireSignedPush = require;
   }
 
-  public void setMaxObjectSizeLimit(final String limit) {
+  public void setMaxObjectSizeLimit(String limit) {
     maxObjectSizeLimit = limit;
   }
 
@@ -206,7 +252,7 @@
     return submitType;
   }
 
-  public void setSubmitType(final SubmitType type) {
+  public void setSubmitType(SubmitType type) {
     submitType = type;
   }
 
@@ -214,7 +260,7 @@
     return state;
   }
 
-  public void setState(final ProjectState newState) {
+  public void setState(ProjectState newState) {
     state = newState;
   }
 
@@ -222,7 +268,7 @@
     return defaultDashboardId;
   }
 
-  public void setDefaultDashboard(final String defaultDashboardId) {
+  public void setDefaultDashboard(String defaultDashboardId) {
     this.defaultDashboardId = defaultDashboardId;
   }
 
@@ -230,7 +276,7 @@
     return localDefaultDashboardId;
   }
 
-  public void setLocalDefaultDashboard(final String localDefaultDashboardId) {
+  public void setLocalDefaultDashboard(String localDefaultDashboardId) {
     this.localDefaultDashboardId = localDefaultDashboardId;
   }
 
@@ -238,11 +284,11 @@
     return themeName;
   }
 
-  public void setThemeName(final String themeName) {
+  public void setThemeName(String themeName) {
     this.themeName = themeName;
   }
 
-  public void copySettingsFrom(final Project update) {
+  public void copySettingsFrom(Project update) {
     description = update.description;
     useContributorAgreements = update.useContributorAgreements;
     useSignedOffBy = update.useSignedOffBy;
@@ -271,7 +317,7 @@
    * @param allProjectsName name key of the wild project
    * @return name key of the parent project, {@code null} if this project is the wild project
    */
-  public Project.NameKey getParent(final Project.NameKey allProjectsName) {
+  public Project.NameKey getParent(Project.NameKey allProjectsName) {
     if (parent != null) {
       return parent;
     }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index b892e3d..89de9dc 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -37,9 +37,6 @@
   /** Note tree listing external IDs */
   public static final String REFS_EXTERNAL_IDS = "refs/meta/external-ids";
 
-  /** Preference settings for a user {@code refs/users} */
-  public static final String REFS_USERS = "refs/users/";
-
   /** Magic user branch in All-Users {@code refs/users/self} */
   public static final String REFS_USERS_SELF = "refs/users/self";
 
@@ -49,12 +46,6 @@
   /** Configurations of project-specific dashboards (canned search queries). */
   public static final String REFS_DASHBOARDS = "refs/meta/dashboards/";
 
-  /** Draft inline comments of a user on a change */
-  public static final String REFS_DRAFT_COMMENTS = "refs/draft-comments/";
-
-  /** A change starred by a user */
-  public static final String REFS_STARRED_CHANGES = "refs/starred-changes/";
-
   /** Sequence counters in NoteDb. */
   public static final String REFS_SEQUENCES = "refs/sequences/";
 
@@ -76,6 +67,27 @@
 
   public static final String EDIT_PREFIX = "edit-";
 
+  /*
+   * The following refs contain an account ID and should be visible only to that account.
+   *
+   * Parsing the account ID from the ref is implemented in Account.Id#fromRef(String). This ensures
+   * that VisibleRefFilter hides those refs from other users.
+   *
+   * This applies to:
+   * - User branches (e.g. 'refs/users/23/1011123')
+   * - Draft comment refs (e.g. 'refs/draft-comments/73/67473/1011123')
+   * - Starred changes refs (e.g. 'refs/starred-changes/73/67473/1011123')
+   */
+
+  /** Preference settings for a user {@code refs/users} */
+  public static final String REFS_USERS = "refs/users/";
+
+  /** Draft inline comments of a user on a change */
+  public static final String REFS_DRAFT_COMMENTS = "refs/draft-comments/";
+
+  /** A change starred by a user */
+  public static final String REFS_STARRED_CHANGES = "refs/starred-changes/";
+
   public static String fullName(String ref) {
     return (ref.startsWith(REFS) || ref.equals(HEAD)) ? ref : REFS_HEADS + ref;
   }
@@ -90,32 +102,33 @@
   }
 
   public static String changeMetaRef(Change.Id id) {
-    StringBuilder r = new StringBuilder();
-    r.append(REFS_CHANGES);
-    r.append(shard(id.get()));
-    r.append(META_SUFFIX);
-    return r.toString();
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    return shard(id.get(), r).append(META_SUFFIX).toString();
   }
 
   public static String robotCommentsRef(Change.Id id) {
-    StringBuilder r = new StringBuilder();
-    r.append(REFS_CHANGES);
-    r.append(shard(id.get()));
-    r.append(ROBOT_COMMENTS_SUFFIX);
-    return r.toString();
+    StringBuilder r = newStringBuilder().append(REFS_CHANGES);
+    return shard(id.get(), r).append(ROBOT_COMMENTS_SUFFIX).toString();
+  }
+
+  public static boolean isNoteDbMetaRef(String ref) {
+    if (ref.startsWith(REFS_CHANGES)
+        && (ref.endsWith(META_SUFFIX) || ref.endsWith(ROBOT_COMMENTS_SUFFIX))) {
+      return true;
+    }
+    if (ref.startsWith(REFS_DRAFT_COMMENTS) || ref.startsWith(REFS_STARRED_CHANGES)) {
+      return true;
+    }
+    return false;
   }
 
   public static String refsUsers(Account.Id accountId) {
-    StringBuilder r = new StringBuilder();
-    r.append(REFS_USERS);
-    r.append(shard(accountId.get()));
-    return r.toString();
+    StringBuilder r = newStringBuilder().append(REFS_USERS);
+    return shard(accountId.get(), r).toString();
   }
 
   public static String refsDraftComments(Change.Id changeId, Account.Id accountId) {
-    StringBuilder r = buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get());
-    r.append(accountId.get());
-    return r.toString();
+    return buildRefsPrefix(REFS_DRAFT_COMMENTS, changeId.get()).append(accountId.get()).toString();
   }
 
   public static String refsDraftCommentsPrefix(Change.Id changeId) {
@@ -123,9 +136,7 @@
   }
 
   public static String refsStarredChanges(Change.Id changeId, Account.Id accountId) {
-    StringBuilder r = buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get());
-    r.append(accountId.get());
-    return r.toString();
+    return buildRefsPrefix(REFS_STARRED_CHANGES, changeId.get()).append(accountId.get()).toString();
   }
 
   public static String refsStarredChangesPrefix(Change.Id changeId) {
@@ -133,11 +144,8 @@
   }
 
   private static StringBuilder buildRefsPrefix(String prefix, int id) {
-    StringBuilder r = new StringBuilder();
-    r.append(prefix);
-    r.append(shard(id));
-    r.append('/');
-    return r;
+    StringBuilder r = newStringBuilder().append(prefix);
+    return shard(id, r).append('/');
   }
 
   public static String refsCacheAutomerge(String hash) {
@@ -148,15 +156,18 @@
     if (id < 0) {
       return null;
     }
-    StringBuilder r = new StringBuilder();
+    return shard(id, newStringBuilder()).toString();
+  }
+
+  private static StringBuilder shard(int id, StringBuilder sb) {
     int n = id % 100;
     if (n < 10) {
-      r.append('0');
+      sb.append('0');
     }
-    r.append(n);
-    r.append('/');
-    r.append(id);
-    return r.toString();
+    sb.append(n);
+    sb.append('/');
+    sb.append(id);
+    return sb;
   }
 
   /**
@@ -238,6 +249,88 @@
     return id;
   }
 
+  /**
+   * Skips a sharded ref part at the beginning of the name.
+   *
+   * <p>E.g.: "01/1" -> "", "01/1/" -> "/", "01/1/2" -> "/2", "01/1-edit" -> "-edit"
+   *
+   * @param name ref part name
+   * @return the rest of the name, {@code null} if the ref name part doesn't start with a valid
+   *     sharded ID
+   */
+  static String skipShardedRefPart(String name) {
+    if (name == null) {
+      return null;
+    }
+
+    String[] parts = name.split("/");
+    int n = parts.length;
+    if (n < 2) {
+      return null;
+    }
+
+    // Last 2 digits.
+    int le;
+    for (le = 0; le < parts[0].length(); le++) {
+      if (!Character.isDigit(parts[0].charAt(le))) {
+        return null;
+      }
+    }
+    if (le != 2) {
+      return null;
+    }
+
+    // Full ID.
+    int ie;
+    for (ie = 0; ie < parts[1].length(); ie++) {
+      if (!Character.isDigit(parts[1].charAt(ie))) {
+        if (ie == 0) {
+          return null;
+        }
+        break;
+      }
+    }
+
+    int shard = Integer.parseInt(parts[0]);
+    int id = Integer.parseInt(parts[1].substring(0, ie));
+
+    if (id % 100 != shard) {
+      return null;
+    }
+
+    return name.substring(2 + 1 + ie); // 2 for the length of the shard, 1 for the '/'
+  }
+
+  /**
+   * Parses an ID that follows a sharded ref part at the beginning of the name.
+   *
+   * <p>E.g.: "01/1/2" -> 2, "01/1/2/4" -> 2, ""01/1/2-edit" -> 2
+   *
+   * @param name ref part name
+   * @return ID that follows the sharded ref part at the beginning of the name, {@code null} if the
+   *     ref name part doesn't start with a valid sharded ID or if no valid ID follows the sharded
+   *     ref part
+   */
+  static Integer parseAfterShardedRefPart(String name) {
+    String rest = skipShardedRefPart(name);
+    if (rest == null || !rest.startsWith("/")) {
+      return null;
+    }
+
+    rest = rest.substring(1);
+
+    int ie;
+    for (ie = 0; ie < rest.length(); ie++) {
+      if (!Character.isDigit(rest.charAt(ie))) {
+        break;
+      }
+    }
+    if (ie == 0) {
+      return null;
+    }
+    return Integer.parseInt(rest.substring(0, ie));
+  }
+
   static Integer parseRefSuffix(String name) {
     if (name == null) {
       return null;
@@ -258,5 +351,12 @@
     return Integer.valueOf(name.substring(i, name.length()));
   }
 
+  private static StringBuilder newStringBuilder() {
+    // Many refname types in this file are always are longer than the default of 16 chars, so
+    // presize StringBuilders larger by default. This hurts readability less than accurate
+    // calculations would, at a negligible cost to memory overhead.
+    return new StringBuilder(64);
+  }
+
   private RefNames() {}
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java
index 5474707..0b0f74a 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RevId.java
@@ -26,7 +26,7 @@
 
   protected RevId() {}
 
-  public RevId(final String str) {
+  public RevId(String str) {
     id = str;
   }
 
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java
index 9abc744..cd42dd1 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/SystemConfig.java
@@ -35,7 +35,7 @@
     }
 
     @Override
-    protected void set(final String newValue) {
+    protected void set(String newValue) {
       assert get().equals(newValue);
     }
   }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/TrackingId.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/TrackingId.java
index 8cc9737..2f6008f 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/TrackingId.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/TrackingId.java
@@ -32,7 +32,7 @@
 
     protected Id() {}
 
-    public Id(final String id) {
+    public Id(String id) {
       this.id = id;
     }
 
@@ -56,7 +56,7 @@
 
     protected System() {}
 
-    public System(final String s) {
+    public System(String s) {
       this.system = s;
     }
 
@@ -89,7 +89,7 @@
       trackingSystem = new System();
     }
 
-    protected Key(final Change.Id ch, final Id id, final System s) {
+    protected Key(Change.Id ch, Id id, System s) {
       changeId = ch;
       trackingKey = id;
       trackingSystem = s;
@@ -119,11 +119,11 @@
 
   protected TrackingId() {}
 
-  public TrackingId(final Change.Id ch, final TrackingId.Id id, final TrackingId.System s) {
+  public TrackingId(Change.Id ch, TrackingId.Id id, TrackingId.System s) {
     key = new Key(ch, id, s);
   }
 
-  public TrackingId(final Change.Id ch, final String id, final String s) {
+  public TrackingId(Change.Id ch, String id, String s) {
     key = new Key(ch, new TrackingId.Id(id), new TrackingId.System(s));
   }
 
@@ -149,7 +149,7 @@
   }
 
   @Override
-  public boolean equals(final Object obj) {
+  public boolean equals(Object obj) {
     if (obj instanceof TrackingId) {
       final TrackingId tr = (TrackingId) obj;
       return key.equals(tr.key);
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/UserIdentity.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/UserIdentity.java
index ddc1297..0b7aee3 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/UserIdentity.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/UserIdentity.java
@@ -39,7 +39,7 @@
     return name;
   }
 
-  public void setName(final String n) {
+  public void setName(String n) {
     name = n;
   }
 
@@ -47,7 +47,7 @@
     return email;
   }
 
-  public void setEmail(final String e) {
+  public void setEmail(String e) {
     email = e;
   }
 
@@ -59,7 +59,7 @@
     return when;
   }
 
-  public void setDate(final Timestamp d) {
+  public void setDate(Timestamp d) {
     when = d;
   }
 
@@ -67,7 +67,7 @@
     return tz;
   }
 
-  public void setTimeZone(final int offset) {
+  public void setTimeZone(int offset) {
     tz = offset;
   }
 
@@ -75,7 +75,7 @@
     return accountId;
   }
 
-  public void setAccount(final Account.Id id) {
+  public void setAccount(Account.Id id) {
     accountId = id;
   }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountAccess.java
deleted file mode 100644
index b015af8..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountAccess.java
+++ /dev/null
@@ -1,52 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-/** Access interface for {@link Account}. */
-public interface AccountAccess extends Access<Account, Account.Id> {
-  /** Locate an account by our locally generated identity. */
-  @Override
-  @PrimaryKey("accountId")
-  Account get(Account.Id key) throws OrmException;
-
-  @Query("WHERE preferredEmail = ? LIMIT 2")
-  ResultSet<Account> byPreferredEmail(String email) throws OrmException;
-
-  @Query("WHERE fullName = ? LIMIT 2")
-  ResultSet<Account> byFullName(String name) throws OrmException;
-
-  @Query("WHERE fullName >= ? AND fullName <= ? ORDER BY fullName LIMIT ?")
-  ResultSet<Account> suggestByFullName(String nameA, String nameB, int limit) throws OrmException;
-
-  @Query("WHERE preferredEmail >= ? AND preferredEmail <= ? ORDER BY preferredEmail LIMIT ?")
-  ResultSet<Account> suggestByPreferredEmail(String nameA, String nameB, int limit)
-      throws OrmException;
-
-  @Query("LIMIT 1")
-  ResultSet<Account> anyAccounts() throws OrmException;
-
-  @Query("ORDER BY accountId LIMIT ?")
-  ResultSet<Account> firstNById(int n) throws OrmException;
-
-  @Query("ORDER BY accountId")
-  ResultSet<Account> all() throws OrmException;
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
deleted file mode 100644
index 9124301..0000000
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountExternalIdAccess.java
+++ /dev/null
@@ -1,35 +0,0 @@
-// Copyright (C) 2008 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.reviewdb.server;
-
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gwtorm.server.Access;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.PrimaryKey;
-import com.google.gwtorm.server.Query;
-import com.google.gwtorm.server.ResultSet;
-
-public interface AccountExternalIdAccess extends Access<AccountExternalId, AccountExternalId.Key> {
-  @Override
-  @PrimaryKey("key")
-  AccountExternalId get(AccountExternalId.Key key) throws OrmException;
-
-  @Query("WHERE accountId = ?")
-  ResultSet<AccountExternalId> byAccount(Account.Id id) throws OrmException;
-
-  @Query
-  ResultSet<AccountExternalId> all() throws OrmException;
-}
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
index 82660cb..b8bc9f0 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/AccountGroupNameAccess.java
@@ -29,8 +29,4 @@
 
   @Query("ORDER BY name")
   ResultSet<AccountGroupName> all() throws OrmException;
-
-  @Query("WHERE name.name >= ? AND name.name <= ? ORDER BY name LIMIT ?")
-  ResultSet<AccountGroupName> suggestByName(String nameA, String nameB, int limit)
-      throws OrmException;
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
index 9b4e1ed..04567bc 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDb.java
@@ -47,11 +47,9 @@
 
   // Deleted @Relation(id = 4)
 
-  @Relation(id = 6)
-  AccountAccess accounts();
+  // Deleted @Relation(id = 6)
 
-  @Relation(id = 7)
-  AccountExternalIdAccess accountExternalIds();
+  // Deleted @Relation(id = 7)
 
   // Deleted @Relation(id = 8)
 
@@ -100,8 +98,15 @@
   @Relation(id = 30)
   AccountGroupByIdAudAccess accountGroupByIdAud();
 
-  /** Create the next unique id for an {@link Account}. */
-  @Sequence(startWith = 1000000)
+  int FIRST_ACCOUNT_ID = 1000000;
+
+  /**
+   * Next unique id for a {@link Account}.
+   *
+   * @deprecated use {@link com.google.gerrit.server.Sequences#nextAccountId()}.
+   */
+  @Sequence(startWith = FIRST_ACCOUNT_ID)
+  @Deprecated
   int nextAccountId() throws OrmException;
 
   /** Next unique id for a {@link AccountGroup}. */
@@ -118,4 +123,8 @@
   @Sequence(startWith = FIRST_CHANGE_ID)
   @Deprecated
   int nextChangeId() throws OrmException;
+
+  default boolean changesTablesEnabled() {
+    return true;
+  }
 }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
index 4ad8e39..29b4be3 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/server/ReviewDbWrapper.java
@@ -77,16 +77,6 @@
   }
 
   @Override
-  public AccountAccess accounts() {
-    return delegate.accounts();
-  }
-
-  @Override
-  public AccountExternalIdAccess accountExternalIds() {
-    return delegate.accountExternalIds();
-  }
-
-  @Override
   public AccountGroupAccess accountGroups() {
     return delegate.accountGroups();
   }
@@ -142,6 +132,7 @@
   }
 
   @Override
+  @SuppressWarnings("deprecation")
   public int nextAccountId() throws OrmException {
     return delegate.nextAccountId();
   }
@@ -157,6 +148,11 @@
     return delegate.nextChangeId();
   }
 
+  @Override
+  public boolean changesTablesEnabled() {
+    return delegate.changesTablesEnabled();
+  }
+
   public static class ChangeAccessWrapper implements ChangeAccess {
     protected final ChangeAccess delegate;
 
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
index deceab9..8f87503 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_generic.sql
@@ -5,24 +5,6 @@
 --
 
 -- *********************************************************************
--- AccountAccess
---    covers:             byPreferredEmail, suggestByPreferredEmail
-CREATE INDEX accounts_byPreferredEmail
-ON accounts (preferred_email);
-
---    covers:             suggestByFullName
-CREATE INDEX accounts_byFullName
-ON accounts (full_name);
-
-
--- *********************************************************************
--- AccountExternalIdAccess
---    covers:             byAccount
-CREATE INDEX account_external_ids_byAccount
-ON account_external_ids (account_id);
-
-
--- *********************************************************************
 -- AccountGroupMemberAccess
 --    @PrimaryKey covers: byAccount
 CREATE INDEX account_group_members_byGroup
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
index 1ec8ea6..57b1a4a 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_maxdb.sql
@@ -6,27 +6,6 @@
 --
 
 -- *********************************************************************
--- AccountAccess
---    covers:             byPreferredEmail, suggestByPreferredEmail
-CREATE INDEX accounts_byPreferredEmail
-ON accounts (preferred_email)
-#
-
---    covers:             suggestByFullName
-CREATE INDEX accounts_byFullName
-ON accounts (full_name)
-#
-
-
--- *********************************************************************
--- AccountExternalIdAccess
---    covers:             byAccount
-CREATE INDEX account_external_ids_byAccount
-ON account_external_ids (account_id)
-#
-
-
--- *********************************************************************
 -- AccountGroupMemberAccess
 --    @PrimaryKey covers: byAccount
 CREATE INDEX account_group_members_byGroup
diff --git a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
index a11c86b..e1d88ef 100644
--- a/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
+++ b/gerrit-reviewdb/src/main/resources/com/google/gerrit/reviewdb/server/index_postgres.sql
@@ -52,24 +52,6 @@
 --
 
 -- *********************************************************************
--- AccountAccess
---    covers:             byPreferredEmail, suggestByPreferredEmail
-CREATE INDEX accounts_byPreferredEmail
-ON accounts (preferred_email);
-
---    covers:             suggestByFullName
-CREATE INDEX accounts_byFullName
-ON accounts (full_name);
-
-
--- *********************************************************************
--- AccountExternalIdAccess
---    covers:             byAccount
-CREATE INDEX account_external_ids_byAccount
-ON account_external_ids (account_id);
-
-
--- *********************************************************************
 -- AccountGroupMemberAccess
 --    @PrimaryKey covers: byAccount
 CREATE INDEX account_group_members_byGroup
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountGroupTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountGroupTest.java
new file mode 100644
index 0000000..02b6dd8
--- /dev/null
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountGroupTest.java
@@ -0,0 +1,32 @@
+// 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.reviewdb.client;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Test;
+
+public class AccountGroupTest {
+  @Test
+  public void auditCreationInstant() {
+    Instant instant = LocalDateTime.of(2009, Month.JUNE, 8, 19, 31).toInstant(ZoneOffset.UTC);
+    assertThat(AccountGroup.auditCreationInstantTs()).isEqualTo(Timestamp.from(instant));
+  }
+}
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountTest.java
index 00bf44e..11a562f 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/AccountTest.java
@@ -44,6 +44,42 @@
   }
 
   @Test
+  public void parseDraftCommentsRefName() {
+    assertThat(fromRef("refs/draft-comments/35/135/1")).isEqualTo(id(1));
+    assertThat(fromRef("refs/draft-comments/35/135/1-foo/2")).isEqualTo(id(1));
+    assertThat(fromRef("refs/draft-comments/35/135/1/foo/2")).isEqualTo(id(1));
+
+    // Invalid characters.
+    assertThat(fromRef("refs/draft-comments/35a/135/1")).isNull();
+    assertThat(fromRef("refs/draft-comments/35/135a/1")).isNull();
+    assertThat(fromRef("refs/draft-comments/35/135/a1")).isNull();
+
+    // Mismatched shard.
+    assertThat(fromRef("refs/draft-comments/02/135/1")).isNull();
+
+    // Shard too short.
+    assertThat(fromRef("refs/draft-comments/2/2/1")).isNull();
+  }
+
+  @Test
+  public void parseStarredChangesRefName() {
+    assertThat(fromRef("refs/starred-changes/35/135/1")).isEqualTo(id(1));
+    assertThat(fromRef("refs/starred-changes/35/135/1-foo/2")).isEqualTo(id(1));
+    assertThat(fromRef("refs/starred-changes/35/135/1/foo/2")).isEqualTo(id(1));
+
+    // Invalid characters.
+    assertThat(fromRef("refs/starred-changes/35a/135/1")).isNull();
+    assertThat(fromRef("refs/starred-changes/35/135a/1")).isNull();
+    assertThat(fromRef("refs/starred-changes/35/135/a1")).isNull();
+
+    // Mismatched shard.
+    assertThat(fromRef("refs/starred-changes/02/135/1")).isNull();
+
+    // Shard too short.
+    assertThat(fromRef("refs/starred-changes/2/2/1")).isNull();
+  }
+
+  @Test
   public void parseRefNameParts() {
     assertThat(fromRefPart("01/1")).isEqualTo(id(1));
     assertThat(fromRefPart("ab/cd")).isNull();
diff --git a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
index 65a92a0..7044547 100644
--- a/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
+++ b/gerrit-reviewdb/src/test/java/com/google/gerrit/reviewdb/client/RefNamesTest.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.reviewdb.client;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.reviewdb.client.RefNames.parseAfterShardedRefPart;
 import static com.google.gerrit.reviewdb.client.RefNames.parseRefSuffix;
 import static com.google.gerrit.reviewdb.client.RefNames.parseShardedRefPart;
+import static com.google.gerrit.reviewdb.client.RefNames.skipShardedRefPart;
 
 import org.junit.Test;
 
@@ -35,6 +37,17 @@
   }
 
   @Test
+  public void changeRefs() throws Exception {
+    String changeMetaRef = RefNames.changeMetaRef(changeId);
+    assertThat(changeMetaRef).isEqualTo("refs/changes/73/67473/meta");
+    assertThat(RefNames.isNoteDbMetaRef(changeMetaRef)).isTrue();
+
+    String robotCommentsRef = RefNames.robotCommentsRef(changeId);
+    assertThat(robotCommentsRef).isEqualTo("refs/changes/73/67473/robot-comments");
+    assertThat(RefNames.isNoteDbMetaRef(robotCommentsRef)).isTrue();
+  }
+
+  @Test
   public void refsUsers() throws Exception {
     assertThat(RefNames.refsUsers(accountId)).isEqualTo("refs/users/23/1011123");
   }
@@ -90,7 +103,7 @@
   }
 
   @Test
-  public void testparseShardedRefsPart() throws Exception {
+  public void parseShardedRefsPart() throws Exception {
     assertThat(parseShardedRefPart("01/1")).isEqualTo(1);
     assertThat(parseShardedRefPart("01/1-drafts")).isEqualTo(1);
     assertThat(parseShardedRefPart("01/1-drafts/2")).isEqualTo(1);
@@ -113,6 +126,58 @@
   }
 
   @Test
+  public void skipShardedRefsPart() throws Exception {
+    assertThat(skipShardedRefPart("01/1")).isEqualTo("");
+    assertThat(skipShardedRefPart("01/1/")).isEqualTo("/");
+    assertThat(skipShardedRefPart("01/1/2")).isEqualTo("/2");
+    assertThat(skipShardedRefPart("01/1-edit")).isEqualTo("-edit");
+
+    assertThat(skipShardedRefPart(null)).isNull();
+    assertThat(skipShardedRefPart("")).isNull();
+
+    // Prefix not stripped.
+    assertThat(skipShardedRefPart("refs/draft-comments/01/1/2")).isNull();
+
+    // Invalid characters.
+    assertThat(skipShardedRefPart("01a/1/2")).isNull();
+    assertThat(skipShardedRefPart("01a/a1/2")).isNull();
+
+    // Mismatched shard.
+    assertThat(skipShardedRefPart("01/23/2")).isNull();
+
+    // Shard too short.
+    assertThat(skipShardedRefPart("1/1")).isNull();
+  }
+
+  @Test
+  public void parseAfterShardedRefsPart() throws Exception {
+    assertThat(parseAfterShardedRefPart("01/1/2")).isEqualTo(2);
+    assertThat(parseAfterShardedRefPart("01/1/2/4")).isEqualTo(2);
+    assertThat(parseAfterShardedRefPart("01/1/2-edit")).isEqualTo(2);
+
+    assertThat(parseAfterShardedRefPart(null)).isNull();
+    assertThat(parseAfterShardedRefPart("")).isNull();
+
+    // No ID after sharded ref part
+    assertThat(parseAfterShardedRefPart("01/1")).isNull();
+    assertThat(parseAfterShardedRefPart("01/1/")).isNull();
+    assertThat(parseAfterShardedRefPart("01/1/a")).isNull();
+
+    // Prefix not stripped.
+    assertThat(parseAfterShardedRefPart("refs/draft-comments/01/1/2")).isNull();
+
+    // Invalid characters.
+    assertThat(parseAfterShardedRefPart("01a/1/2")).isNull();
+    assertThat(parseAfterShardedRefPart("01a/a1/2")).isNull();
+
+    // Mismatched shard.
+    assertThat(parseAfterShardedRefPart("01/23/2")).isNull();
+
+    // Shard too short.
+    assertThat(parseAfterShardedRefPart("1/1")).isNull();
+  }
+
+  @Test
   public void testParseRefSuffix() throws Exception {
     assertThat(parseRefSuffix("1/2/34")).isEqualTo(34);
     assertThat(parseRefSuffix("/34")).isEqualTo(34);
diff --git a/gerrit-server/BUILD b/gerrit-server/BUILD
index 9de5bca..fc0fb0d 100644
--- a/gerrit-server/BUILD
+++ b/gerrit-server/BUILD
@@ -1,3 +1,4 @@
+load("//lib/prolog:prolog.bzl", "prolog_cafe_library")
 load("//tools/bzl:javadoc.bzl", "java_doc")
 load("//tools/bzl:junit.bzl", "junit_tests")
 
@@ -5,9 +6,18 @@
     "src/main/java/com/google/gerrit/server/documentation/Constants.java",
 ]
 
+GERRIT_GLOBAL_MODULE_SRC = [
+    "src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java",
+]
+
+# Non-recursive glob; dropwizard implementation is in a subpackage.
+METRICS_SRCS = glob(["src/main/java/com/google/gerrit/metrics/*.java"])
+
+RECEIVE_SRCS = glob(["src/main/java/com/google/gerrit/server/git/receive/**/*.java"])
+
 SRCS = glob(
     ["src/main/java/**/*.java"],
-    exclude = CONSTANTS_SRC,
+    exclude = CONSTANTS_SRC + GERRIT_GLOBAL_MODULE_SRC + METRICS_SRCS + RECEIVE_SRCS,
 )
 
 RESOURCES = glob(["src/main/resources/**/*"])
@@ -18,6 +28,19 @@
     visibility = ["//visibility:public"],
 )
 
+prolog_cafe_library(
+    name = "prolog-common",
+    srcs = ["src/main/prolog/gerrit_common.pl"],
+    visibility = ["//visibility:public"],
+    deps = [":server"],
+)
+
+# Giant kitchen-sink target.
+#
+# The only reason this hasn't been split up further is because we have too many
+# tangled dependencies (and Guice unfortunately makes it quite easy to get into
+# this state). Which means if you see an opportunity to split something off, you
+# should seize it.
 java_library(
     name = "server",
     srcs = SRCS,
@@ -25,11 +48,12 @@
     visibility = ["//visibility:public"],
     deps = [
         ":constants",
-        "//gerrit-antlr:query_exception",
-        "//gerrit-antlr:query_parser",
+        ":metrics",
         "//gerrit-common:annotations",
         "//gerrit-common:server",
         "//gerrit-extension-api:api",
+        "//gerrit-index:index",
+        "//gerrit-index:query_exception",
         "//gerrit-patch-commonsnet:commons-net",
         "//gerrit-patch-jgit:server",
         "//gerrit-prettify:server",
@@ -54,7 +78,6 @@
         "//lib:soy",
         "//lib:tukaani-xz",
         "//lib:velocity",
-        "//lib/antlr:java-runtime",
         "//lib/auto:auto-value",
         "//lib/auto:auto-value-annotations",
         "//lib/bouncycastle:bcpkix-neverlink",
@@ -88,7 +111,63 @@
     ],
 )
 
+# Large modules that import things from all across the server package
+# hierarchy, so they need lots of dependencies.
+java_library(
+    name = "module",
+    srcs = GERRIT_GLOBAL_MODULE_SRC,
+    visibility = ["//visibility:public"],
+    deps = [
+        ":receive",
+        ":server",
+        "//gerrit-extension-api:api",
+        "//lib:blame-cache",
+        "//lib:guava",
+        "//lib:soy",
+        "//lib:velocity",
+        "//lib/guice",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+    ],
+)
+
+java_library(
+    name = "receive",
+    srcs = RECEIVE_SRCS,
+    visibility = ["//visibility:public"],
+    deps = [
+        ":server",
+        "//gerrit-common:annotations",
+        "//gerrit-common:server",
+        "//gerrit-extension-api:api",
+        "//gerrit-reviewdb:server",
+        "//gerrit-util-cli:cli",
+        "//lib:args4j",
+        "//lib:guava",
+        "//lib:gwtorm",
+        "//lib/auto:auto-value",
+        "//lib/auto:auto-value-annotations",
+        "//lib/guice",
+        "//lib/guice:guice-assistedinject",
+        "//lib/jgit/org.eclipse.jgit:jgit",
+        "//lib/log:api",
+    ],
+)
+
+# TODO(dborowitz): Move to a different top-level directory to avoid inbound
+# dependencies on gerrit-server.
+java_library(
+    name = "metrics",
+    srcs = METRICS_SRCS,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//gerrit-extension-api:api",
+        "//lib:guava",
+    ],
+)
+
 TESTUTIL_DEPS = [
+    ":metrics",
+    ":module",
     ":server",
     "//gerrit-common:annotations",
     "//gerrit-common:server",
@@ -96,6 +175,7 @@
     "//gerrit-cache-mem:mem",
     "//gerrit-extension-api:api",
     "//gerrit-gpg:gpg",
+    "//gerrit-index:index",
     "//gerrit-lucene:lucene",
     "//gerrit-reviewdb:server",
     "//lib:gwtorm",
@@ -143,6 +223,22 @@
     ],
 )
 
+CUSTOM_TRUTH_SUBJECTS = glob([
+    "src/test/java/com/google/gerrit/server/**/*Subject.java",
+])
+
+java_library(
+    name = "custom-truth-subjects",
+    testonly = 1,
+    srcs = CUSTOM_TRUTH_SUBJECTS,
+    deps = [
+        ":server",
+        "//gerrit-extension-api:api",
+        "//gerrit-test-util:test_util",
+        "//lib:truth",
+    ],
+)
+
 PROLOG_TEST_CASE = [
     "src/test/java/com/google/gerrit/rules/PrologTestCase.java",
 ]
@@ -174,9 +270,9 @@
     srcs = PROLOG_TESTS,
     resources = glob(["src/test/resources/com/google/gerrit/rules/**/*"]),
     deps = TESTUTIL_DEPS + [
+        ":prolog-common",
         ":prolog_test_case",
         ":testutil",
-        "//gerrit-server/src/main/prolog:common",
         "//lib/prolog:runtime",
     ],
 )
@@ -191,11 +287,8 @@
     srcs = QUERY_TESTS,
     visibility = ["//visibility:public"],
     deps = TESTUTIL_DEPS + [
+        ":prolog-common",
         ":testutil",
-        "//gerrit-antlr:query_exception",
-        "//gerrit-antlr:query_parser",
-        "//gerrit-server/src/main/prolog:common",
-        "//lib/antlr:java-runtime",
     ],
 )
 
@@ -205,11 +298,8 @@
     srcs = QUERY_TESTS,
     visibility = ["//visibility:public"],
     deps = TESTUTIL_DEPS + [
+        ":prolog-common",
         ":testutil",
-        "//gerrit-antlr:query_exception",
-        "//gerrit-antlr:query_parser",
-        "//gerrit-server/src/main/prolog:common",
-        "//lib/antlr:java-runtime",
     ],
 )
 
@@ -218,15 +308,16 @@
     size = "large",
     srcs = glob(
         ["src/test/java/**/*.java"],
-        exclude = TESTUTIL + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS,
+        exclude = TESTUTIL + CUSTOM_TRUTH_SUBJECTS + PROLOG_TESTS + PROLOG_TEST_CASE + QUERY_TESTS,
     ),
     resources = glob(["src/test/resources/com/google/gerrit/server/**/*"]),
     visibility = ["//visibility:public"],
     deps = TESTUTIL_DEPS + [
+        ":custom-truth-subjects",
+        ":prolog-common",
         ":testutil",
-        "//gerrit-antlr:query_exception",
+        "//gerrit-index:query_exception",
         "//gerrit-patch-jgit:server",
-        "//gerrit-server/src/main/prolog:common",
         "//gerrit-test-util:test_util",
         "//lib:args4j",
         "//lib:grappa",
@@ -234,6 +325,7 @@
         "//lib:guava",
         "//lib:guava-retrying",
         "//lib:protobuf",
+        "//lib:truth-java8-extension",
         "//lib/bouncycastle:bcprov",
         "//lib/bouncycastle:bcpkix",
         "//lib/dropwizard:dropwizard-core",
@@ -243,6 +335,18 @@
     ],
 )
 
+junit_tests(
+    name = "testutil_test",
+    size = "small",
+    srcs = [
+        "src/test/java/com/google/gerrit/testutil/IndexVersionsTest.java",
+    ],
+    visibility = ["//visibility:public"],
+    deps = TESTUTIL_DEPS + [
+        ":testutil",
+    ],
+)
+
 java_doc(
     name = "doc",
     libs = [":server"],
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java
index aedb8a7..d5bd8d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditModule.java
@@ -23,6 +23,6 @@
   protected void configure() {
     DynamicSet.setOf(binder(), AuditListener.class);
     DynamicSet.setOf(binder(), GroupMemberAuditListener.class);
-    bind(AuditService.class);
+    bind(AuditService.class).to(AuditServiceImpl.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java
index cc29559..171ff45 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditService.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2012 The Android Open Source Project
+// 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.
@@ -14,76 +14,19 @@
 
 package com.google.gerrit.audit;
 
-import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroupById;
 import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
 import java.util.Collection;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
-@Singleton
-public class AuditService {
-  private static final Logger log = LoggerFactory.getLogger(AuditService.class);
+public interface AuditService {
+  void dispatch(AuditEvent action);
 
-  private final DynamicSet<AuditListener> auditListeners;
-  private final DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners;
+  void dispatchAddAccountsToGroup(Account.Id actor, Collection<AccountGroupMember> added);
 
-  @Inject
-  public AuditService(
-      DynamicSet<AuditListener> auditListeners,
-      DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners) {
-    this.auditListeners = auditListeners;
-    this.groupMemberAuditListeners = groupMemberAuditListeners;
-  }
+  void dispatchDeleteAccountsFromGroup(Account.Id actor, Collection<AccountGroupMember> removed);
 
-  public void dispatch(AuditEvent action) {
-    for (AuditListener auditListener : auditListeners) {
-      auditListener.onAuditableAction(action);
-    }
-  }
+  void dispatchAddGroupsToGroup(Account.Id actor, Collection<AccountGroupById> added);
 
-  public void dispatchAddAccountsToGroup(Account.Id actor, Collection<AccountGroupMember> added) {
-    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
-      try {
-        auditListener.onAddAccountsToGroup(actor, added);
-      } catch (RuntimeException e) {
-        log.error("failed to log add accounts to group event", e);
-      }
-    }
-  }
-
-  public void dispatchDeleteAccountsFromGroup(
-      Account.Id actor, Collection<AccountGroupMember> removed) {
-    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
-      try {
-        auditListener.onDeleteAccountsFromGroup(actor, removed);
-      } catch (RuntimeException e) {
-        log.error("failed to log delete accounts from group event", e);
-      }
-    }
-  }
-
-  public void dispatchAddGroupsToGroup(Account.Id actor, Collection<AccountGroupById> added) {
-    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
-      try {
-        auditListener.onAddGroupsToGroup(actor, added);
-      } catch (RuntimeException e) {
-        log.error("failed to log add groups to group event", e);
-      }
-    }
-  }
-
-  public void dispatchDeleteGroupsFromGroup(
-      Account.Id actor, Collection<AccountGroupById> removed) {
-    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
-      try {
-        auditListener.onDeleteGroupsFromGroup(actor, removed);
-      } catch (RuntimeException e) {
-        log.error("failed to log delete groups from group event", e);
-      }
-    }
-  }
+  void dispatchDeleteGroupsFromGroup(Account.Id actor, Collection<AccountGroupById> removed);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/audit/AuditServiceImpl.java b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditServiceImpl.java
new file mode 100644
index 0000000..940742f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/audit/AuditServiceImpl.java
@@ -0,0 +1,94 @@
+// 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.audit;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class AuditServiceImpl implements AuditService {
+  private static final Logger log = LoggerFactory.getLogger(AuditServiceImpl.class);
+
+  private final DynamicSet<AuditListener> auditListeners;
+  private final DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners;
+
+  @Inject
+  public AuditServiceImpl(
+      DynamicSet<AuditListener> auditListeners,
+      DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners) {
+    this.auditListeners = auditListeners;
+    this.groupMemberAuditListeners = groupMemberAuditListeners;
+  }
+
+  @Override
+  public void dispatch(AuditEvent action) {
+    for (AuditListener auditListener : auditListeners) {
+      auditListener.onAuditableAction(action);
+    }
+  }
+
+  @Override
+  public void dispatchAddAccountsToGroup(Account.Id actor, Collection<AccountGroupMember> added) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      try {
+        auditListener.onAddAccountsToGroup(actor, added);
+      } catch (RuntimeException e) {
+        log.error("failed to log add accounts to group event", e);
+      }
+    }
+  }
+
+  @Override
+  public void dispatchDeleteAccountsFromGroup(
+      Account.Id actor, Collection<AccountGroupMember> removed) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      try {
+        auditListener.onDeleteAccountsFromGroup(actor, removed);
+      } catch (RuntimeException e) {
+        log.error("failed to log delete accounts from group event", e);
+      }
+    }
+  }
+
+  @Override
+  public void dispatchAddGroupsToGroup(Account.Id actor, Collection<AccountGroupById> added) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      try {
+        auditListener.onAddGroupsToGroup(actor, added);
+      } catch (RuntimeException e) {
+        log.error("failed to log add groups to group event", e);
+      }
+    }
+  }
+
+  @Override
+  public void dispatchDeleteGroupsFromGroup(
+      Account.Id actor, Collection<AccountGroupById> removed) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      try {
+        auditListener.onDeleteGroupsFromGroup(actor, removed);
+      } catch (RuntimeException e) {
+        log.error("failed to log delete groups from group event", e);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
index 4603141..c58b723 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventBroker.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -28,9 +29,13 @@
 import com.google.gerrit.server.events.ProjectEvent;
 import com.google.gerrit.server.events.RefEvent;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+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.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -58,6 +63,7 @@
   /** Listeners to receive all changes as they happen. */
   protected final DynamicSet<EventListener> unrestrictedListeners;
 
+  private final PermissionBackend permissionBackend;
   protected final ProjectCache projectCache;
 
   protected final ChangeNotes.Factory notesFactory;
@@ -68,23 +74,27 @@
   public EventBroker(
       DynamicSet<UserScopedEventListener> listeners,
       DynamicSet<EventListener> unrestrictedListeners,
+      PermissionBackend permissionBackend,
       ProjectCache projectCache,
       ChangeNotes.Factory notesFactory,
       Provider<ReviewDb> dbProvider) {
     this.listeners = listeners;
     this.unrestrictedListeners = unrestrictedListeners;
+    this.permissionBackend = permissionBackend;
     this.projectCache = projectCache;
     this.notesFactory = notesFactory;
     this.dbProvider = dbProvider;
   }
 
   @Override
-  public void postEvent(Change change, ChangeEvent event) throws OrmException {
+  public void postEvent(Change change, ChangeEvent event)
+      throws OrmException, PermissionBackendException {
     fireEvent(change, event);
   }
 
   @Override
-  public void postEvent(Branch.NameKey branchName, RefEvent event) {
+  public void postEvent(Branch.NameKey branchName, RefEvent event)
+      throws PermissionBackendException {
     fireEvent(branchName, event);
   }
 
@@ -94,7 +104,7 @@
   }
 
   @Override
-  public void postEvent(Event event) throws OrmException {
+  public void postEvent(Event event) throws OrmException, PermissionBackendException {
     fireEvent(event);
   }
 
@@ -104,7 +114,8 @@
     }
   }
 
-  protected void fireEvent(Change change, ChangeEvent event) throws OrmException {
+  protected void fireEvent(Change change, ChangeEvent event)
+      throws OrmException, PermissionBackendException {
     for (UserScopedEventListener listener : listeners) {
       if (isVisibleTo(change, listener.getUser())) {
         listener.onEvent(event);
@@ -122,7 +133,8 @@
     fireEventForUnrestrictedListeners(event);
   }
 
-  protected void fireEvent(Branch.NameKey branchName, RefEvent event) {
+  protected void fireEvent(Branch.NameKey branchName, RefEvent event)
+      throws PermissionBackendException {
     for (UserScopedEventListener listener : listeners) {
       if (isVisibleTo(branchName, listener.getUser())) {
         listener.onEvent(event);
@@ -131,7 +143,7 @@
     fireEventForUnrestrictedListeners(event);
   }
 
-  protected void fireEvent(Event event) throws OrmException {
+  protected void fireEvent(Event event) throws OrmException, PermissionBackendException {
     for (UserScopedEventListener listener : listeners) {
       if (isVisibleTo(event, listener.getUser())) {
         listener.onEvent(event);
@@ -141,14 +153,16 @@
   }
 
   protected boolean isVisibleTo(Project.NameKey project, CurrentUser user) {
-    ProjectState pe = projectCache.get(project);
-    if (pe == null) {
+    try {
+      permissionBackend.user(user).project(project).check(ProjectPermission.ACCESS);
+      return true;
+    } catch (AuthException | PermissionBackendException e) {
       return false;
     }
-    return pe.controlFor(user).isVisible();
   }
 
-  protected boolean isVisibleTo(Change change, CurrentUser user) throws OrmException {
+  protected boolean isVisibleTo(Change change, CurrentUser user)
+      throws OrmException, PermissionBackendException {
     if (change == null) {
       return false;
     }
@@ -156,21 +170,25 @@
     if (pe == null) {
       return false;
     }
-    ProjectControl pc = pe.controlFor(user);
     ReviewDb db = dbProvider.get();
-    return pc.controlFor(db, change).isVisible(db);
+    return permissionBackend
+        .user(user)
+        .change(notesFactory.createChecked(db, change))
+        .database(db)
+        .test(ChangePermission.READ);
   }
 
-  protected boolean isVisibleTo(Branch.NameKey branchName, CurrentUser user) {
+  protected boolean isVisibleTo(Branch.NameKey branchName, CurrentUser user)
+      throws PermissionBackendException {
     ProjectState pe = projectCache.get(branchName.getParentKey());
     if (pe == null) {
       return false;
     }
-    ProjectControl pc = pe.controlFor(user);
-    return pc.controlForRef(branchName).isVisible();
+    return permissionBackend.user(user).ref(branchName).test(RefPermission.READ);
   }
 
-  protected boolean isVisibleTo(Event event, CurrentUser user) throws OrmException {
+  protected boolean isVisibleTo(Event event, CurrentUser user)
+      throws OrmException, PermissionBackendException {
     if (event instanceof RefEvent) {
       RefEvent refEvent = (RefEvent) event;
       String ref = refEvent.getRefName();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
index 20d55d6..bfc7973 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/common/EventDispatcher.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.server.events.Event;
 import com.google.gerrit.server.events.ProjectEvent;
 import com.google.gerrit.server.events.RefEvent;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 
 /** Interface for posting (dispatching) Events */
@@ -31,16 +32,18 @@
    * @param change The change that the event is related to
    * @param event The event to post
    * @throws OrmException on failure to post the event due to DB error
+   * @throws PermissionBackendException on failure of permission checks
    */
-  void postEvent(Change change, ChangeEvent event) throws OrmException;
+  void postEvent(Change change, ChangeEvent event) throws OrmException, PermissionBackendException;
 
   /**
    * Post a stream event that is related to a branch
    *
    * @param branchName The branch that the event is related to
    * @param event The event to post
+   * @throws PermissionBackendException on failure of permission checks
    */
-  void postEvent(Branch.NameKey branchName, RefEvent event);
+  void postEvent(Branch.NameKey branchName, RefEvent event) throws PermissionBackendException;
 
   /**
    * Post a stream event that is related to a project.
@@ -58,6 +61,7 @@
    *
    * @param event The event to post.
    * @throws OrmException on failure to post the event due to DB error
+   * @throws PermissionBackendException on failure of permission checks
    */
-  void postEvent(Event event) throws OrmException;
+  void postEvent(Event event) throws OrmException, PermissionBackendException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
index eee76fd..401a6d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/MetricMaker.java
@@ -79,20 +79,13 @@
    * @param value only value of the metric.
    * @param desc description of the metric.
    */
-  public <V> void newConstantMetric(String name, final V value, Description desc) {
+  public <V> void newConstantMetric(String name, V value, Description desc) {
     desc.setConstant();
 
     @SuppressWarnings("unchecked")
     Class<V> type = (Class<V>) value.getClass();
-    final CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
-    newTrigger(
-        metric,
-        new Runnable() {
-          @Override
-          public void run() {
-            metric.set(value);
-          }
-        });
+    CallbackMetric0<V> metric = newCallbackMetric(name, type, desc);
+    newTrigger(metric, () -> metric.set(value));
   }
 
   /**
@@ -116,16 +109,9 @@
    * @param trigger function to compute the value of the metric.
    */
   public <V> void newCallbackMetric(
-      String name, Class<V> valueClass, Description desc, final Supplier<V> trigger) {
-    final CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
-    newTrigger(
-        metric,
-        new Runnable() {
-          @Override
-          public void run() {
-            metric.set(trigger.get());
-          }
-        });
+      String name, Class<V> valueClass, Description desc, Supplier<V> trigger) {
+    CallbackMetric0<V> metric = newCallbackMetric(name, valueClass, desc);
+    newTrigger(metric, () -> metric.set(trigger.get()));
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
index 6910d22..5e25651 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/CallbackMetricImpl0.java
@@ -68,7 +68,7 @@
   }
 
   @Override
-  public void register(final Runnable trigger) {
+  public void register(Runnable trigger) {
     registry.register(
         name,
         new com.codahale.metrics.Gauge<V>() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
index 52e35c3..f0ae97e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/GetMetric.java
@@ -17,10 +17,14 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import org.kohsuke.args4j.Option;
 
 class GetMetric implements RestReadView<MetricResource> {
+  private final PermissionBackend permissionBackend;
   private final CurrentUser user;
   private final DropWizardMetricMaker metrics;
 
@@ -28,16 +32,16 @@
   boolean dataOnly;
 
   @Inject
-  GetMetric(CurrentUser user, DropWizardMetricMaker metrics) {
+  GetMetric(PermissionBackend permissionBackend, CurrentUser user, DropWizardMetricMaker metrics) {
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.metrics = metrics;
   }
 
   @Override
-  public MetricJson apply(MetricResource resource) throws AuthException {
-    if (!user.getCapabilities().canViewCaches()) {
-      throw new AuthException("restricted to viewCaches");
-    }
+  public MetricJson apply(MetricResource resource)
+      throws AuthException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
     return new MetricJson(
         resource.getMetric(), metrics.getAnnotations(resource.getName()), dataOnly);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
index 8ef1614..012894d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/ListMetrics.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
 import java.util.List;
@@ -28,6 +31,7 @@
 import org.kohsuke.args4j.Option;
 
 class ListMetrics implements RestReadView<ConfigResource> {
+  private final PermissionBackend permissionBackend;
   private final CurrentUser user;
   private final DropWizardMetricMaker metrics;
 
@@ -42,16 +46,17 @@
   List<String> query = new ArrayList<>();
 
   @Inject
-  ListMetrics(CurrentUser user, DropWizardMetricMaker metrics) {
+  ListMetrics(
+      PermissionBackend permissionBackend, CurrentUser user, DropWizardMetricMaker metrics) {
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.metrics = metrics;
   }
 
   @Override
-  public Map<String, MetricJson> apply(ConfigResource resource) throws AuthException {
-    if (!user.getCapabilities().canViewCaches()) {
-      throw new AuthException("restricted to viewCaches");
-    }
+  public Map<String, MetricJson> apply(ConfigResource resource)
+      throws AuthException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
 
     SortedMap<String, MetricJson> out = new TreeMap<>();
     List<String> prefixes = new ArrayList<>(query.size());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
index 2686f1f..6abf17c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/dropwizard/MetricsCollection.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -31,6 +34,7 @@
 class MetricsCollection implements ChildCollection<ConfigResource, MetricResource> {
   private final DynamicMap<RestView<MetricResource>> views;
   private final Provider<ListMetrics> list;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final DropWizardMetricMaker metrics;
 
@@ -38,10 +42,12 @@
   MetricsCollection(
       DynamicMap<RestView<MetricResource>> views,
       Provider<ListMetrics> list,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> user,
       DropWizardMetricMaker metrics) {
     this.views = views;
     this.list = list;
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.metrics = metrics;
   }
@@ -58,10 +64,8 @@
 
   @Override
   public MetricResource parse(ConfigResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException {
-    if (!user.get().getCapabilities().canViewCaches()) {
-      throw new AuthException("restricted to viewCaches");
-    }
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.VIEW_CACHES);
 
     Metric metric = metrics.getMetric(id.get());
     if (metric == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
index 11f8e50..8978e99 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/metrics/proc/ProcMetricModule.java
@@ -103,7 +103,7 @@
   }
 
   private void procJvmMemory(MetricMaker metrics) {
-    final CallbackMetric0<Long> heapCommitted =
+    CallbackMetric0<Long> heapCommitted =
         metrics.newCallbackMetric(
             "proc/jvm/memory/heap_committed",
             Long.class,
@@ -111,7 +111,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> heapUsed =
+    CallbackMetric0<Long> heapUsed =
         metrics.newCallbackMetric(
             "proc/jvm/memory/heap_used",
             Long.class,
@@ -119,7 +119,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> nonHeapCommitted =
+    CallbackMetric0<Long> nonHeapCommitted =
         metrics.newCallbackMetric(
             "proc/jvm/memory/non_heap_committed",
             Long.class,
@@ -127,7 +127,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Long> nonHeapUsed =
+    CallbackMetric0<Long> nonHeapUsed =
         metrics.newCallbackMetric(
             "proc/jvm/memory/non_heap_used",
             Long.class,
@@ -135,7 +135,7 @@
                 .setGauge()
                 .setUnit(Units.BYTES));
 
-    final CallbackMetric0<Integer> objectPendingFinalizationCount =
+    CallbackMetric0<Integer> objectPendingFinalizationCount =
         metrics.newCallbackMetric(
             "proc/jvm/memory/object_pending_finalization_count",
             Integer.class,
@@ -143,39 +143,36 @@
                 .setGauge()
                 .setUnit("objects"));
 
-    final MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
+    MemoryMXBean memory = ManagementFactory.getMemoryMXBean();
     metrics.newTrigger(
         ImmutableSet.<CallbackMetric<?>>of(
             heapCommitted, heapUsed, nonHeapCommitted, nonHeapUsed, objectPendingFinalizationCount),
-        new Runnable() {
-          @Override
-          public void run() {
-            try {
-              MemoryUsage stats = memory.getHeapMemoryUsage();
-              heapCommitted.set(stats.getCommitted());
-              heapUsed.set(stats.getUsed());
-            } catch (IllegalArgumentException e) {
-              // MXBean may throw due to a bug in Java 7; ignore.
-            }
-
-            MemoryUsage stats = memory.getNonHeapMemoryUsage();
-            nonHeapCommitted.set(stats.getCommitted());
-            nonHeapUsed.set(stats.getUsed());
-
-            objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
+        () -> {
+          try {
+            MemoryUsage stats = memory.getHeapMemoryUsage();
+            heapCommitted.set(stats.getCommitted());
+            heapUsed.set(stats.getUsed());
+          } catch (IllegalArgumentException e) {
+            // MXBean may throw due to a bug in Java 7; ignore.
           }
+
+          MemoryUsage stats = memory.getNonHeapMemoryUsage();
+          nonHeapCommitted.set(stats.getCommitted());
+          nonHeapUsed.set(stats.getUsed());
+
+          objectPendingFinalizationCount.set(memory.getObjectPendingFinalizationCount());
         });
   }
 
   private void procJvmGc(MetricMaker metrics) {
-    final CallbackMetric1<String, Long> gcCount =
+    CallbackMetric1<String, Long> gcCount =
         metrics.newCallbackMetric(
             "proc/jvm/gc/count",
             Long.class,
             new Description("Number of GCs").setCumulative(),
             Field.ofString("gc_name", "The name of the garbage collector"));
 
-    final CallbackMetric1<String, Long> gcTime =
+    CallbackMetric1<String, Long> gcTime =
         metrics.newCallbackMetric(
             "proc/jvm/gc/time",
             Long.class,
@@ -187,34 +184,26 @@
     metrics.newTrigger(
         gcCount,
         gcTime,
-        new Runnable() {
-          @Override
-          public void run() {
-            for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
-              long count = gc.getCollectionCount();
-              if (count != -1) {
-                gcCount.set(gc.getName(), count);
-              }
-              long time = gc.getCollectionTime();
-              if (time != -1) {
-                gcTime.set(gc.getName(), time);
-              }
+        () -> {
+          for (GarbageCollectorMXBean gc : ManagementFactory.getGarbageCollectorMXBeans()) {
+            long count = gc.getCollectionCount();
+            if (count != -1) {
+              gcCount.set(gc.getName(), count);
+            }
+            long time = gc.getCollectionTime();
+            if (time != -1) {
+              gcTime.set(gc.getName(), time);
             }
           }
         });
   }
 
   private void procJvmThread(MetricMaker metrics) {
-    final ThreadMXBean thread = ManagementFactory.getThreadMXBean();
+    ThreadMXBean thread = ManagementFactory.getThreadMXBean();
     metrics.newCallbackMetric(
         "proc/jvm/thread/num_live",
         Integer.class,
         new Description("Current live thread count").setGauge().setUnit("threads"),
-        new Supplier<Integer>() {
-          @Override
-          public Integer get() {
-            return thread.getThreadCount();
-          }
-        });
+        () -> thread.getThreadCount());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java
index c2643de..3478694 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/PredicateClassLoader.java
@@ -26,7 +26,7 @@
       LinkedHashMultimap.create();
 
   public PredicateClassLoader(
-      final DynamicSet<PredicateProvider> predicateProviders, final ClassLoader parent) {
+      final DynamicSet<PredicateProvider> predicateProviders, ClassLoader parent) {
     super(parent);
 
     for (PredicateProvider predicateProvider : predicateProviders) {
@@ -37,10 +37,10 @@
   }
 
   @Override
-  protected Class<?> findClass(final String className) throws ClassNotFoundException {
+  protected Class<?> findClass(String className) throws ClassNotFoundException {
     final Collection<ClassLoader> classLoaders =
         packageClassLoaderMap.get(getPackageName(className));
-    for (final ClassLoader cl : classLoaders) {
+    for (ClassLoader cl : classLoaders) {
       try {
         return Class.forName(className, true, cl);
       } catch (ClassNotFoundException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
index 9538121a4..36cb4cc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/PrologEnvironment.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -136,7 +137,7 @@
 
   /** Release resources stored in interpreter's hash manager. */
   public void close() {
-    for (final Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
+    for (Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
       try {
         i.next().run();
       } catch (Throwable err) {
@@ -166,6 +167,7 @@
     }
 
     private final ProjectCache projectCache;
+    private final PermissionBackend permissionBackend;
     private final GitRepositoryManager repositoryManager;
     private final PatchListCache patchListCache;
     private final PatchSetInfoFactory patchSetInfoFactory;
@@ -177,6 +179,7 @@
     @Inject
     Args(
         ProjectCache projectCache,
+        PermissionBackend permissionBackend,
         GitRepositoryManager repositoryManager,
         PatchListCache patchListCache,
         PatchSetInfoFactory patchSetInfoFactory,
@@ -184,6 +187,7 @@
         Provider<AnonymousUser> anonymousUser,
         @GerritServerConfig Config config) {
       this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
       this.repositoryManager = repositoryManager;
       this.patchListCache = patchListCache;
       this.patchSetInfoFactory = patchSetInfoFactory;
@@ -213,6 +217,10 @@
       return projectCache;
     }
 
+    public PermissionBackend getPermissionBackend() {
+      return permissionBackend;
+    }
+
     public GitRepositoryManager getGitRepositoryManager() {
       return repositoryManager;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
index 34fcb52..89fedda 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/rules/StoredValues.java
@@ -26,6 +26,9 @@
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
@@ -33,7 +36,8 @@
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.googlecode.prolog_cafe.exceptions.SystemException;
@@ -45,15 +49,13 @@
 import org.eclipse.jgit.lib.Repository;
 
 public final class StoredValues {
+  public static final StoredValue<Accounts> ACCOUNTS = create(Accounts.class);
+  public static final StoredValue<AccountCache> ACCOUNT_CACHE = create(AccountCache.class);
+  public static final StoredValue<Emails> EMAILS = create(Emails.class);
   public static final StoredValue<ReviewDb> REVIEW_DB = create(ReviewDb.class);
   public static final StoredValue<ChangeData> CHANGE_DATA = create(ChangeData.class);
-
-  // Note: no guarantees are made about the user passed in the ChangeControl; do
-  // not depend on this directly. Either use .forUser(otherUser) to get a
-  // control for a specific known user, or use CURRENT_USER, which may be null
-  // for rule types that may not depend on the current user.
-  public static final StoredValue<ChangeControl> CHANGE_CONTROL = create(ChangeControl.class);
   public static final StoredValue<CurrentUser> CURRENT_USER = create(CurrentUser.class);
+  public static final StoredValue<ProjectState> PROJECT_STATE = create(ProjectState.class);
 
   public static Change getChange(Prolog engine) throws SystemException {
     ChangeData cd = CHANGE_DATA.get(engine);
@@ -119,23 +121,26 @@
           GitRepositoryManager gitMgr = env.getArgs().getGitRepositoryManager();
           Change change = getChange(engine);
           Project.NameKey projectKey = change.getProject();
-          final Repository repo;
+          Repository repo;
           try {
             repo = gitMgr.openRepository(projectKey);
           } catch (IOException e) {
             throw new SystemException(e.getMessage());
           }
-          env.addToCleanup(
-              new Runnable() {
-                @Override
-                public void run() {
-                  repo.close();
-                }
-              });
+          env.addToCleanup(repo::close);
           return repo;
         }
       };
 
+  public static final StoredValue<PermissionBackend> PERMISSION_BACKEND =
+      new StoredValue<PermissionBackend>() {
+        @Override
+        protected PermissionBackend createValue(Prolog engine) {
+          PrologEnvironment env = (PrologEnvironment) engine.control;
+          return env.getArgs().getPermissionBackend();
+        }
+      };
+
   public static final StoredValue<AnonymousUser> ANONYMOUS_USER =
       new StoredValue<AnonymousUser>() {
         @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java
index de8e9a4..c96d61a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/AnonymousUser.java
@@ -14,20 +14,13 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.inject.Inject;
 import java.util.Collections;
 
 /** An anonymous user who has not yet authenticated. */
 public class AnonymousUser extends CurrentUser {
-  @Inject
-  AnonymousUser(CapabilityControl.Factory capabilityControlFactory) {
-    super(capabilityControlFactory);
-  }
-
   @Override
   public GroupMembership getEffectiveGroups() {
     return new ListGroupMembership(Collections.singleton(SystemGroupBackend.ANONYMOUS_USERS));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
index cb65ed3..c1f89e2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Table;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.reviewdb.client.Account;
@@ -27,9 +28,9 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LabelNormalizer;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -41,8 +42,9 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.TreeMap;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Copies approvals between patch sets.
@@ -52,7 +54,6 @@
  */
 @Singleton
 public class ApprovalCopier {
-  private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final ChangeKindCache changeKindCache;
   private final LabelNormalizer labelNormalizer;
@@ -61,13 +62,11 @@
 
   @Inject
   ApprovalCopier(
-      GitRepositoryManager repoManager,
       ProjectCache projectCache,
       ChangeKindCache changeKindCache,
       LabelNormalizer labelNormalizer,
       ChangeData.Factory changeDataFactory,
       PatchSetUtil psUtil) {
-    this.repoManager = repoManager;
     this.projectCache = projectCache;
     this.changeKindCache = changeKindCache;
     this.labelNormalizer = labelNormalizer;
@@ -79,48 +78,89 @@
    * Apply approval copy settings from prior PatchSets to a new PatchSet.
    *
    * @param db review database.
-   * @param ctl change control for user uploading PatchSet
+   * @param notes change notes for user uploading PatchSet
+   * @param user user uploading PatchSet
    * @param ps new PatchSet
+   * @param rw open walk that can read the patch set commit; null to open the repo on demand.
+   * @param repoConfig repo config used for change kind detection; null to read from repo on demand.
    * @throws OrmException
    */
-  public void copy(ReviewDb db, ChangeControl ctl, PatchSet ps) throws OrmException {
-    copy(db, ctl, ps, Collections.<PatchSetApproval>emptyList());
+  public void copyInReviewDb(
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet ps,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig)
+      throws OrmException {
+    copyInReviewDb(db, notes, user, ps, rw, repoConfig, Collections.emptyList());
   }
 
   /**
    * Apply approval copy settings from prior PatchSets to a new PatchSet.
    *
    * @param db review database.
-   * @param ctl change control for user uploading PatchSet
+   * @param notes change notes for user uploading PatchSet
+   * @param user user uploading PatchSet
    * @param ps new PatchSet
+   * @param rw open walk that can read the patch set commit; null to open the repo on demand.
+   * @param repoConfig repo config used for change kind detection; null to read from repo on demand.
    * @param dontCopy PatchSetApprovals indicating which (account, label) pairs should not be copied
    * @throws OrmException
    */
-  public void copy(ReviewDb db, ChangeControl ctl, PatchSet ps, Iterable<PatchSetApproval> dontCopy)
+  public void copyInReviewDb(
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet ps,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      Iterable<PatchSetApproval> dontCopy)
       throws OrmException {
-    db.patchSetApprovals().insert(getForPatchSet(db, ctl, ps, dontCopy));
-  }
-
-  Iterable<PatchSetApproval> getForPatchSet(ReviewDb db, ChangeControl ctl, PatchSet.Id psId)
-      throws OrmException {
-    return getForPatchSet(db, ctl, psId, Collections.<PatchSetApproval>emptyList());
+    if (PrimaryStorage.of(notes.getChange()) == PrimaryStorage.REVIEW_DB) {
+      db.patchSetApprovals().insert(getForPatchSet(db, notes, user, ps, rw, repoConfig, dontCopy));
+    }
   }
 
   Iterable<PatchSetApproval> getForPatchSet(
-      ReviewDb db, ChangeControl ctl, PatchSet.Id psId, Iterable<PatchSetApproval> dontCopy)
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet.Id psId,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig)
       throws OrmException {
-    PatchSet ps = psUtil.get(db, ctl.getNotes(), psId);
+    return getForPatchSet(
+        db, notes, user, psId, rw, repoConfig, Collections.<PatchSetApproval>emptyList());
+  }
+
+  Iterable<PatchSetApproval> getForPatchSet(
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet.Id psId,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      Iterable<PatchSetApproval> dontCopy)
+      throws OrmException {
+    PatchSet ps = psUtil.get(db, notes, psId);
     if (ps == null) {
       return Collections.emptyList();
     }
-    return getForPatchSet(db, ctl, ps, dontCopy);
+    return getForPatchSet(db, notes, user, ps, rw, repoConfig, dontCopy);
   }
 
   private Iterable<PatchSetApproval> getForPatchSet(
-      ReviewDb db, ChangeControl ctl, PatchSet ps, Iterable<PatchSetApproval> dontCopy)
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet ps,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      Iterable<PatchSetApproval> dontCopy)
       throws OrmException {
     checkNotNull(ps, "ps should not be null");
-    ChangeData cd = changeDataFactory.create(db, ctl);
+    ChangeData cd = changeDataFactory.create(db, notes);
     try {
       ProjectState project = projectCache.checkedGet(cd.change().getDest().getParentKey());
       ListMultimap<PatchSet.Id, PatchSetApproval> all = cd.approvals();
@@ -140,39 +180,38 @@
 
       TreeMap<Integer, PatchSet> patchSets = getPatchSets(cd);
 
-      try (Repository repo = repoManager.openRepository(project.getProject().getNameKey())) {
-        // Walk patch sets strictly less than current in descending order.
-        Collection<PatchSet> allPrior =
-            patchSets.descendingMap().tailMap(ps.getId().get(), false).values();
-        for (PatchSet priorPs : allPrior) {
-          List<PatchSetApproval> priorApprovals = all.get(priorPs.getId());
-          if (priorApprovals.isEmpty()) {
+      // Walk patch sets strictly less than current in descending order.
+      Collection<PatchSet> allPrior =
+          patchSets.descendingMap().tailMap(ps.getId().get(), false).values();
+      for (PatchSet priorPs : allPrior) {
+        List<PatchSetApproval> priorApprovals = all.get(priorPs.getId());
+        if (priorApprovals.isEmpty()) {
+          continue;
+        }
+
+        ChangeKind kind =
+            changeKindCache.getChangeKind(
+                project.getNameKey(),
+                rw,
+                repoConfig,
+                ObjectId.fromString(priorPs.getRevision().get()),
+                ObjectId.fromString(ps.getRevision().get()));
+
+        for (PatchSetApproval psa : priorApprovals) {
+          if (wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
             continue;
           }
-
-          ChangeKind kind =
-              changeKindCache.getChangeKind(
-                  project.getProject().getNameKey(),
-                  repo,
-                  ObjectId.fromString(priorPs.getRevision().get()),
-                  ObjectId.fromString(ps.getRevision().get()));
-
-          for (PatchSetApproval psa : priorApprovals) {
-            if (wontCopy.contains(psa.getLabel(), psa.getAccountId())) {
-              continue;
-            }
-            if (byUser.contains(psa.getLabel(), psa.getAccountId())) {
-              continue;
-            }
-            if (!canCopy(project, psa, ps.getId(), kind)) {
-              wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
-              continue;
-            }
-            byUser.put(psa.getLabel(), psa.getAccountId(), copy(psa, ps.getId()));
+          if (byUser.contains(psa.getLabel(), psa.getAccountId())) {
+            continue;
           }
+          if (!canCopy(project, psa, ps.getId(), kind)) {
+            wontCopy.put(psa.getLabel(), psa.getAccountId(), psa);
+            continue;
+          }
+          byUser.put(psa.getLabel(), psa.getAccountId(), copy(psa, ps.getId()));
         }
-        return labelNormalizer.normalize(ctl, byUser.values()).getNormalized();
       }
+      return labelNormalizer.normalize(notes, user, byUser.values()).getNormalized();
     } catch (IOException e) {
       throw new OrmException(e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index 57615c4..82fa3f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -28,10 +28,9 @@
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
 import com.google.common.primitives.Shorts;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -46,7 +45,10 @@
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -60,6 +62,8 @@
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -97,26 +101,26 @@
   }
 
   private static Iterable<PatchSetApproval> filterApprovals(
-      Iterable<PatchSetApproval> psas, final Account.Id accountId) {
+      Iterable<PatchSetApproval> psas, Account.Id accountId) {
     return Iterables.filter(psas, a -> Objects.equals(a.getAccountId(), accountId));
   }
 
   private final NotesMigration migration;
   private final IdentifiedUser.GenericFactory userFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
   private final ApprovalCopier copier;
+  private final PermissionBackend permissionBackend;
 
   @VisibleForTesting
   @Inject
   public ApprovalsUtil(
       NotesMigration migration,
       IdentifiedUser.GenericFactory userFactory,
-      ChangeControl.GenericFactory changeControlFactory,
-      ApprovalCopier copier) {
+      ApprovalCopier copier,
+      PermissionBackend permissionBackend) {
     this.migration = migration;
     this.userFactory = userFactory;
-    this.changeControlFactory = changeControlFactory;
     this.copier = copier;
+    this.permissionBackend = permissionBackend;
   }
 
   /**
@@ -259,8 +263,8 @@
   private boolean canSee(ReviewDb db, ChangeNotes notes, Account.Id accountId) {
     try {
       IdentifiedUser user = userFactory.create(accountId);
-      return changeControlFactory.controlFor(notes, user).isVisible(db);
-    } catch (OrmException e) {
+      return permissionBackend.user(user).change(notes).database(db).test(ChangePermission.READ);
+    } catch (PermissionBackendException e) {
       log.warn(
           "Failed to check if account {} can see change {}",
           accountId.get(),
@@ -302,7 +306,7 @@
    * @param update change update.
    * @param labelTypes label types for the containing project.
    * @param ps patch set being approved.
-   * @param changeCtl change control for user adding approvals.
+   * @param user user adding approvals.
    * @param approvals approvals to add.
    * @throws RestApiException
    * @throws OrmException
@@ -312,24 +316,24 @@
       ChangeUpdate update,
       LabelTypes labelTypes,
       PatchSet ps,
-      ChangeControl changeCtl,
+      CurrentUser user,
       Map<String, Short> approvals)
-      throws RestApiException, OrmException {
-    Account.Id accountId = changeCtl.getUser().getAccountId();
+      throws RestApiException, OrmException, PermissionBackendException {
+    Account.Id accountId = user.getAccountId();
     checkArgument(
         accountId.equals(ps.getUploader()),
         "expected user %s to match patch set uploader %s",
         accountId,
         ps.getUploader());
     if (approvals.isEmpty()) {
-      return Collections.emptyList();
+      return ImmutableList.of();
     }
-    checkApprovals(approvals, changeCtl);
+    checkApprovals(approvals, permissionBackend.user(user).database(db).change(update.getNotes()));
     List<PatchSetApproval> cells = new ArrayList<>(approvals.size());
     Date ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       LabelType lt = labelTypes.byLabel(vote.getKey());
-      cells.add(newApproval(ps.getId(), changeCtl.getUser(), lt.getLabelId(), vote.getValue(), ts));
+      cells.add(newApproval(ps.getId(), user, lt.getLabelId(), vote.getValue(), ts));
     }
     for (PatchSetApproval psa : cells) {
       update.putApproval(psa.getLabel(), psa.getValue());
@@ -350,13 +354,15 @@
     }
   }
 
-  private static void checkApprovals(Map<String, Short> approvals, ChangeControl changeCtl)
-      throws AuthException {
+  private static void checkApprovals(
+      Map<String, Short> approvals, PermissionBackend.ForChange forChange)
+      throws AuthException, PermissionBackendException {
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       String name = vote.getKey();
       Short value = vote.getValue();
-      PermissionRange range = changeCtl.getRange(Permission.forLabel(name));
-      if (range == null || !range.contains(value)) {
+      try {
+        forChange.check(new LabelPermission.WithValue(name, value));
+      } catch (AuthException e) {
         throw new AuthException(
             String.format("applying label \"%s\": %d is restricted", name, value));
       }
@@ -376,20 +382,33 @@
     return notes.load().getApprovals();
   }
 
-  public Iterable<PatchSetApproval> byPatchSet(ReviewDb db, ChangeControl ctl, PatchSet.Id psId)
+  public Iterable<PatchSetApproval> byPatchSet(
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet.Id psId,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig)
       throws OrmException {
     if (!migration.readChanges()) {
       return sortApprovals(db.patchSetApprovals().byPatchSet(psId));
     }
-    return copier.getForPatchSet(db, ctl, psId);
+    return copier.getForPatchSet(db, notes, user, psId, rw, repoConfig);
   }
 
   public Iterable<PatchSetApproval> byPatchSetUser(
-      ReviewDb db, ChangeControl ctl, PatchSet.Id psId, Account.Id accountId) throws OrmException {
+      ReviewDb db,
+      ChangeNotes notes,
+      CurrentUser user,
+      PatchSet.Id psId,
+      Account.Id accountId,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig)
+      throws OrmException {
     if (!migration.readChanges()) {
       return sortApprovals(db.patchSetApprovals().byPatchSetUser(psId, accountId));
     }
-    return filterApprovals(byPatchSet(db, ctl, psId), accountId);
+    return filterApprovals(byPatchSet(db, notes, user, psId, rw, repoConfig), accountId);
   }
 
   public PatchSetApproval getSubmitter(ReviewDb db, ChangeNotes notes, PatchSet.Id c) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
index 0d5c61c..46016c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeFinder.java
@@ -14,34 +14,71 @@
 
 package com.google.gerrit.server;
 
+import com.google.common.base.Throwables;
+import com.google.common.cache.Cache;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.change.ChangeTriplet;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import com.google.inject.name.Named;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
 
 @Singleton
 public class ChangeFinder {
-  private final Provider<InternalChangeQuery> queryProvider;
+  private static final String CACHE_NAME = "changeid_project";
 
-  @Inject
-  ChangeFinder(Provider<InternalChangeQuery> queryProvider) {
-    this.queryProvider = queryProvider;
+  public static Module module() {
+    return new CacheModule() {
+      @Override
+      protected void configure() {
+        cache(CACHE_NAME, Change.Id.class, String.class).maximumWeight(1024);
+      }
+    };
   }
 
-  public ChangeControl findOne(String id, CurrentUser user) throws OrmException {
-    List<ChangeControl> ctls = find(id, user);
+  private final IndexConfig indexConfig;
+  private final Cache<Change.Id, String> changeIdProjectCache;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Provider<ReviewDb> reviewDb;
+  private final ChangeNotes.Factory changeNotesFactory;
+
+  @Inject
+  ChangeFinder(
+      IndexConfig indexConfig,
+      @Named(CACHE_NAME) Cache<Change.Id, String> changeIdProjectCache,
+      Provider<InternalChangeQuery> queryProvider,
+      Provider<ReviewDb> reviewDb,
+      ChangeNotes.Factory changeNotesFactory) {
+    this.indexConfig = indexConfig;
+    this.changeIdProjectCache = changeIdProjectCache;
+    this.queryProvider = queryProvider;
+    this.reviewDb = reviewDb;
+    this.changeNotesFactory = changeNotesFactory;
+  }
+
+  public ChangeNotes findOne(String id) throws OrmException {
+    List<ChangeNotes> ctls = find(id);
     if (ctls.size() != 1) {
       return null;
     }
@@ -52,64 +89,117 @@
    * Find changes matching the given identifier.
    *
    * @param id change identifier, either a numeric ID, a Change-Id, or project~branch~id triplet.
-   * @param user user to wrap in controls.
-   * @return possibly-empty list of controls for all matching changes, corresponding to the given
-   *     user; may or may not be visible.
+   * @return possibly-empty list of notes for all matching changes; may or may not be visible.
    * @throws OrmException if an error occurred querying the database.
    */
-  public List<ChangeControl> find(String id, CurrentUser user) throws OrmException {
-    // Use the index to search for changes, but don't return any stored fields,
-    // to force rereading in case the index is stale.
-    InternalChangeQuery query = queryProvider.get().noFields();
+  public List<ChangeNotes> find(String id) throws OrmException {
+    if (id.isEmpty()) {
+      return Collections.emptyList();
+    }
 
-    // Try legacy id
-    if (!id.isEmpty() && id.charAt(0) != '0') {
-      Integer n = Ints.tryParse(id);
+    int z = id.lastIndexOf('~');
+    int y = id.lastIndexOf('~', z - 1);
+    if (y < 0 && z > 0) {
+      // Try project~numericChangeId
+      Integer n = Ints.tryParse(id.substring(z + 1));
       if (n != null) {
-        return asChangeControls(query.byLegacyChangeId(new Change.Id(n)), user);
+        return fromProjectNumber(id.substring(0, z), n.intValue());
       }
     }
 
-    // Try commit hash
-    if (id.matches("^([0-9a-fA-F]{" + RevId.ABBREV_LEN + "," + RevId.LEN + "})$")) {
-      return asChangeControls(query.byCommit(id), user);
+    if (y < 0 && z < 0) {
+      // Try numeric changeId
+      Integer n = Ints.tryParse(id);
+      if (n != null) {
+        return find(new Change.Id(n));
+      }
     }
 
-    // Try isolated changeId
-    if (!id.contains("~")) {
-      return asChangeControls(query.byKeyPrefix(id), user);
-    }
-
-    // Try change triplet
-    Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id);
-    if (triplet.isPresent()) {
-      return asChangeControls(query.byBranchKey(triplet.get().branch(), triplet.get().id()), user);
-    }
-
-    return Collections.emptyList();
-  }
-
-  public ChangeControl findOne(Change.Id id, CurrentUser user) throws OrmException {
-    List<ChangeControl> ctls = find(id, user);
-    if (ctls.size() != 1) {
-      throw new NoSuchChangeException(id);
-    }
-    return ctls.get(0);
-  }
-
-  public List<ChangeControl> find(Change.Id id, CurrentUser user) throws OrmException {
     // Use the index to search for changes, but don't return any stored fields,
     // to force rereading in case the index is stale.
     InternalChangeQuery query = queryProvider.get().noFields();
-    return asChangeControls(query.byLegacyChangeId(id), user);
+
+    // Try commit hash
+    if (id.matches("^([0-9a-fA-F]{" + RevId.ABBREV_LEN + "," + RevId.LEN + "})$")) {
+      return asChangeNotes(query.byCommit(id));
+    }
+
+    if (y > 0 && z > 0) {
+      // Try change triplet (project~branch~Ihash...)
+      Optional<ChangeTriplet> triplet = ChangeTriplet.parse(id, y, z);
+      if (triplet.isPresent()) {
+        ChangeTriplet t = triplet.get();
+        return asChangeNotes(query.byBranchKey(t.branch(), t.id()));
+      }
+    }
+
+    // Try isolated Ihash... format ("Change-Id: Ihash").
+    return asChangeNotes(query.byKeyPrefix(id));
   }
 
-  private List<ChangeControl> asChangeControls(List<ChangeData> cds, CurrentUser user)
+  private List<ChangeNotes> fromProjectNumber(String project, int changeNumber)
       throws OrmException {
-    List<ChangeControl> ctls = new ArrayList<>(cds.size());
-    for (ChangeData cd : cds) {
-      ctls.add(cd.changeControl(user));
+    Change.Id cId = new Change.Id(changeNumber);
+    try {
+      return ImmutableList.of(
+          changeNotesFactory.createChecked(reviewDb.get(), Project.NameKey.parse(project), cId));
+    } catch (NoSuchChangeException e) {
+      return Collections.emptyList();
+    } catch (OrmException e) {
+      // Distinguish between a RepositoryNotFoundException (project argument invalid) and
+      // other OrmExceptions (failure in the persistence layer).
+      if (Throwables.getRootCause(e) instanceof RepositoryNotFoundException) {
+        return Collections.emptyList();
+      }
+      throw e;
     }
-    return ctls;
+  }
+
+  public ChangeNotes findOne(Change.Id id) throws OrmException {
+    List<ChangeNotes> notes = find(id);
+    if (notes.size() != 1) {
+      throw new NoSuchChangeException(id);
+    }
+    return notes.get(0);
+  }
+
+  public List<ChangeNotes> find(Change.Id id) throws OrmException {
+    String project = changeIdProjectCache.getIfPresent(id);
+    if (project != null) {
+      return fromProjectNumber(project, id.get());
+    }
+
+    // Use the index to search for changes, but don't return any stored fields,
+    // to force rereading in case the index is stale.
+    InternalChangeQuery query = queryProvider.get().noFields();
+    List<ChangeData> r = query.byLegacyChangeId(id);
+    if (r.size() == 1) {
+      changeIdProjectCache.put(id, Url.encode(r.get(0).project().get()));
+    }
+    return asChangeNotes(r);
+  }
+
+  private List<ChangeNotes> asChangeNotes(List<ChangeData> cds) throws OrmException {
+    List<ChangeNotes> notes = new ArrayList<>(cds.size());
+    if (!indexConfig.separateChangeSubIndexes()) {
+      for (ChangeData cd : cds) {
+        notes.add(cd.notes());
+      }
+      return notes;
+    }
+
+    // If an index implementation uses separate non-atomic subindexes, it's possible to temporarily
+    // observe a change as present in both subindexes, if this search is concurrent with a write.
+    // Dedup to avoid confusing the caller. We can choose an arbitrary ChangeData instance because
+    // the index results have no stored fields, so the data is already reloaded. (It's also possible
+    // that a change might appear in zero subindexes, but there's nothing we can do here to help
+    // this case.)
+    Set<Change.Id> seen = Sets.newHashSetWithExpectedSize(cds.size());
+    for (ChangeData cd : cds) {
+      if (seen.add(cd.getId())) {
+        notes.add(cd.notes());
+      }
+    }
+    return notes;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
index d277bf9..9aae00b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -43,20 +43,33 @@
  */
 @Singleton
 public class ChangeMessagesUtil {
-  public static final String TAG_ABANDON = "autogenerated:gerrit:abandon";
-  public static final String TAG_CHERRY_PICK_CHANGE = "autogenerated:gerrit:cherryPickChange";
-  public static final String TAG_DELETE_ASSIGNEE = "autogenerated:gerrit:deleteAssignee";
-  public static final String TAG_DELETE_REVIEWER = "autogenerated:gerrit:deleteReviewer";
-  public static final String TAG_DELETE_VOTE = "autogenerated:gerrit:deleteVote";
-  public static final String TAG_MERGED = "autogenerated:gerrit:merged";
-  public static final String TAG_MOVE = "autogenerated:gerrit:move";
-  public static final String TAG_RESTORE = "autogenerated:gerrit:restore";
-  public static final String TAG_REVERT = "autogenerated:gerrit:revert";
-  public static final String TAG_SET_ASSIGNEE = "autogenerated:gerrit:setAssignee";
-  public static final String TAG_SET_DESCRIPTION = "autogenerated:gerrit:setPsDescription";
-  public static final String TAG_SET_HASHTAGS = "autogenerated:gerrit:setHashtag";
-  public static final String TAG_SET_TOPIC = "autogenerated:gerrit:setTopic";
-  public static final String TAG_UPLOADED_PATCH_SET = "autogenerated:gerrit:newPatchSet";
+  public static final String AUTOGENERATED_TAG_PREFIX = "autogenerated:";
+
+  public static final String TAG_ABANDON = AUTOGENERATED_TAG_PREFIX + "gerrit:abandon";
+  public static final String TAG_CHERRY_PICK_CHANGE =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:cherryPickChange";
+  public static final String TAG_DELETE_ASSIGNEE =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:deleteAssignee";
+  public static final String TAG_DELETE_REVIEWER =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:deleteReviewer";
+  public static final String TAG_DELETE_VOTE = AUTOGENERATED_TAG_PREFIX + "gerrit:deleteVote";
+  public static final String TAG_MERGED = AUTOGENERATED_TAG_PREFIX + "gerrit:merged";
+  public static final String TAG_MOVE = AUTOGENERATED_TAG_PREFIX + "gerrit:move";
+  public static final String TAG_RESTORE = AUTOGENERATED_TAG_PREFIX + "gerrit:restore";
+  public static final String TAG_REVERT = AUTOGENERATED_TAG_PREFIX + "gerrit:revert";
+  public static final String TAG_SET_ASSIGNEE = AUTOGENERATED_TAG_PREFIX + "gerrit:setAssignee";
+  public static final String TAG_SET_DESCRIPTION =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:setPsDescription";
+  public static final String TAG_SET_HASHTAGS = AUTOGENERATED_TAG_PREFIX + "gerrit:setHashtag";
+  public static final String TAG_SET_PRIVATE = AUTOGENERATED_TAG_PREFIX + "gerrit:setPrivate";
+  public static final String TAG_SET_READY = AUTOGENERATED_TAG_PREFIX + "gerrit:setReadyForReview";
+  public static final String TAG_SET_TOPIC = AUTOGENERATED_TAG_PREFIX + "gerrit:setTopic";
+  public static final String TAG_SET_WIP = AUTOGENERATED_TAG_PREFIX + "gerrit:setWorkInProgress";
+  public static final String TAG_UNSET_PRIVATE = AUTOGENERATED_TAG_PREFIX + "gerrit:unsetPrivate";
+  public static final String TAG_UPLOADED_PATCH_SET =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:newPatchSet";
+  public static final String TAG_UPLOADED_WIP_PATCH_SET =
+      AUTOGENERATED_TAG_PREFIX + "gerrit:newWipPatchSet";
 
   public static ChangeMessage newMessage(ChangeContext ctx, String body, @Nullable String tag) {
     return newMessage(ctx.getChange().currentPatchSetId(), ctx.getUser(), ctx.getWhen(), body, tag);
@@ -78,6 +91,10 @@
     return m;
   }
 
+  public static String uploadedPatchSetTag(boolean workInProgress) {
+    return workInProgress ? TAG_UPLOADED_WIP_PATCH_SET : TAG_UPLOADED_PATCH_SET;
+  }
+
   private static List<ChangeMessage> sortChangeMessages(Iterable<ChangeMessage> changeMessage) {
     return ChangeNotes.MESSAGE_BY_TIME.sortedCopy(changeMessage);
   }
@@ -116,4 +133,12 @@
     update.setTag(changeMessage.getTag());
     db.changeMessages().insert(Collections.singleton(changeMessage));
   }
+
+  /**
+   * @param tag value of a tag, or null.
+   * @return whether the tag starts with the autogenerated prefix.
+   */
+  public static boolean isAutogenerated(@Nullable String tag) {
+    return tag != null && tag.startsWith(AUTOGENERATED_TAG_PREFIX);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
index 2859949..56359ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeUtil.java
@@ -16,6 +16,7 @@
 
 import static java.util.Comparator.comparingInt;
 
+import com.google.common.collect.Maps;
 import com.google.common.collect.Ordering;
 import com.google.common.io.BaseEncoding;
 import com.google.gerrit.reviewdb.client.Change;
@@ -25,12 +26,14 @@
 import java.security.SecureRandom;
 import java.util.Map;
 import java.util.Random;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
 public class ChangeUtil {
+  public static final int TOPIC_MAX_LENGTH = 2048;
+
   private static final Random UUID_RANDOM = new SecureRandom();
   private static final BaseEncoding UUID_ENCODING = BaseEncoding.base16().lowerCase();
 
@@ -42,7 +45,7 @@
       Ordering.from(comparingInt(PatchSet::getPatchSetId));
 
   public static String formatChangeUrl(String canonicalWebUrl, Change change) {
-    return canonicalWebUrl + change.getChangeId();
+    return canonicalWebUrl + "#/c/" + change.getProject().get() + "/+/" + change.getChangeId();
   }
 
   /** @return a new unique identifier for change message entities. */
@@ -52,7 +55,16 @@
     return UUID_ENCODING.encode(buf, 0, 4) + '_' + UUID_ENCODING.encode(buf, 4, 4);
   }
 
-  public static PatchSet.Id nextPatchSetId(Map<String, Ref> allRefs, PatchSet.Id id) {
+  /**
+   * Get the next patch set ID from a previously-read map of all refs.
+   *
+   * @param allRefs map of full ref name to ref, in the same format returned by {@link
+   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)} when passing {@code ""}.
+   * @param id previous patch set ID.
+   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
+   *     names appear in the {@code allRefs} map.
+   */
+  public static PatchSet.Id nextPatchSetIdFromAllRefsMap(Map<String, Ref> allRefs, PatchSet.Id id) {
     PatchSet.Id next = nextPatchSetId(id);
     while (allRefs.containsKey(next.toRefName())) {
       next = nextPatchSetId(next);
@@ -60,12 +72,55 @@
     return next;
   }
 
+  /**
+   * Get the next patch set ID from a previously-read map of refs below the change prefix.
+   *
+   * @param changeRefs map of ref suffix to SHA-1, where the keys are ref names with the {@code
+   *     refs/changes/CD/ABCD/} prefix stripped. All refs should be under {@code id}'s change ref
+   *     prefix. The keys match the format returned by {@link
+   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)} when passing the appropriate {@code
+   *     refs/changes/CD/ABCD}.
+   * @param id previous patch set ID.
+   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
+   *     names appear in the {@code changeRefs} map.
+   */
+  public static PatchSet.Id nextPatchSetIdFromChangeRefsMap(
+      Map<String, ObjectId> changeRefs, PatchSet.Id id) {
+    int prefixLen = id.getParentKey().toRefPrefix().length();
+    PatchSet.Id next = nextPatchSetId(id);
+    while (changeRefs.containsKey(next.toRefName().substring(prefixLen))) {
+      next = nextPatchSetId(next);
+    }
+    return next;
+  }
+
+  /**
+   * Get the next patch set ID just looking at a single previous patch set ID.
+   *
+   * <p>This patch set ID may or may not be available in the database; callers that want a
+   * previously-unused ID should use {@link #nextPatchSetIdFromAllRefsMap} or {@link
+   * #nextPatchSetIdFromChangeRefsMap}.
+   *
+   * @param id previous patch set ID.
+   * @return next patch set ID for the same change, incrementing by 1.
+   */
   public static PatchSet.Id nextPatchSetId(PatchSet.Id id) {
     return new PatchSet.Id(id.getParentKey(), id.get() + 1);
   }
 
+  /**
+   * Get the next patch set ID from scanning refs in the repo.
+   *
+   * @param git repository to scan for patch set refs.
+   * @param id previous patch set ID.
+   * @return next unused patch set ID for the same change, skipping any IDs whose corresponding ref
+   *     names appear in the repository.
+   */
   public static PatchSet.Id nextPatchSetId(Repository git, PatchSet.Id id) throws IOException {
-    return nextPatchSetId(git.getRefDatabase().getRefs(RefDatabase.ALL), id);
+    return nextPatchSetIdFromChangeRefsMap(
+        Maps.transformValues(
+            git.getRefDatabase().getRefs(id.getParentKey().toRefPrefix()), Ref::getObjectId),
+        id);
   }
 
   public static String cropSubject(String subject) {
@@ -83,5 +138,9 @@
     return subject;
   }
 
+  public static String status(Change c) {
+    return c != null ? c.getStatus().name().toLowerCase() : "deleted";
+  }
+
   private ChangeUtil() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
index 8d2289a..56b1724 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
@@ -16,7 +16,9 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.reviewdb.client.PatchLineComment.Status.PUBLISHED;
 import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.FluentIterable;
@@ -24,6 +26,7 @@
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
@@ -38,14 +41,17 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
@@ -56,9 +62,8 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
+import java.util.Map;
 import java.util.Optional;
-import java.util.function.Predicate;
-import java.util.stream.StreamSupport;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
@@ -125,6 +130,8 @@
   private final GitRepositoryManager repoManager;
   private final AllUsersName allUsers;
   private final NotesMigration migration;
+  private final PatchListCache patchListCache;
+  private final PatchSetUtil psUtil;
   private final String serverId;
 
   @Inject
@@ -132,10 +139,14 @@
       GitRepositoryManager repoManager,
       AllUsersName allUsers,
       NotesMigration migration,
+      PatchListCache patchListCache,
+      PatchSetUtil psUtil,
       @GerritServerId String serverId) {
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.migration = migration;
+    this.patchListCache = patchListCache;
+    this.psUtil = psUtil;
     this.serverId = serverId;
   }
 
@@ -155,7 +166,7 @@
       } else {
         // Inherit unresolved value from inReplyTo comment if not specified.
         Comment.Key key = new Comment.Key(parentUuid, path, psId.patchSetId);
-        Optional<Comment> parent = get(ctx.getDb(), ctx.getNotes(), key);
+        Optional<Comment> parent = getPublished(ctx.getDb(), ctx.getNotes(), key);
         if (!parent.isPresent()) {
           throw new UnprocessableEntityException("Invalid parentUuid supplied for comment");
         }
@@ -198,25 +209,42 @@
     return c;
   }
 
-  public Optional<Comment> get(ReviewDb db, ChangeNotes notes, Comment.Key key)
+  public Optional<Comment> getPublished(ReviewDb db, ChangeNotes notes, Comment.Key key)
       throws OrmException {
     if (!migration.readChanges()) {
-      return Optional.ofNullable(
-              db.patchComments().get(PatchLineComment.Key.from(notes.getChangeId(), key)))
-          .map(plc -> plc.asComment(serverId));
+      return getReviewDb(db, notes, key);
     }
-    Predicate<Comment> p = c -> key.equals(c.key);
-    Optional<Comment> c = publishedByChange(db, notes).stream().filter(p).findFirst();
-    if (c.isPresent()) {
+    return publishedByChange(db, notes).stream().filter(c -> key.equals(c.key)).findFirst();
+  }
+
+  public Optional<Comment> getDraft(
+      ReviewDb db, ChangeNotes notes, IdentifiedUser user, Comment.Key key) throws OrmException {
+    if (!migration.readChanges()) {
+      Optional<Comment> c = getReviewDb(db, notes, key);
+      if (c.isPresent() && !c.get().author.getId().equals(user.getAccountId())) {
+        throw new OrmException(
+            String.format(
+                "Expected draft %s to belong to account %s, but it belongs to %s",
+                key, user.getAccountId(), c.get().author.getId()));
+      }
       return c;
     }
-    return draftByChange(db, notes).stream().filter(p).findFirst();
+    return draftByChangeAuthor(db, notes, user.getAccountId())
+        .stream()
+        .filter(c -> key.equals(c.key))
+        .findFirst();
+  }
+
+  private Optional<Comment> getReviewDb(ReviewDb db, ChangeNotes notes, Comment.Key key)
+      throws OrmException {
+    return Optional.ofNullable(
+            db.patchComments().get(PatchLineComment.Key.from(notes.getChangeId(), key)))
+        .map(plc -> plc.asComment(serverId));
   }
 
   public List<Comment> publishedByChange(ReviewDb db, ChangeNotes notes) throws OrmException {
     if (!migration.readChanges()) {
-      return sort(
-          byCommentStatus(db.patchComments().byChange(notes.getChangeId()), Status.PUBLISHED));
+      return sort(byCommentStatus(db.patchComments().byChange(notes.getChangeId()), PUBLISHED));
     }
 
     notes.load();
@@ -248,7 +276,7 @@
   }
 
   private List<Comment> byCommentStatus(
-      ResultSet<PatchLineComment> comments, final PatchLineComment.Status status) {
+      ResultSet<PatchLineComment> comments, PatchLineComment.Status status) {
     return toComments(
         serverId, Lists.newArrayList(Iterables.filter(comments, c -> c.getStatus() == status)));
   }
@@ -335,7 +363,7 @@
   public List<Comment> draftByChangeAuthor(ReviewDb db, ChangeNotes notes, Account.Id author)
       throws OrmException {
     if (!migration.readChanges()) {
-      return StreamSupport.stream(db.patchComments().draftByAuthor(author).spliterator(), false)
+      return Streams.stream(db.patchComments().draftByAuthor(author))
           .filter(c -> c.getPatchSetId().getParentKey().equals(notes.getChangeId()))
           .map(plc -> plc.asComment(serverId))
           .sorted(COMMENT_ORDER)
@@ -395,6 +423,31 @@
         .delete(toPatchLineComments(update.getId(), PatchLineComment.Status.DRAFT, comments));
   }
 
+  public void deleteCommentByRewritingHistory(
+      ReviewDb db, ChangeUpdate update, Comment.Key commentKey, PatchSet.Id psId, String newMessage)
+      throws OrmException {
+    if (PrimaryStorage.of(update.getChange()).equals(PrimaryStorage.REVIEW_DB)) {
+      PatchLineComment.Key key =
+          new PatchLineComment.Key(new Patch.Key(psId, commentKey.filename), commentKey.uuid);
+
+      if (db instanceof BatchUpdateReviewDb) {
+        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+      }
+      db = ReviewDbUtil.unwrapDb(db);
+
+      PatchLineComment patchLineComment = db.patchComments().get(key);
+
+      if (!patchLineComment.getStatus().equals(PUBLISHED)) {
+        throw new OrmException(String.format("comment %s is not published", key));
+      }
+
+      patchLineComment.setMessage(newMessage);
+      db.patchComments().upsert(Collections.singleton(patchLineComment));
+    }
+
+    update.deleteCommentByRewritingHistory(commentKey.uuid, newMessage);
+  }
+
   public void deleteAllDraftsFromAllUsers(Change.Id changeId) throws IOException {
     try (Repository repo = repoManager.openRepository(allUsers);
         RevWalk rw = new RevWalk(repo)) {
@@ -438,25 +491,21 @@
   }
 
   public static void setCommentRevId(Comment c, PatchListCache cache, Change change, PatchSet ps)
-      throws OrmException {
+      throws PatchListNotAvailableException {
     checkArgument(
         c.key.patchSetId == ps.getId().get(),
         "cannot set RevId for patch set %s on comment %s",
         ps.getId(),
         c);
     if (c.revId == null) {
-      try {
-        if (Side.fromShort(c.side) == Side.PARENT) {
-          if (c.side < 0) {
-            c.revId = ObjectId.toString(cache.getOldId(change, ps, -c.side));
-          } else {
-            c.revId = ObjectId.toString(cache.getOldId(change, ps, null));
-          }
+      if (Side.fromShort(c.side) == Side.PARENT) {
+        if (c.side < 0) {
+          c.revId = ObjectId.toString(cache.getOldId(change, ps, -c.side));
         } else {
-          c.revId = ps.getRevision().get();
+          c.revId = ObjectId.toString(cache.getOldId(change, ps, null));
         }
-      } catch (PatchListNotAvailableException e) {
-        throw new OrmException(e);
+      } else {
+        c.revId = ps.getRevision().get();
       }
     }
   }
@@ -500,4 +549,39 @@
     return COMMENT_ORDER.sortedCopy(
         FluentIterable.from(comments).transform(plc -> plc.asComment(serverId)));
   }
+
+  public void publish(
+      ChangeContext ctx, PatchSet.Id psId, Collection<Comment> drafts, @Nullable String tag)
+      throws OrmException {
+    ChangeNotes notes = ctx.getNotes();
+    checkArgument(notes != null);
+    if (drafts.isEmpty()) {
+      return;
+    }
+
+    Map<PatchSet.Id, PatchSet> patchSets =
+        psUtil.getAsMap(
+            ctx.getDb(), notes, drafts.stream().map(d -> psId(notes, d)).collect(toSet()));
+    for (Comment d : drafts) {
+      PatchSet ps = patchSets.get(psId(notes, d));
+      if (ps == null) {
+        throw new OrmException("patch set " + ps + " not found");
+      }
+      d.writtenOn = ctx.getWhen();
+      d.tag = tag;
+      // Draft may have been created by a different real user; copy the current real user. (Only
+      // applies to X-Gerrit-RunAs, since modifying drafts via on_behalf_of is not allowed.)
+      ctx.getUser().updateRealAccountId(d::setRealAuthor);
+      try {
+        setCommentRevId(d, patchListCache, notes.getChange(), ps);
+      } catch (PatchListNotAvailableException e) {
+        throw new OrmException(e);
+      }
+    }
+    putComments(ctx.getDb(), ctx.getUpdate(psId), PUBLISHED, drafts);
+  }
+
+  private static PatchSet.Id psId(ChangeNotes notes, Comment c) {
+    return new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
index c6f10d2..0959e04 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
@@ -16,9 +16,8 @@
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.CapabilityControl;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.inject.servlet.RequestScoped;
 import java.util.function.Consumer;
 
@@ -40,16 +39,9 @@
     private PropertyKey() {}
   }
 
-  private final CapabilityControl.Factory capabilityControlFactory;
   private AccessPath accessPath = AccessPath.UNKNOWN;
-
-  private CapabilityControl capabilities;
   private PropertyKey<ExternalId.Key> lastLoginExternalIdPropertyKey = PropertyKey.create();
 
-  protected CurrentUser(CapabilityControl.Factory capabilityControlFactory) {
-    this.capabilityControlFactory = capabilityControlFactory;
-  }
-
   /** How this user is accessing the Gerrit Code Review application. */
   public final AccessPath getAccessPath() {
     return accessPath;
@@ -71,6 +63,10 @@
     return this;
   }
 
+  public boolean isImpersonating() {
+    return false;
+  }
+
   /**
    * If the {@link #getRealUser()} has an account ID associated with it, call the given setter with
    * that ID.
@@ -98,14 +94,6 @@
     return null;
   }
 
-  /** Capabilities available to this user account. */
-  public CapabilityControl getCapabilities() {
-    if (capabilities == null) {
-      capabilities = capabilityControlFactory.create(this);
-    }
-    return capabilities;
-  }
-
   /** Check if user is the IdentifiedUser */
   public boolean isIdentifiedUser() {
     return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java
new file mode 100644
index 0000000..3759f09
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/DynamicOptions.java
@@ -0,0 +1,294 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.plugins.DelegatingClassLoader;
+import com.google.gerrit.util.cli.CmdLineParser;
+import com.google.inject.Injector;
+import com.google.inject.Module;
+import com.google.inject.Provider;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.WeakHashMap;
+
+/** Helper class to define and parse options from plugins on ssh and RestAPI commands. */
+public class DynamicOptions {
+  /**
+   * To provide additional options, bind a DynamicBean. For example:
+   *
+   * <pre>
+   *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+   *       .annotatedWith(Exports.named(com.google.gerrit.sshd.commands.Query.class))
+   *       .to(MyOptions.class);
+   * </pre>
+   *
+   * To define the additional options, implement this interface. For example:
+   *
+   * <pre>
+   *   public class MyOptions implements DynamicOptions.DynamicBean {
+   *     {@literal @}Option(name = "--verbose", aliases = {"-v"}
+   *             usage = "Make the operation more talkative")
+   *     public boolean verbose;
+   *   }
+   * </pre>
+   *
+   * The option will be prefixed by the plugin name. In the example above, if the plugin name was
+   * my-plugin, then the --verbose option as used by the caller would be --my-plugin--verbose.
+   */
+  public interface DynamicBean {}
+
+  /**
+   * To provide additional options to a command in another classloader, bind a ClassNameProvider
+   * which provides the name of your DynamicBean in the other classLoader.
+   *
+   * <p>Do this by binding to just the name of the command you are going to bind to so that your
+   * classLoader does not load the command's class which likely is not in your classpath. To ensure
+   * that the command's class is not in your classpath, you can exclude it during your build.
+   *
+   * <p>For example:
+   *
+   * <pre>
+   *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+   *       .annotatedWith(Exports.named( "com.google.gerrit.plugins.otherplugin.command"))
+   *       .to(MyOptionsClassNameProvider.class);
+   *
+   *   static class MyOptionsClassNameProvider implements DynamicOptions.ClassNameProvider {
+   *     @Override
+   *     public String getClassName() {
+   *       return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
+   *     }
+   *   }
+   * </pre>
+   */
+  public interface ClassNameProvider extends DynamicBean {
+    String getClassName();
+  }
+
+  /**
+   * To provide additional Guice bindings for options to a command in another classloader, bind a
+   * ModulesClassNamesProvider which provides the name of your Modules needed for your DynamicBean
+   * in the other classLoader.
+   *
+   * <p>Do this by binding to the name of the command you are going to bind to and providing an
+   * Iterable of Module names to instantiate and add to the Injector used to instantiate the
+   * DynamicBean in the other classLoader. For example:
+   *
+   * <pre>
+   *   bind(com.google.gerrit.server.DynamicOptions.DynamicBean.class)
+   *       .annotatedWith(Exports.named(
+   *           "com.google.gerrit.plugins.otherplugin.command"))
+   *       .to(MyOptionsModulesClassNamesProvider.class);
+   *
+   *   static class MyOptionsModulesClassNamesProvider implements DynamicOptions.ClassNameProvider {
+   *     @Override
+   *     public String getClassName() {
+   *       return "com.googlesource.gerrit.plugins.myplugin.CommandOptions";
+   *     }
+   *     @Override
+   *     public Iterable<String> getModulesClassNames()() {
+   *       return "com.googlesource.gerrit.plugins.myplugin.MyOptionsModule";
+   *     }
+   *   }
+   * </pre>
+   */
+  public interface ModulesClassNamesProvider extends ClassNameProvider {
+    Iterable<String> getModulesClassNames();
+  }
+
+  /**
+   * Implement this if your DynamicBean needs an opportunity to act on the Bean directly before or
+   * after argument parsing.
+   */
+  public interface BeanParseListener extends DynamicBean {
+    void onBeanParseStart(String plugin, Object bean);
+
+    void onBeanParseEnd(String plugin, Object bean);
+  }
+
+  /**
+   * The entity which provided additional options may need a way to receive a reference to the
+   * DynamicBean it provided. To do so, the existing class should implement BeanReceiver (a setter)
+   * and then provide some way for the plugin to request its DynamicBean (a getter.) For example:
+   *
+   * <pre>
+   *   public class Query extends SshCommand implements DynamicOptions.BeanReceiver {
+   *       public void setDynamicBean(String plugin, DynamicOptions.DynamicBean dynamicBean) {
+   *         dynamicBeans.put(plugin, dynamicBean);
+   *       }
+   *
+   *       public DynamicOptions.DynamicBean getDynamicBean(String plugin) {
+   *         return dynamicBeans.get(plugin);
+   *       }
+   *   ...
+   *   }
+   * }
+   * </pre>
+   */
+  public interface BeanReceiver {
+    void setDynamicBean(String plugin, DynamicBean dynamicBean);
+  }
+
+  /**
+   * MergedClassloaders allow us to load classes from both plugin classloaders. Store the merged
+   * classloaders in a Map to avoid creating a new classloader for each invocation. Use a
+   * WeakHashMap to avoid leaking these MergedClassLoaders once either plugin is unloaded. Since the
+   * WeakHashMap only takes care of ensuring the Keys can get garbage collected, use WeakReferences
+   * to store the MergedClassloaders in the WeakHashMap.
+   *
+   * <p>Outter keys are the bean plugin's classloaders (the plugin being extended)
+   *
+   * <p>Inner keys are the dynamicBeans plugin's classloaders (the extending plugin)
+   *
+   * <p>The value is the MergedClassLoader representing the merging of the outter and inner key
+   * classloaders.
+   */
+  protected static Map<ClassLoader, Map<ClassLoader, WeakReference<ClassLoader>>> mergedClByCls =
+      Collections.synchronizedMap(
+          new WeakHashMap<ClassLoader, Map<ClassLoader, WeakReference<ClassLoader>>>());
+
+  protected Object bean;
+  protected Map<String, DynamicBean> beansByPlugin;
+  protected Injector injector;
+
+  /**
+   * Internal: For Gerrit to include options from DynamicBeans, setup a DynamicMap and instantiate
+   * this class so the following methods can be called if desired:
+   *
+   * <pre>
+   *    DynamicOptions pluginOptions = new DynamicOptions(bean, injector, dynamicBeans);
+   *    pluginOptions.parseDynamicBeans(clp);
+   *    pluginOptions.setDynamicBeans();
+   *    pluginOptions.onBeanParseStart();
+   *
+   *    // parse arguments here:  clp.parseArgument(argv);
+   *
+   *    pluginOptions.onBeanParseEnd();
+   * </pre>
+   */
+  public DynamicOptions(Object bean, Injector injector, DynamicMap<DynamicBean> dynamicBeans) {
+    this.bean = bean;
+    this.injector = injector;
+    beansByPlugin = new HashMap<>();
+    for (String plugin : dynamicBeans.plugins()) {
+      Provider<DynamicBean> provider =
+          dynamicBeans.byPlugin(plugin).get(bean.getClass().getCanonicalName());
+      if (provider != null) {
+        beansByPlugin.put(plugin, getDynamicBean(bean, provider.get()));
+      }
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  public DynamicBean getDynamicBean(Object bean, DynamicBean dynamicBean) {
+    ClassLoader coreCl = getClass().getClassLoader();
+    ClassLoader beanCl = bean.getClass().getClassLoader();
+
+    ClassLoader loader = beanCl;
+    if (beanCl != coreCl) { // bean from a plugin?
+      ClassLoader dynamicBeanCl = dynamicBean.getClass().getClassLoader();
+      if (beanCl != dynamicBeanCl) { // in a different plugin?
+        loader = getMergedClassLoader(beanCl, dynamicBeanCl);
+      }
+    }
+
+    String className = null;
+    if (dynamicBean instanceof ClassNameProvider) {
+      className = ((ClassNameProvider) dynamicBean).getClassName();
+    } else if (loader != beanCl) { // in a different plugin?
+      className = dynamicBean.getClass().getCanonicalName();
+    }
+
+    if (className != null) {
+      try {
+        List<Module> modules = new ArrayList<>();
+        Injector modulesInjector = injector;
+        if (dynamicBean instanceof ModulesClassNamesProvider) {
+          modulesInjector = injector.createChildInjector();
+          for (String moduleName :
+              ((ModulesClassNamesProvider) dynamicBean).getModulesClassNames()) {
+            Class<Module> mClass = (Class<Module>) loader.loadClass(moduleName);
+            modules.add(modulesInjector.getInstance(mClass));
+          }
+        }
+        return modulesInjector
+            .createChildInjector(modules)
+            .getInstance((Class<DynamicOptions.DynamicBean>) loader.loadClass(className));
+      } catch (ClassNotFoundException e) {
+        throw new RuntimeException(e);
+      }
+    }
+
+    return dynamicBean;
+  }
+
+  protected ClassLoader getMergedClassLoader(ClassLoader beanCl, ClassLoader dynamicBeanCl) {
+    Map<ClassLoader, WeakReference<ClassLoader>> mergedClByCl = mergedClByCls.get(beanCl);
+    if (mergedClByCl == null) {
+      mergedClByCl = Collections.synchronizedMap(new WeakHashMap<>());
+      mergedClByCls.put(beanCl, mergedClByCl);
+    }
+    WeakReference<ClassLoader> mergedClRef = mergedClByCl.get(dynamicBeanCl);
+    ClassLoader mergedCl = null;
+    if (mergedClRef != null) {
+      mergedCl = mergedClRef.get();
+    }
+    if (mergedCl == null) {
+      mergedCl = new DelegatingClassLoader(beanCl, dynamicBeanCl);
+      mergedClByCl.put(dynamicBeanCl, new WeakReference<>(mergedCl));
+    }
+    return mergedCl;
+  }
+
+  public void parseDynamicBeans(CmdLineParser clp) {
+    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+      clp.parseWithPrefix("--" + e.getKey(), e.getValue());
+    }
+  }
+
+  public void setDynamicBeans() {
+    if (bean instanceof BeanReceiver) {
+      BeanReceiver receiver = (BeanReceiver) bean;
+      for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+        receiver.setDynamicBean(e.getKey(), e.getValue());
+      }
+    }
+  }
+
+  public void onBeanParseStart() {
+    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+      DynamicBean instance = e.getValue();
+      if (instance instanceof BeanParseListener) {
+        BeanParseListener listener = (BeanParseListener) instance;
+        listener.onBeanParseStart(e.getKey(), bean);
+      }
+    }
+  }
+
+  public void onBeanParseEnd() {
+    for (Entry<String, DynamicBean> e : beansByPlugin.entrySet()) {
+      DynamicBean instance = e.getValue();
+      if (instance instanceof BeanParseListener) {
+        BeanParseListener listener = (BeanParseListener) instance;
+        listener.onBeanParseEnd(e.getKey(), bean);
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdentProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdentProvider.java
index 95e9813c..3ec07bd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdentProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/GerritPersonIdentProvider.java
@@ -31,7 +31,7 @@
   private final String email;
 
   @Inject
-  public GerritPersonIdentProvider(@GerritServerConfig final Config cfg) {
+  public GerritPersonIdentProvider(@GerritServerConfig Config cfg) {
     StringBuilder name = new StringBuilder();
     PersonIdent.appendSanitized(
         name, firstNonNull(cfg.getString("user", null, "name"), "Gerrit Code Review"));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
index 41b7c67..37f43a0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/IdentifiedUser.java
@@ -21,7 +21,6 @@
 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.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
@@ -55,7 +54,6 @@
   /** Create an IdentifiedUser, ignoring any per-request state. */
   @Singleton
   public static class GenericFactory {
-    private final CapabilityControl.Factory capabilityControlFactory;
     private final AuthConfig authConfig;
     private final Realm realm;
     private final String anonymousCowardName;
@@ -66,7 +64,6 @@
 
     @Inject
     public GenericFactory(
-        @Nullable CapabilityControl.Factory capabilityControlFactory,
         AuthConfig authConfig,
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
@@ -74,7 +71,6 @@
         @DisableReverseDnsLookup Boolean disableReverseDnsLookup,
         AccountCache accountCache,
         GroupBackend groupBackend) {
-      this.capabilityControlFactory = capabilityControlFactory;
       this.authConfig = authConfig;
       this.realm = realm;
       this.anonymousCowardName = anonymousCowardName;
@@ -86,7 +82,6 @@
 
     public IdentifiedUser create(AccountState state) {
       return new IdentifiedUser(
-          capabilityControlFactory,
           authConfig,
           realm,
           anonymousCowardName,
@@ -110,7 +105,6 @@
     public IdentifiedUser runAs(
         SocketAddress remotePeer, Account.Id id, @Nullable CurrentUser caller) {
       return new IdentifiedUser(
-          capabilityControlFactory,
           authConfig,
           realm,
           anonymousCowardName,
@@ -132,7 +126,6 @@
    */
   @Singleton
   public static class RequestFactory {
-    private final CapabilityControl.Factory capabilityControlFactory;
     private final AuthConfig authConfig;
     private final Realm realm;
     private final String anonymousCowardName;
@@ -144,7 +137,6 @@
 
     @Inject
     RequestFactory(
-        CapabilityControl.Factory capabilityControlFactory,
         AuthConfig authConfig,
         Realm realm,
         @AnonymousCowardName String anonymousCowardName,
@@ -153,7 +145,6 @@
         GroupBackend groupBackend,
         @DisableReverseDnsLookup Boolean disableReverseDnsLookup,
         @RemotePeer Provider<SocketAddress> remotePeerProvider) {
-      this.capabilityControlFactory = capabilityControlFactory;
       this.authConfig = authConfig;
       this.realm = realm;
       this.anonymousCowardName = anonymousCowardName;
@@ -166,7 +157,6 @@
 
     public IdentifiedUser create(Account.Id id) {
       return new IdentifiedUser(
-          capabilityControlFactory,
           authConfig,
           realm,
           anonymousCowardName,
@@ -181,7 +171,6 @@
 
     public IdentifiedUser runAs(Account.Id id, CurrentUser caller) {
       return new IdentifiedUser(
-          capabilityControlFactory,
           authConfig,
           realm,
           anonymousCowardName,
@@ -219,7 +208,6 @@
   private Map<PropertyKey<Object>, Object> properties;
 
   private IdentifiedUser(
-      CapabilityControl.Factory capabilityControlFactory,
       AuthConfig authConfig,
       Realm realm,
       String anonymousCowardName,
@@ -231,7 +219,6 @@
       AccountState state,
       @Nullable CurrentUser realUser) {
     this(
-        capabilityControlFactory,
         authConfig,
         realm,
         anonymousCowardName,
@@ -246,7 +233,6 @@
   }
 
   private IdentifiedUser(
-      CapabilityControl.Factory capabilityControlFactory,
       AuthConfig authConfig,
       Realm realm,
       String anonymousCowardName,
@@ -257,7 +243,6 @@
       @Nullable Provider<SocketAddress> remotePeerProvider,
       Account.Id id,
       @Nullable CurrentUser realUser) {
-    super(capabilityControlFactory);
     this.canonicalUrl = canonicalUrl;
     this.accountCache = accountCache;
     this.groupBackend = groupBackend;
@@ -275,6 +260,20 @@
     return realUser;
   }
 
+  @Override
+  public boolean isImpersonating() {
+    if (realUser == this) {
+      return false;
+    }
+    if (realUser.isIdentifiedUser()) {
+      if (realUser.getAccountId().equals(getAccountId())) {
+        // Impersonating another copy of this user is allowed.
+        return false;
+      }
+    }
+    return true;
+  }
+
   public AccountState state() {
     if (state == null) {
       state = accountCache.get(getAccountId());
@@ -349,7 +348,7 @@
     return newRefLogIdent(new Date(), TimeZone.getDefault());
   }
 
-  public PersonIdent newRefLogIdent(final Date when, final TimeZone tz) {
+  public PersonIdent newRefLogIdent(Date when, TimeZone tz) {
     final Account ua = getAccount();
 
     String name = ua.getFullName();
@@ -369,7 +368,7 @@
     return new PersonIdent(name, user + "@" + guessHost(), when, tz);
   }
 
-  public PersonIdent newCommitterIdent(final Date when, final TimeZone tz) {
+  public PersonIdent newCommitterIdent(Date when, TimeZone tz) {
     final Account ua = getAccount();
     String name = ua.getFullName();
     String email = ua.getPreferredEmail();
@@ -465,7 +464,6 @@
    * @return copy of the identified user
    */
   public IdentifiedUser materializedCopy() {
-    CapabilityControl capabilities = getCapabilities();
     Provider<SocketAddress> remotePeer;
     try {
       remotePeer = Providers.of(remotePeerProvider.get());
@@ -479,13 +477,6 @@
           };
     }
     return new IdentifiedUser(
-        new CapabilityControl.Factory() {
-
-          @Override
-          public CapabilityControl create(CurrentUser user) {
-            return capabilities;
-          }
-        },
         authConfig,
         realm,
         anonymousCowardName,
@@ -522,7 +513,7 @@
     return host;
   }
 
-  private String getHost(final InetAddress in) {
+  private String getHost(InetAddress in) {
     if (Boolean.FALSE.equals(disableReverseDnsLookup)) {
       return in.getCanonicalHostName();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java
index bc99ec1..821a0c6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/InternalUser.java
@@ -14,10 +14,7 @@
 
 package com.google.gerrit.server;
 
-import com.google.common.annotations.VisibleForTesting;
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
-import com.google.inject.Inject;
 
 /**
  * User identity for plugin code that needs an identity.
@@ -33,12 +30,6 @@
     InternalUser create();
   }
 
-  @VisibleForTesting
-  @Inject
-  public InternalUser(CapabilityControl.Factory capabilityControlFactory) {
-    super(capabilityControlFactory);
-  }
-
   @Override
   public GroupMembership getEffectiveGroups() {
     return GroupMembership.EMPTY;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
index ab942ca..82ca8d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PatchSetUtil.java
@@ -16,11 +16,16 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.notedb.PatchSetState.DRAFT;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.gerrit.server.ChangeUtil.PS_ID_ORDER;
 import static com.google.gerrit.server.notedb.PatchSetState.PUBLISHED;
+import static java.util.function.Function.identity;
 
 import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
@@ -28,7 +33,6 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.notedb.PatchSetState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -36,6 +40,7 @@
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevWalk;
 
@@ -63,8 +68,7 @@
   public ImmutableCollection<PatchSet> byChange(ReviewDb db, ChangeNotes notes)
       throws OrmException {
     if (!migration.readChanges()) {
-      return ChangeUtil.PS_ID_ORDER.immutableSortedCopy(
-          db.patchSets().byChange(notes.getChangeId()));
+      return PS_ID_ORDER.immutableSortedCopy(db.patchSets().byChange(notes.getChangeId()));
     }
     return notes.load().getPatchSets().values();
   }
@@ -73,8 +77,7 @@
       throws OrmException {
     if (!migration.readChanges()) {
       ImmutableMap.Builder<PatchSet.Id, PatchSet> result = ImmutableMap.builder();
-      for (PatchSet ps :
-          ChangeUtil.PS_ID_ORDER.sortedCopy(db.patchSets().byChange(notes.getChangeId()))) {
+      for (PatchSet ps : PS_ID_ORDER.sortedCopy(db.patchSets().byChange(notes.getChangeId()))) {
         result.put(ps.getId(), ps);
       }
       return result.build();
@@ -82,13 +85,23 @@
     return notes.load().getPatchSets();
   }
 
+  public ImmutableMap<PatchSet.Id, PatchSet> getAsMap(
+      ReviewDb db, ChangeNotes notes, Set<PatchSet.Id> patchSetIds) throws OrmException {
+    if (!migration.readChanges()) {
+      patchSetIds = Sets.filter(patchSetIds, p -> p.getParentKey().equals(notes.getChangeId()));
+      return Streams.stream(db.patchSets().get(patchSetIds))
+          .sorted(PS_ID_ORDER)
+          .collect(toImmutableMap(PatchSet::getId, identity()));
+    }
+    return ImmutableMap.copyOf(Maps.filterKeys(notes.load().getPatchSets(), patchSetIds::contains));
+  }
+
   public PatchSet insert(
       ReviewDb db,
       RevWalk rw,
       ChangeUpdate update,
       PatchSet.Id psId,
       ObjectId commit,
-      boolean draft,
       List<String> groups,
       String pushCertificate,
       String description)
@@ -100,7 +113,6 @@
     ps.setRevision(new RevId(commit.name()));
     ps.setUploader(update.getAccountId());
     ps.setCreatedOn(new Timestamp(update.getWhen().getTime()));
-    ps.setDraft(draft);
     ps.setGroups(groups);
     ps.setPushCertificate(pushCertificate);
     ps.setDescription(description);
@@ -109,27 +121,16 @@
     update.setCommit(rw, commit, pushCertificate);
     update.setPsDescription(description);
     update.setGroups(groups);
-    if (draft) {
-      update.setPatchSetState(DRAFT);
-    }
 
     return ps;
   }
 
   public void publish(ReviewDb db, ChangeUpdate update, PatchSet ps) throws OrmException {
     ensurePatchSetMatches(ps.getId(), update);
-    ps.setDraft(false);
     update.setPatchSetState(PUBLISHED);
     db.patchSets().update(Collections.singleton(ps));
   }
 
-  public void delete(ReviewDb db, ChangeUpdate update, PatchSet ps) throws OrmException {
-    ensurePatchSetMatches(ps.getId(), update);
-    checkArgument(ps.isDraft(), "cannot delete non-draft patch set %s", ps.getId());
-    update.setPatchSetState(PatchSetState.DELETED);
-    db.patchSets().delete(Collections.singleton(ps));
-  }
-
   private void ensurePatchSetMatches(PatchSet.Id psId, ChangeUpdate update) {
     Change.Id changeId = update.getChange().getId();
     checkArgument(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java
index 263bb50..8a8b67a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PeerDaemonUser.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -32,9 +31,7 @@
   private final SocketAddress peer;
 
   @Inject
-  protected PeerDaemonUser(
-      CapabilityControl.Factory capabilityControlFactory, @Assisted SocketAddress peer) {
-    super(capabilityControlFactory);
+  protected PeerDaemonUser(@Assisted SocketAddress peer) {
     this.peer = peer;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/PluginUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/PluginUser.java
index 13e04c5..09f5043 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/PluginUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/PluginUser.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server;
 
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -27,9 +26,7 @@
   private final String pluginName;
 
   @Inject
-  protected PluginUser(
-      CapabilityControl.Factory capabilityControlFactory, @Assisted String pluginName) {
-    super(capabilityControlFactory);
+  protected PluginUser(@Assisted String pluginName) {
     this.pluginName = pluginName;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ProjectUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ProjectUtil.java
index f3ab21d..1a327db 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ProjectUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ProjectUtil.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server;
 
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -32,13 +33,13 @@
    * @throws RepositoryNotFoundException the repository of the branch's project does not exist.
    * @throws IOException error while retrieving the branch from the repository.
    */
-  public static boolean branchExists(
-      final GitRepositoryManager repoManager, final Branch.NameKey branch)
+  public static boolean branchExists(final GitRepositoryManager repoManager, Branch.NameKey branch)
       throws RepositoryNotFoundException, IOException {
     try (Repository repo = repoManager.openRepository(branch.getParentKey())) {
       boolean exists = repo.getRefDatabase().exactRef(branch.get()) != null;
       if (!exists) {
-        exists = repo.getFullBranch().equals(branch.get());
+        exists =
+            repo.getFullBranch().equals(branch.get()) || RefNames.REFS_CONFIG.equals(branch.get());
       }
       return exists;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/RequestCleanup.java b/gerrit-server/src/main/java/com/google/gerrit/server/RequestCleanup.java
index 72b361c..ea60682 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/RequestCleanup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/RequestCleanup.java
@@ -30,7 +30,7 @@
   private boolean ran;
 
   /** Register a task to be completed after the request ends. */
-  public void add(final Runnable task) {
+  public void add(Runnable task) {
     synchronized (cleanup) {
       if (ran) {
         throw new IllegalStateException("Request has already been cleaned up");
@@ -43,7 +43,7 @@
   public void run() {
     synchronized (cleanup) {
       ran = true;
-      for (final Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
+      for (Iterator<Runnable> i = cleanup.iterator(); i.hasNext(); ) {
         try {
           i.next().run();
         } catch (Throwable err) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java
new file mode 100644
index 0000000..c16c9c8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerByEmailSet.java
@@ -0,0 +1,79 @@
+// 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;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
+import com.google.common.collect.Table;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import java.sql.Timestamp;
+
+/**
+ * Set of reviewers on a change that do not have a Gerrit account and were added by email instead.
+ *
+ * <p>A given account may appear in multiple states and at different timestamps. No reviewers with
+ * state {@link ReviewerStateInternal#REMOVED} are ever exposed by this interface.
+ */
+public class ReviewerByEmailSet {
+  private static final ReviewerByEmailSet EMPTY = new ReviewerByEmailSet(ImmutableTable.of());
+
+  public static ReviewerByEmailSet fromTable(
+      Table<ReviewerStateInternal, Address, Timestamp> table) {
+    return new ReviewerByEmailSet(table);
+  }
+
+  public static ReviewerByEmailSet empty() {
+    return EMPTY;
+  }
+
+  private final ImmutableTable<ReviewerStateInternal, Address, Timestamp> table;
+  private ImmutableSet<Address> users;
+
+  private ReviewerByEmailSet(Table<ReviewerStateInternal, Address, Timestamp> table) {
+    this.table = ImmutableTable.copyOf(table);
+  }
+
+  public ImmutableSet<Address> all() {
+    if (users == null) {
+      // Idempotent and immutable, don't bother locking.
+      users = ImmutableSet.copyOf(table.columnKeySet());
+    }
+    return users;
+  }
+
+  public ImmutableSet<Address> byState(ReviewerStateInternal state) {
+    return table.row(state).keySet();
+  }
+
+  public ImmutableTable<ReviewerStateInternal, Address, Timestamp> asTable() {
+    return table;
+  }
+
+  @Override
+  public boolean equals(Object o) {
+    return (o instanceof ReviewerByEmailSet) && table.equals(((ReviewerByEmailSet) o).table);
+  }
+
+  @Override
+  public int hashCode() {
+    return table.hashCode();
+  }
+
+  @Override
+  public String toString() {
+    return getClass().getSimpleName() + table;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
index 27af787..5b3f093 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
@@ -23,6 +23,8 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.extensions.registration.DynamicMap;
+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.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -35,15 +37,14 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.EnumSet;
@@ -60,6 +61,7 @@
 import java.util.concurrent.TimeUnit;
 import java.util.stream.Stream;
 import org.apache.commons.lang.mutable.MutableDouble;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -78,7 +80,7 @@
   private final ChangeQueryBuilder changeQueryBuilder;
   private final Config config;
   private final DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap;
-  private final InternalChangeQuery internalChangeQuery;
+  private final Provider<InternalChangeQuery> queryProvider;
   private final WorkQueue workQueue;
   private final Provider<ReviewDb> dbProvider;
   private final ApprovalsUtil approvalsUtil;
@@ -87,7 +89,7 @@
   ReviewerRecommender(
       ChangeQueryBuilder changeQueryBuilder,
       DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap,
-      InternalChangeQuery internalChangeQuery,
+      Provider<InternalChangeQuery> queryProvider,
       WorkQueue workQueue,
       Provider<ReviewDb> dbProvider,
       ApprovalsUtil approvalsUtil,
@@ -96,7 +98,7 @@
     fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
     this.changeQueryBuilder = changeQueryBuilder;
     this.config = config;
-    this.internalChangeQuery = internalChangeQuery;
+    this.queryProvider = queryProvider;
     this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
     this.workQueue = workQueue;
     this.dbProvider = dbProvider;
@@ -106,9 +108,9 @@
   public List<Account.Id> suggestReviewers(
       ChangeNotes changeNotes,
       SuggestReviewers suggestReviewers,
-      ProjectControl projectControl,
+      ProjectState projectState,
       List<Account.Id> candidateList)
-      throws OrmException {
+      throws OrmException, IOException, ConfigInvalidException {
     String query = suggestReviewers.getQuery();
     double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
 
@@ -116,7 +118,7 @@
     if (Strings.isNullOrEmpty(query)) {
       reviewerScores = baseRankingForEmptyQuery(baseWeight);
     } else {
-      reviewerScores = baseRankingForCandidateList(candidateList, projectControl, baseWeight);
+      reviewerScores = baseRankingForCandidateList(candidateList, projectState, baseWeight);
     }
 
     // Send the query along with a candidate list to all plugins and merge the
@@ -133,7 +135,7 @@
                   .getProvider()
                   .get()
                   .suggestReviewers(
-                      projectControl.getProject().getNameKey(),
+                      projectState.getNameKey(),
                       changeNotes != null ? changeNotes.getChangeId() : null,
                       query,
                       reviewerScores.keySet()));
@@ -191,13 +193,14 @@
   }
 
   private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(double baseWeight)
-      throws OrmException {
+      throws OrmException, IOException, ConfigInvalidException {
     // Get the user's last 25 changes, check approvals
     try {
       List<ChangeData> result =
-          internalChangeQuery
+          queryProvider
+              .get()
               .setLimit(25)
-              .setRequestedFields(ImmutableSet.of(ChangeField.REVIEWER.getName()))
+              .setRequestedFields(ImmutableSet.of(ChangeField.APPROVAL.getName()))
               .query(changeQueryBuilder.owner("self"));
       Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
       for (ChangeData cd : result) {
@@ -219,8 +222,8 @@
   }
 
   private Map<Account.Id, MutableDouble> baseRankingForCandidateList(
-      List<Account.Id> candidates, ProjectControl projectControl, double baseWeight)
-      throws OrmException {
+      List<Account.Id> candidates, ProjectState projectState, double baseWeight)
+      throws OrmException, IOException, ConfigInvalidException {
     // Get each reviewer's activity based on number of applied labels
     // (weighted 10d), number of comments (weighted 0.5d) and number of owned
     // changes (weighted 1d).
@@ -231,12 +234,11 @@
     List<Predicate<ChangeData>> predicates = new ArrayList<>();
     for (Account.Id id : candidates) {
       try {
-        Predicate<ChangeData> projectQuery =
-            changeQueryBuilder.project(projectControl.getProject().getName());
+        Predicate<ChangeData> projectQuery = changeQueryBuilder.project(projectState.getName());
 
         // Get all labels for this project and create a compound OR query to
         // fetch all changes where users have applied one of these labels
-        List<LabelType> labelTypes = projectControl.getLabelTypes().getLabelTypes();
+        List<LabelType> labelTypes = projectState.getLabelTypes().getLabelTypes();
         List<Predicate<ChangeData>> labelPredicates = new ArrayList<>(labelTypes.size());
         for (LabelType type : labelTypes) {
           labelPredicates.add(changeQueryBuilder.label(type.getName() + ",user=" + id));
@@ -261,7 +263,7 @@
     }
 
     List<List<ChangeData>> result =
-        internalChangeQuery.setLimit(25).setRequestedFields(ImmutableSet.of()).query(predicates);
+        queryProvider.get().setLimit(25).setRequestedFields(ImmutableSet.of()).query(predicates);
 
     Iterator<List<ChangeData>> queryResultIterator = result.iterator();
     Iterator<Account.Id> reviewersIterator = reviewers.keySet().iterator();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
index 410dc5c..ee25d54 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.extensions.common.GroupBaseInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
@@ -40,9 +42,8 @@
 import com.google.gerrit.server.change.SuggestReviewers;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.QueryResult;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.account.AccountPredicates;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
 import com.google.gerrit.server.query.account.AccountQueryProcessor;
 import com.google.gwtorm.server.OrmException;
@@ -56,6 +57,7 @@
 import java.util.List;
 import java.util.Objects;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class ReviewersUtil {
   @Singleton
@@ -107,7 +109,7 @@
 
   private final AccountLoader accountLoader;
   private final AccountQueryBuilder accountQueryBuilder;
-  private final AccountQueryProcessor accountQueryProcessor;
+  private final Provider<AccountQueryProcessor> queryProvider;
   private final GroupBackend groupBackend;
   private final GroupMembers.Factory groupMembersFactory;
   private final Provider<CurrentUser> currentUser;
@@ -118,7 +120,7 @@
   ReviewersUtil(
       AccountLoader.Factory accountLoaderFactory,
       AccountQueryBuilder accountQueryBuilder,
-      AccountQueryProcessor accountQueryProcessor,
+      Provider<AccountQueryProcessor> queryProvider,
       GroupBackend groupBackend,
       GroupMembers.Factory groupMembersFactory,
       Provider<CurrentUser> currentUser,
@@ -128,7 +130,7 @@
     fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
     this.accountLoader = accountLoaderFactory.create(fillOptions);
     this.accountQueryBuilder = accountQueryBuilder;
-    this.accountQueryProcessor = accountQueryProcessor;
+    this.queryProvider = queryProvider;
     this.currentUser = currentUser;
     this.groupBackend = groupBackend;
     this.groupMembersFactory = groupMembersFactory;
@@ -143,10 +145,10 @@
   public List<SuggestedReviewerInfo> suggestReviewers(
       ChangeNotes changeNotes,
       SuggestReviewers suggestReviewers,
-      ProjectControl projectControl,
+      ProjectState projectState,
       VisibilityControl visibilityControl,
       boolean excludeGroups)
-      throws IOException, OrmException {
+      throws IOException, OrmException, ConfigInvalidException {
     String query = suggestReviewers.getQuery();
     int limit = suggestReviewers.getLimit();
 
@@ -160,7 +162,7 @@
     }
 
     List<Account.Id> sortedRecommendations =
-        recommendAccounts(changeNotes, suggestReviewers, projectControl, candidateList);
+        recommendAccounts(changeNotes, suggestReviewers, projectState, candidateList);
 
     // Filter accounts by visibility and enforce limit
     List<Account.Id> filteredRecommendations = new ArrayList<>();
@@ -181,10 +183,7 @@
       // important.
       suggestedReviewer.addAll(
           suggestAccountGroups(
-              suggestReviewers,
-              projectControl,
-              visibilityControl,
-              limit - suggestedReviewer.size()));
+              suggestReviewers, projectState, visibilityControl, limit - suggestedReviewer.size()));
     }
 
     if (suggestedReviewer.size() <= limit) {
@@ -197,9 +196,12 @@
     try (Timer0.Context ctx = metrics.queryAccountsLatency.start()) {
       try {
         QueryResult<AccountState> result =
-            accountQueryProcessor
-                .setLimit(suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER)
-                .query(accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
+            queryProvider
+                .get()
+                .setUserProvidedLimit(suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER)
+                .query(
+                    AccountPredicates.andActive(
+                        accountQueryBuilder.defaultQuery(suggestReviewers.getQuery())));
         return result.entities().stream().map(a -> a.getAccount().getId()).collect(toList());
       } catch (QueryParseException e) {
         return ImmutableList.of();
@@ -210,12 +212,12 @@
   private List<Account.Id> recommendAccounts(
       ChangeNotes changeNotes,
       SuggestReviewers suggestReviewers,
-      ProjectControl projectControl,
+      ProjectState projectState,
       List<Account.Id> candidateList)
-      throws OrmException {
+      throws OrmException, IOException, ConfigInvalidException {
     try (Timer0.Context ctx = metrics.recommendAccountsLatency.start()) {
       return reviewerRecommender.suggestReviewers(
-          changeNotes, suggestReviewers, projectControl, candidateList);
+          changeNotes, suggestReviewers, projectState, candidateList);
     }
   }
 
@@ -242,16 +244,16 @@
 
   private List<SuggestedReviewerInfo> suggestAccountGroups(
       SuggestReviewers suggestReviewers,
-      ProjectControl projectControl,
+      ProjectState projectState,
       VisibilityControl visibilityControl,
       int limit)
       throws OrmException, IOException {
     try (Timer0.Context ctx = metrics.queryGroupsLatency.start()) {
       List<SuggestedReviewerInfo> groups = new ArrayList<>();
-      for (GroupReference g : suggestAccountGroups(suggestReviewers, projectControl)) {
+      for (GroupReference g : suggestAccountGroups(suggestReviewers, projectState)) {
         GroupAsReviewer result =
             suggestGroupAsReviewer(
-                suggestReviewers, projectControl.getProject(), g, visibilityControl);
+                suggestReviewers, projectState.getProject(), g, visibilityControl);
         if (result.allowed || result.allowedWithConfirmation) {
           GroupBaseInfo info = new GroupBaseInfo();
           info.id = Url.encode(g.getUUID().get());
@@ -273,10 +275,11 @@
   }
 
   private List<GroupReference> suggestAccountGroups(
-      SuggestReviewers suggestReviewers, ProjectControl ctl) {
+      SuggestReviewers suggestReviewers, ProjectState projectState) {
     return Lists.newArrayList(
         Iterables.limit(
-            groupBackend.suggest(suggestReviewers.getQuery(), ctl), suggestReviewers.getLimit()));
+            groupBackend.suggest(suggestReviewers.getQuery(), projectState),
+            suggestReviewers.getLimit()));
   }
 
   private static class GroupAsReviewer {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
index 4ab42f3..930f3f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/Sequences.java
@@ -18,9 +18,16 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.Field;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer2;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.notedb.RepoSequence;
@@ -32,50 +39,87 @@
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
 
-@SuppressWarnings("deprecation")
 @Singleton
 public class Sequences {
-  public static final String CHANGES = "changes";
+  public static final String NAME_ACCOUNTS = "accounts";
+  public static final String NAME_CHANGES = "changes";
+
+  public static int getChangeSequenceGap(Config cfg) {
+    return cfg.getInt("noteDb", "changes", "initialSequenceGap", 1000);
+  }
+
+  private enum SequenceType {
+    ACCOUNTS,
+    CHANGES;
+  }
 
   private final Provider<ReviewDb> db;
   private final NotesMigration migration;
+  private final RepoSequence accountSeq;
   private final RepoSequence changeSeq;
+  private final Timer2<SequenceType, Boolean> nextIdLatency;
 
   @Inject
   Sequences(
       @GerritServerConfig Config cfg,
-      final Provider<ReviewDb> db,
+      Provider<ReviewDb> db,
       NotesMigration migration,
       GitRepositoryManager repoManager,
-      AllProjectsName allProjects) {
+      GitReferenceUpdated gitRefUpdated,
+      AllProjectsName allProjects,
+      AllUsersName allUsers,
+      MetricMaker metrics) {
     this.db = db;
     this.migration = migration;
 
-    final int gap = cfg.getInt("noteDb", "changes", "initialSequenceGap", 0);
-    changeSeq =
+    int accountBatchSize = cfg.getInt("noteDb", "accounts", "sequenceBatchSize", 1);
+    accountSeq =
         new RepoSequence(
             repoManager,
-            allProjects,
-            CHANGES,
-            new RepoSequence.Seed() {
-              @Override
-              public int get() throws OrmException {
-                return db.get().nextChangeId() + gap;
-              }
-            },
-            cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20));
+            gitRefUpdated,
+            allUsers,
+            NAME_ACCOUNTS,
+            () -> ReviewDb.FIRST_ACCOUNT_ID,
+            accountBatchSize);
+
+    int gap = getChangeSequenceGap(cfg);
+    @SuppressWarnings("deprecation")
+    RepoSequence.Seed changeSeed = () -> db.get().nextChangeId() + gap;
+    int changeBatchSize = cfg.getInt("noteDb", "changes", "sequenceBatchSize", 20);
+    changeSeq =
+        new RepoSequence(
+            repoManager, gitRefUpdated, allProjects, NAME_CHANGES, changeSeed, changeBatchSize);
+
+    nextIdLatency =
+        metrics.newTimer(
+            "sequence/next_id_latency",
+            new Description("Latency of requesting IDs from repo sequences")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS),
+            Field.ofEnum(SequenceType.class, "sequence"),
+            Field.ofBoolean("multiple"));
+  }
+
+  public int nextAccountId() throws OrmException {
+    try (Timer2.Context timer = nextIdLatency.start(SequenceType.ACCOUNTS, false)) {
+      return accountSeq.next();
+    }
   }
 
   public int nextChangeId() throws OrmException {
     if (!migration.readChangeSequence()) {
-      return db.get().nextChangeId();
+      return nextChangeId(db.get());
     }
-    return changeSeq.next();
+    try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, false)) {
+      return changeSeq.next();
+    }
   }
 
   public ImmutableList<Integer> nextChangeIds(int count) throws OrmException {
     if (migration.readChangeSequence()) {
-      return changeSeq.next(count);
+      try (Timer2.Context timer = nextIdLatency.start(SequenceType.CHANGES, count > 1)) {
+        return changeSeq.next(count);
+      }
     }
 
     if (count == 0) {
@@ -85,7 +129,7 @@
     List<Integer> ids = new ArrayList<>(count);
     ReviewDb db = this.db.get();
     for (int i = 0; i < count; i++) {
-      ids.add(db.nextChangeId());
+      ids.add(nextChangeId(db));
     }
     return ImmutableList.copyOf(ids);
   }
@@ -94,4 +138,9 @@
   public RepoSequence getChangeIdRepoSequence() {
     return changeSeq;
   }
+
+  @SuppressWarnings("deprecation")
+  private static int nextChangeId(ReviewDb db) throws OrmException {
+    return db.nextChangeId();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
index d5f5dee..a999b95 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StarredChangesUtil.java
@@ -27,6 +27,7 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedSet;
+import com.google.common.collect.Sets;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
@@ -34,7 +35,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexer;
@@ -49,6 +52,7 @@
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.SortedSet;
 import java.util.TreeSet;
@@ -123,34 +127,44 @@
     }
   }
 
-  public static class IllegalLabelException extends IllegalArgumentException {
+  public static class IllegalLabelException extends Exception {
     private static final long serialVersionUID = 1L;
 
-    static IllegalLabelException invalidLabels(Set<String> invalidLabels) {
-      return new IllegalLabelException(
-          String.format("invalid labels: %s", Joiner.on(", ").join(invalidLabels)));
-    }
-
-    static IllegalLabelException mutuallyExclusiveLabels(String label1, String label2) {
-      return new IllegalLabelException(
-          String.format(
-              "The labels %s and %s are mutually exclusive. Only one of them can be set.",
-              label1, label2));
-    }
-
     IllegalLabelException(String message) {
       super(message);
     }
   }
 
+  public static class InvalidLabelsException extends IllegalLabelException {
+    private static final long serialVersionUID = 1L;
+
+    InvalidLabelsException(Set<String> invalidLabels) {
+      super(String.format("invalid labels: %s", Joiner.on(", ").join(invalidLabels)));
+    }
+  }
+
+  public static class MutuallyExclusiveLabelsException extends IllegalLabelException {
+    private static final long serialVersionUID = 1L;
+
+    MutuallyExclusiveLabelsException(String label1, String label2) {
+      super(
+          String.format(
+              "The labels %s and %s are mutually exclusive. Only one of them can be set.",
+              label1, label2));
+    }
+  }
+
   private static final Logger log = LoggerFactory.getLogger(StarredChangesUtil.class);
 
   public static final String DEFAULT_LABEL = "star";
   public static final String IGNORE_LABEL = "ignore";
+  public static final String REVIEWED_LABEL = "reviewed";
+  public static final String UNREVIEWED_LABEL = "unreviewed";
   public static final ImmutableSortedSet<String> DEFAULT_LABELS =
       ImmutableSortedSet.of(DEFAULT_LABEL);
 
   private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
   private final AllUsersName allUsers;
   private final Provider<ReviewDb> dbProvider;
   private final PersonIdent serverIdent;
@@ -160,12 +174,14 @@
   @Inject
   StarredChangesUtil(
       GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
       AllUsersName allUsers,
       Provider<ReviewDb> dbProvider,
       @GerritPersonIdent PersonIdent serverIdent,
       ChangeIndexer indexer,
       Provider<InternalChangeQuery> queryProvider) {
     this.repoManager = repoManager;
+    this.gitRefUpdated = gitRefUpdated;
     this.allUsers = allUsers;
     this.dbProvider = dbProvider;
     this.serverIdent = serverIdent;
@@ -192,7 +208,7 @@
       Change.Id changeId,
       Set<String> labelsToAdd,
       Set<String> labelsToRemove)
-      throws OrmException {
+      throws OrmException, IllegalLabelException {
     try (Repository repo = repoManager.openRepository(allUsers)) {
       String refName = RefNames.refsStarredChanges(changeId, accountId);
       StarRef old = readLabels(repo, refName);
@@ -266,49 +282,6 @@
     }
   }
 
-  public Set<Account.Id> byChange(final Change.Id changeId, final String label)
-      throws OrmException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getRefNames(repo, RefNames.refsStarredChangesPrefix(changeId))
-          .stream()
-          .map(Account.Id::parse)
-          .filter(accountId -> hasStar(repo, changeId, accountId, label))
-          .collect(toSet());
-    } catch (IOException e) {
-      throw new OrmException(
-          String.format("Get accounts that starred change %d failed", changeId.get()), e);
-    }
-  }
-
-  @Deprecated
-  // To be used only for IsStarredByLegacyPredicate.
-  public Set<Change.Id> byAccount(final Account.Id accountId, final String label)
-      throws OrmException {
-    try (Repository repo = repoManager.openRepository(allUsers)) {
-      return getRefNames(repo, RefNames.REFS_STARRED_CHANGES)
-          .stream()
-          .filter(refPart -> refPart.endsWith("/" + accountId.get()))
-          .map(Change.Id::fromRefPart)
-          .filter(changeId -> hasStar(repo, changeId, accountId, label))
-          .collect(toSet());
-    } catch (IOException e) {
-      throw new OrmException(
-          String.format("Get changes that were starred by %d failed", accountId.get()), e);
-    }
-  }
-
-  private boolean hasStar(Repository repo, Change.Id changeId, Account.Id accountId, String label) {
-    try {
-      return readLabels(repo, RefNames.refsStarredChanges(changeId, accountId))
-          .labels()
-          .contains(label);
-    } catch (IOException e) {
-      log.error(
-          "Cannot query stars by account {} on change {}", accountId.get(), changeId.get(), e);
-      return false;
-    }
-  }
-
   public ImmutableListMultimap<Account.Id, String> byChangeFromIndex(Change.Id changeId)
       throws OrmException {
     Set<String> fields = ImmutableSet.of(ChangeField.ID.getName(), ChangeField.STAR.getName());
@@ -339,7 +312,67 @@
     }
   }
 
-  private static StarRef readLabels(Repository repo, String refName) throws IOException {
+  public void ignore(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+    star(
+        rsrc.getUser().asIdentifiedUser().getAccountId(),
+        rsrc.getProject(),
+        rsrc.getChange().getId(),
+        ImmutableSet.of(IGNORE_LABEL),
+        ImmutableSet.of());
+  }
+
+  public void unignore(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+    star(
+        rsrc.getUser().asIdentifiedUser().getAccountId(),
+        rsrc.getProject(),
+        rsrc.getChange().getId(),
+        ImmutableSet.of(),
+        ImmutableSet.of(IGNORE_LABEL));
+  }
+
+  public boolean isIgnoredBy(Change.Id changeId, Account.Id accountId) throws OrmException {
+    return getLabels(accountId, changeId).contains(IGNORE_LABEL);
+  }
+
+  public boolean isIgnored(ChangeResource rsrc) throws OrmException {
+    return isIgnoredBy(rsrc.getChange().getId(), rsrc.getUser().asIdentifiedUser().getAccountId());
+  }
+
+  private static String getReviewedLabel(Change change) {
+    return getReviewedLabel(change.currentPatchSetId().get());
+  }
+
+  private static String getReviewedLabel(int ps) {
+    return REVIEWED_LABEL + "/" + ps;
+  }
+
+  private static String getUnreviewedLabel(Change change) {
+    return getUnreviewedLabel(change.currentPatchSetId().get());
+  }
+
+  private static String getUnreviewedLabel(int ps) {
+    return UNREVIEWED_LABEL + "/" + ps;
+  }
+
+  public void markAsReviewed(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+    star(
+        rsrc.getUser().asIdentifiedUser().getAccountId(),
+        rsrc.getProject(),
+        rsrc.getChange().getId(),
+        ImmutableSet.of(getReviewedLabel(rsrc.getChange())),
+        ImmutableSet.of(getUnreviewedLabel(rsrc.getChange())));
+  }
+
+  public void markAsUnreviewed(ChangeResource rsrc) throws OrmException, IllegalLabelException {
+    star(
+        rsrc.getUser().asIdentifiedUser().getAccountId(),
+        rsrc.getProject(),
+        rsrc.getChange().getId(),
+        ImmutableSet.of(getUnreviewedLabel(rsrc.getChange())),
+        ImmutableSet.of(getReviewedLabel(rsrc.getChange())));
+  }
+
+  public static StarRef readLabels(Repository repo, String refName) throws IOException {
     Ref ref = repo.exactRef(refName);
     if (ref == null) {
       return StarRef.MISSING;
@@ -356,7 +389,7 @@
   }
 
   public static ObjectId writeLabels(Repository repo, Collection<String> labels)
-      throws IOException {
+      throws IOException, InvalidLabelsException {
     validateLabels(labels);
     try (ObjectInserter oi = repo.newObjectInserter()) {
       ObjectId id =
@@ -368,13 +401,32 @@
     }
   }
 
-  private static void checkMutuallyExclusiveLabels(Set<String> labels) {
+  private static void checkMutuallyExclusiveLabels(Set<String> labels)
+      throws MutuallyExclusiveLabelsException {
     if (labels.containsAll(ImmutableSet.of(DEFAULT_LABEL, IGNORE_LABEL))) {
-      throw IllegalLabelException.mutuallyExclusiveLabels(DEFAULT_LABEL, IGNORE_LABEL);
+      throw new MutuallyExclusiveLabelsException(DEFAULT_LABEL, IGNORE_LABEL);
+    }
+
+    Set<Integer> reviewedPatchSets = getStarredPatchSets(labels, REVIEWED_LABEL);
+    Set<Integer> unreviewedPatchSets = getStarredPatchSets(labels, UNREVIEWED_LABEL);
+    Optional<Integer> ps =
+        Sets.intersection(reviewedPatchSets, unreviewedPatchSets).stream().findFirst();
+    if (ps.isPresent()) {
+      throw new MutuallyExclusiveLabelsException(
+          getReviewedLabel(ps.get()), getUnreviewedLabel(ps.get()));
     }
   }
 
-  private static void validateLabels(Collection<String> labels) {
+  public static Set<Integer> getStarredPatchSets(Set<String> labels, String label) {
+    return labels
+        .stream()
+        .filter(l -> l.startsWith(label))
+        .filter(l -> Ints.tryParse(l.substring(label.length() + 1)) != null)
+        .map(l -> Integer.valueOf(l.substring(label.length() + 1)))
+        .collect(toSet());
+  }
+
+  private static void validateLabels(Collection<String> labels) throws InvalidLabelsException {
     if (labels == null) {
       return;
     }
@@ -386,13 +438,13 @@
       }
     }
     if (!invalidLabels.isEmpty()) {
-      throw IllegalLabelException.invalidLabels(invalidLabels);
+      throw new InvalidLabelsException(invalidLabels);
     }
   }
 
   private void updateLabels(
       Repository repo, String refName, ObjectId oldObjectId, Collection<String> labels)
-      throws IOException, OrmException {
+      throws IOException, OrmException, InvalidLabelsException {
     try (RevWalk rw = new RevWalk(repo)) {
       RefUpdate u = repo.updateRef(refName);
       u.setExpectedOldObjectId(oldObjectId);
@@ -406,6 +458,7 @@
         case FORCED:
         case NO_CHANGE:
         case FAST_FORWARD:
+          gitRefUpdated.fire(allUsers, u, null);
           return;
         case IO_FAILURE:
         case LOCK_FAILURE:
@@ -413,6 +466,9 @@
         case REJECTED:
         case REJECTED_CURRENT_BRANCH:
         case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
+        default:
           throw new OrmException(
               String.format("Update star labels on ref %s failed: %s", refName, result.name()));
       }
@@ -421,6 +477,11 @@
 
   private void deleteRef(Repository repo, String refName, ObjectId oldObjectId)
       throws IOException, OrmException {
+    if (ObjectId.zeroId().equals(oldObjectId)) {
+      // ref doesn't exist
+      return;
+    }
+
     RefUpdate u = repo.updateRef(refName);
     u.setForceUpdate(true);
     u.setExpectedOldObjectId(oldObjectId);
@@ -429,6 +490,7 @@
     RefUpdate.Result result = u.delete();
     switch (result) {
       case FORCED:
+        gitRefUpdated.fire(allUsers, u, null);
         return;
       case NEW:
       case NO_CHANGE:
@@ -439,6 +501,9 @@
       case REJECTED:
       case REJECTED_CURRENT_BRANCH:
       case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
         throw new OrmException(
             String.format("Delete star ref %s failed: %s", refName, result.name()));
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
index 83b6ec6..891dec2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/StringUtil.java
@@ -33,7 +33,7 @@
    * hex escape (\x00, \x01, ...) or as a C-style escape sequence (\a, \b, \t, \n, \v, \f, or \r).
    * Backslashes in the input string are doubled (\\).
    */
-  public static String escapeString(final String str) {
+  public static String escapeString(String str) {
     // Allocate a buffer big enough to cover the case with a string needed
     // very excessive escaping without having to reallocate the buffer.
     final StringBuilder result = new StringBuilder(3 * str.length());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/UrlEncoded.java b/gerrit-server/src/main/java/com/google/gerrit/server/UrlEncoded.java
index adad11c..2b7b618 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/UrlEncoded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/UrlEncoded.java
@@ -25,7 +25,7 @@
 
   public UrlEncoded() {}
 
-  public UrlEncoded(final String url) {
+  public UrlEncoded(String url) {
     this.url = url;
   }
 
@@ -37,7 +37,7 @@
       separator = '?';
       buffer.append(url);
     }
-    for (final Map.Entry<String, String> entry : entrySet()) {
+    for (Map.Entry<String, String> entry : entrySet()) {
       final String key = entry.getKey();
       final String val = entry.getValue();
       if (separator != 0) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
index 4ca77bc..dacbe37 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/WebLinks.java
@@ -195,7 +195,7 @@
    * @param project Project name.
    * @return Links for projects.
    */
-  public List<WebLinkInfo> getProjectLinks(final String project) {
+  public List<WebLinkInfo> getProjectLinks(String project) {
     return filterLinks(projectLinks, webLink -> webLink.getProjectWeblink(project));
   }
 
@@ -204,7 +204,7 @@
    * @param branch Branch name
    * @return Links for branches.
    */
-  public List<WebLinkInfo> getBranchLinks(final String project, final String branch) {
+  public List<WebLinkInfo> getBranchLinks(String project, String branch) {
     return filterLinks(branchLinks, webLink -> webLink.getBranchWebLink(project, branch));
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
index 68e787d..a2cedbf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/access/ListAccess.java
@@ -20,7 +20,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.GetAccess;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
@@ -47,11 +49,11 @@
 
   @Override
   public Map<String, ProjectAccessInfo> apply(TopLevelResource resource)
-      throws ResourceNotFoundException, ResourceConflictException, IOException {
+      throws ResourceNotFoundException, ResourceConflictException, IOException,
+          PermissionBackendException, OrmException {
     Map<String, ProjectAccessInfo> access = new TreeMap<>();
     for (String p : projects) {
-      Project.NameKey projectName = new Project.NameKey(p);
-      access.put(p, getAccess.apply(projectName));
+      access.put(p, getAccess.apply(new Project.NameKey(p)));
     }
     return access;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
index 0f9ec8d..e61736d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AbstractRealm.java
@@ -18,6 +18,7 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.mail.send.EmailSender;
 import com.google.inject.Inject;
 import java.util.Collection;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
deleted file mode 100644
index e73d82b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCache.java
+++ /dev/null
@@ -1,25 +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.account;
-
-import com.google.gerrit.reviewdb.client.Account;
-import java.util.Set;
-
-/** Translates an email address to a set of matching accounts. */
-public interface AccountByEmailCache {
-  Set<Account.Id> get(String email);
-
-  void evict(String email);
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
deleted file mode 100644
index d45ecd8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountByEmailCacheImpl.java
+++ /dev/null
@@ -1,110 +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.account;
-
-import com.google.common.cache.CacheLoader;
-import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.cache.CacheModule;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gwtorm.server.SchemaFactory;
-import com.google.inject.Inject;
-import com.google.inject.Module;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
-import com.google.inject.name.Named;
-import java.util.Collections;
-import java.util.HashSet;
-import java.util.Set;
-import java.util.concurrent.ExecutionException;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Translates an email address to a set of matching accounts. */
-@Singleton
-public class AccountByEmailCacheImpl implements AccountByEmailCache {
-  private static final Logger log = LoggerFactory.getLogger(AccountByEmailCacheImpl.class);
-  private static final String CACHE_NAME = "accounts_byemail";
-
-  public static Module module() {
-    return new CacheModule() {
-      @Override
-      protected void configure() {
-        cache(CACHE_NAME, String.class, new TypeLiteral<Set<Account.Id>>() {}).loader(Loader.class);
-        bind(AccountByEmailCacheImpl.class);
-        bind(AccountByEmailCache.class).to(AccountByEmailCacheImpl.class);
-      }
-    };
-  }
-
-  private final LoadingCache<String, Set<Account.Id>> cache;
-
-  @Inject
-  AccountByEmailCacheImpl(@Named(CACHE_NAME) LoadingCache<String, Set<Account.Id>> cache) {
-    this.cache = cache;
-  }
-
-  @Override
-  public Set<Account.Id> get(final String email) {
-    try {
-      return cache.get(email);
-    } catch (ExecutionException e) {
-      log.warn("Cannot resolve accounts by email", e);
-      return Collections.emptySet();
-    }
-  }
-
-  @Override
-  public void evict(final String email) {
-    if (email != null) {
-      cache.invalidate(email);
-    }
-  }
-
-  static class Loader extends CacheLoader<String, Set<Account.Id>> {
-    private final SchemaFactory<ReviewDb> schema;
-    private final Provider<InternalAccountQuery> accountQueryProvider;
-
-    @Inject
-    Loader(SchemaFactory<ReviewDb> schema, Provider<InternalAccountQuery> accountQueryProvider) {
-      this.schema = schema;
-      this.accountQueryProvider = accountQueryProvider;
-    }
-
-    @Override
-    public Set<Account.Id> load(String email) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        Set<Account.Id> r = new HashSet<>();
-        for (Account a : db.accounts().byPreferredEmail(email)) {
-          r.add(a.getId());
-        }
-        for (AccountState accountState : accountQueryProvider.get().byEmailPrefix(email)) {
-          if (accountState
-              .getExternalIds()
-              .stream()
-              .filter(e -> email.equals(e.email()))
-              .findAny()
-              .isPresent()) {
-            r.add(accountState.getAccount().getId());
-          }
-        }
-        return ImmutableSet.copyOf(r);
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
index df6b122..bbc4f5f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCache.java
@@ -43,20 +43,25 @@
   AccountState getOrNull(Account.Id accountId);
 
   /**
-   * Returns an {@code AccountState} instance for the given account ID if it is present in the
-   * cache.
+   * Returns an {@code AccountState} instance for the given username.
    *
-   * @param accountId ID of the account that should be retrieved
-   * @return {@code AccountState} instance for the given account ID if it is present in the cache,
-   *     otherwise {@code null}
+   * <p>This method first loads the external ID for the username and then uses the account ID of the
+   * external ID to lookup the account from the cache.
+   *
+   * @param username username of the account that should be retrieved
+   * @return {@code AccountState} instance for the given username, if no account with this username
+   *     exists or if loading the external ID fails {@code null} is returned
    */
-  AccountState getIfPresent(Account.Id accountId);
-
   AccountState getByUsername(String username);
 
+  /**
+   * Evicts the account from the cache and triggers a reindex for it.
+   *
+   * @param accountId account ID of the account that should be evicted
+   * @throws IOException thrown if reindexing fails
+   */
   void evict(Account.Id accountId) throws IOException;
 
-  void evictByUsername(String username);
-
-  void evictAll() throws IOException;
+  /** Evict all accounts from the cache, but doesn't trigger reindex of all accounts. */
+  void evictAllNoReindex();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 1828cca..9894751 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -14,26 +14,19 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.WatchConfig.NotifyType;
-import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.index.account.AccountIndexer;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Provider;
@@ -43,9 +36,7 @@
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Optional;
-import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
@@ -57,35 +48,34 @@
   private static final Logger log = LoggerFactory.getLogger(AccountCacheImpl.class);
 
   private static final String BYID_NAME = "accounts";
-  private static final String BYUSER_NAME = "accounts_byname";
 
-  public static Module module(boolean useReviewdb) {
+  public static Module module() {
     return new CacheModule() {
       @Override
       protected void configure() {
         cache(BYID_NAME, Account.Id.class, new TypeLiteral<Optional<AccountState>>() {})
             .loader(ByIdLoader.class);
 
-        cache(BYUSER_NAME, String.class, new TypeLiteral<Optional<Account.Id>>() {})
-            .loader(useReviewdb ? ByNameReviewDbLoader.class : ByNameLoader.class);
-
         bind(AccountCacheImpl.class);
         bind(AccountCache.class).to(AccountCacheImpl.class);
       }
     };
   }
 
+  private final AllUsersName allUsersName;
+  private final ExternalIds externalIds;
   private final LoadingCache<Account.Id, Optional<AccountState>> byId;
-  private final LoadingCache<String, Optional<Account.Id>> byName;
   private final Provider<AccountIndexer> indexer;
 
   @Inject
   AccountCacheImpl(
+      AllUsersName allUsersName,
+      ExternalIds externalIds,
       @Named(BYID_NAME) LoadingCache<Account.Id, Optional<AccountState>> byId,
-      @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
       Provider<AccountIndexer> indexer) {
+    this.allUsersName = allUsersName;
+    this.externalIds = externalIds;
     this.byId = byId;
-    this.byName = byUsername;
     this.indexer = indexer;
   }
 
@@ -105,24 +95,21 @@
     try {
       return byId.get(accountId).orElse(null);
     } catch (ExecutionException e) {
-      log.warn("Cannot load AccountState for " + accountId, e);
+      log.warn("Cannot load AccountState for ID " + accountId, e);
       return null;
     }
   }
 
   @Override
-  public AccountState getIfPresent(Account.Id accountId) {
-    Optional<AccountState> state = byId.getIfPresent(accountId);
-    return state != null ? state.orElse(missing(accountId)) : null;
-  }
-
-  @Override
   public AccountState getByUsername(String username) {
     try {
-      Optional<Account.Id> id = byName.get(username);
-      return id != null && id.isPresent() ? getOrNull(id.get()) : null;
-    } catch (ExecutionException e) {
-      log.warn("Cannot load AccountState for " + username, e);
+      ExternalId extId = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
+      if (extId == null) {
+        return null;
+      }
+      return getOrNull(extId.accountId());
+    } catch (IOException | ConfigInvalidException e) {
+      log.warn("Cannot load AccountState for username " + username, e);
       return null;
     }
   }
@@ -136,84 +123,44 @@
   }
 
   @Override
-  public void evictAll() throws IOException {
+  public void evictAllNoReindex() {
     byId.invalidateAll();
-    for (Account.Id accountId : byId.asMap().keySet()) {
-      indexer.get().index(accountId);
-    }
   }
 
-  @Override
-  public void evictByUsername(String username) {
-    if (username != null) {
-      byName.invalidate(username);
-    }
-  }
-
-  private static AccountState missing(Account.Id accountId) {
+  private AccountState missing(Account.Id accountId) {
     Account account = new Account(accountId, TimeUtil.nowTs());
     account.setActive(false);
-    Set<AccountGroup.UUID> anon = ImmutableSet.of();
-    return new AccountState(
-        account, anon, Collections.emptySet(), new HashMap<ProjectWatchKey, Set<NotifyType>>());
+    return new AccountState(allUsersName, account, Collections.emptySet(), new HashMap<>());
   }
 
   static class ByIdLoader extends CacheLoader<Account.Id, Optional<AccountState>> {
-    private final SchemaFactory<ReviewDb> schema;
-    private final GroupCache groupCache;
+    private final AllUsersName allUsersName;
+    private final Accounts accounts;
     private final GeneralPreferencesLoader loader;
-    private final LoadingCache<String, Optional<Account.Id>> byName;
     private final Provider<WatchConfig.Accessor> watchConfig;
+    private final ExternalIds externalIds;
 
     @Inject
     ByIdLoader(
-        SchemaFactory<ReviewDb> sf,
-        GroupCache groupCache,
+        AllUsersName allUsersName,
+        Accounts accounts,
         GeneralPreferencesLoader loader,
-        @Named(BYUSER_NAME) LoadingCache<String, Optional<Account.Id>> byUsername,
-        Provider<WatchConfig.Accessor> watchConfig) {
-      this.schema = sf;
-      this.groupCache = groupCache;
+        Provider<WatchConfig.Accessor> watchConfig,
+        ExternalIds externalIds) {
+      this.allUsersName = allUsersName;
+      this.accounts = accounts;
       this.loader = loader;
-      this.byName = byUsername;
       this.watchConfig = watchConfig;
+      this.externalIds = externalIds;
     }
 
     @Override
-    public Optional<AccountState> load(Account.Id key) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        Optional<AccountState> state = load(db, key);
-        if (!state.isPresent()) {
-          return state;
-        }
-        String user = state.get().getUserName();
-        if (user != null) {
-          byName.put(user, Optional.of(state.get().getAccount().getId()));
-        }
-        return state;
-      }
-    }
-
-    private Optional<AccountState> load(final ReviewDb db, final Account.Id who)
-        throws OrmException, IOException, ConfigInvalidException {
-      Account account = db.accounts().get(who);
+    public Optional<AccountState> load(Account.Id who) throws Exception {
+      Account account = accounts.get(who);
       if (account == null) {
         return Optional.empty();
       }
 
-      Set<ExternalId> externalIds =
-          ExternalId.from(db.accountExternalIds().byAccount(who).toList());
-
-      Set<AccountGroup.UUID> internalGroups = new HashSet<>();
-      for (AccountGroupMember g : db.accountGroupMembers().byAccount(who)) {
-        final AccountGroup.Id groupId = g.getAccountGroupId();
-        final AccountGroup group = groupCache.get(groupId);
-        if (group != null && group.getGroupUUID() != null) {
-          internalGroups.add(group.getGroupUUID());
-        }
-      }
-      internalGroups = Collections.unmodifiableSet(internalGroups);
-
       try {
         account.setGeneralPreferences(loader.load(who));
       } catch (IOException | ConfigInvalidException e) {
@@ -223,42 +170,10 @@
 
       return Optional.of(
           new AccountState(
-              account, internalGroups, externalIds, watchConfig.get().getProjectWatches(who)));
-    }
-  }
-
-  static class ByNameReviewDbLoader extends CacheLoader<String, Optional<Account.Id>> {
-    private final SchemaFactory<ReviewDb> dbProvider;
-
-    @Inject
-    public ByNameReviewDbLoader(SchemaFactory<ReviewDb> dbProvider) {
-      this.dbProvider = dbProvider;
-    }
-
-    @Override
-    public Optional<Account.Id> load(String username) throws Exception {
-      try (ReviewDb db = dbProvider.open()) {
-        return Optional.ofNullable(
-                db.accountExternalIds()
-                    .get(new AccountExternalId.Key(SCHEME_USERNAME + ":" + username)))
-            .map(AccountExternalId::getAccountId);
-      }
-    }
-  }
-
-  static class ByNameLoader extends CacheLoader<String, Optional<Account.Id>> {
-    private final Provider<InternalAccountQuery> accountQueryProvider;
-
-    @Inject
-    ByNameLoader(Provider<InternalAccountQuery> accountQueryProvider) {
-      this.accountQueryProvider = accountQueryProvider;
-    }
-
-    @Override
-    public Optional<Account.Id> load(String username) throws Exception {
-      AccountState accountState =
-          accountQueryProvider.get().oneByExternalId(SCHEME_USERNAME, username);
-      return Optional.ofNullable(accountState).map(s -> s.getAccount().getId());
+              allUsersName,
+              account,
+              externalIds.byAccount(who),
+              watchConfig.get().getProjectWatches(who)));
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountConfig.java
new file mode 100644
index 0000000..f44aa0e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountConfig.java
@@ -0,0 +1,274 @@
+// 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.account;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.VersionedMetaData;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+
+/**
+ * ‘account.config’ file in the user branch in the All-Users repository that contains the properties
+ * of the account.
+ *
+ * <p>The 'account.config' file is a git config file that has one 'account' section with the
+ * properties of the account:
+ *
+ * <pre>
+ *   [account]
+ *     active = false
+ *     fullName = John Doe
+ *     preferredEmail = john.doe@foo.com
+ *     status = Overloaded with reviews
+ * </pre>
+ *
+ * <p>All keys are optional. This means 'account.config' may not exist on the user branch if no
+ * properties are set.
+ *
+ * <p>Not setting a key and setting a key to an empty string are treated the same way and result in
+ * a {@code null} value.
+ *
+ * <p>If no value for 'active' is specified, by default the account is considered as active.
+ *
+ * <p>The commit date of the first commit on the user branch is used as registration date of the
+ * account. The first commit may be an empty commit (if no properties were set and 'account.config'
+ * doesn't exist).
+ */
+public class AccountConfig extends VersionedMetaData implements ValidationError.Sink {
+  public static final String ACCOUNT_CONFIG = "account.config";
+  public static final String ACCOUNT = "account";
+  public static final String KEY_ACTIVE = "active";
+  public static final String KEY_FULL_NAME = "fullName";
+  public static final String KEY_PREFERRED_EMAIL = "preferredEmail";
+  public static final String KEY_STATUS = "status";
+
+  @Nullable private final OutgoingEmailValidator emailValidator;
+  private final Account.Id accountId;
+  private final String ref;
+
+  private boolean isLoaded;
+  private Account account;
+  private Timestamp registeredOn;
+  private List<ValidationError> validationErrors;
+
+  public AccountConfig(@Nullable OutgoingEmailValidator emailValidator, Account.Id accountId) {
+    this.emailValidator = emailValidator;
+    this.accountId = accountId;
+    this.ref = RefNames.refsUsers(accountId);
+  }
+
+  @Override
+  protected String getRefName() {
+    return ref;
+  }
+
+  /**
+   * Get the loaded account.
+   *
+   * @return loaded account.
+   * @throws IllegalStateException if the account was not loaded yet
+   */
+  public Account getAccount() {
+    checkLoaded();
+    return account;
+  }
+
+  /**
+   * Sets the account. This means the loaded account will be overwritten with the given account.
+   *
+   * <p>Changing the registration date of an account is not supported.
+   *
+   * @param account account that should be set
+   * @throws IllegalStateException if the account was not loaded yet
+   */
+  public void setAccount(Account account) {
+    checkLoaded();
+    this.account = account;
+    this.registeredOn = account.getRegisteredOn();
+  }
+
+  /**
+   * Creates a new account.
+   *
+   * @return the new account
+   * @throws OrmDuplicateKeyException if the user branch already exists
+   */
+  public Account getNewAccount() throws OrmDuplicateKeyException {
+    checkLoaded();
+    if (revision != null) {
+      throw new OrmDuplicateKeyException(String.format("account %s already exists", accountId));
+    }
+    this.registeredOn = TimeUtil.nowTs();
+    this.account = new Account(accountId, registeredOn);
+    return account;
+  }
+
+  @Override
+  protected void onLoad() throws IOException, ConfigInvalidException {
+    if (revision != null) {
+      rw.markStart(revision);
+      rw.sort(RevSort.REVERSE);
+      registeredOn = new Timestamp(rw.next().getCommitTime() * 1000L);
+
+      Config cfg = readConfig(ACCOUNT_CONFIG);
+
+      account = parse(cfg);
+      account.setMetaId(revision.name());
+    }
+
+    isLoaded = true;
+  }
+
+  private Account parse(Config cfg) {
+    Account account = new Account(accountId, registeredOn);
+    account.setActive(cfg.getBoolean(ACCOUNT, null, KEY_ACTIVE, true));
+    account.setFullName(get(cfg, KEY_FULL_NAME));
+
+    String preferredEmail = get(cfg, KEY_PREFERRED_EMAIL);
+    account.setPreferredEmail(preferredEmail);
+    if (emailValidator != null && !emailValidator.isValid(preferredEmail)) {
+      error(
+          new ValidationError(
+              ACCOUNT_CONFIG, String.format("Invalid preferred email: %s", preferredEmail)));
+    }
+
+    account.setStatus(get(cfg, KEY_STATUS));
+    return account;
+  }
+
+  @Override
+  public RevCommit commit(MetaDataUpdate update) throws IOException {
+    RevCommit c = super.commit(update);
+    account.setMetaId(c.name());
+    return c;
+  }
+
+  @Override
+  protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+    checkLoaded();
+
+    if (revision != null) {
+      commit.setMessage("Update account\n");
+    } else if (account != null) {
+      commit.setMessage("Create account\n");
+      commit.setAuthor(new PersonIdent(commit.getAuthor(), registeredOn));
+      commit.setCommitter(new PersonIdent(commit.getCommitter(), registeredOn));
+    }
+
+    Config cfg = readConfig(ACCOUNT_CONFIG);
+    writeToConfig(account, cfg);
+    saveConfig(ACCOUNT_CONFIG, cfg);
+    return true;
+  }
+
+  public static void writeToConfig(Account account, Config cfg) {
+    setActive(cfg, account.isActive());
+    set(cfg, KEY_FULL_NAME, account.getFullName());
+    set(cfg, KEY_PREFERRED_EMAIL, account.getPreferredEmail());
+    set(cfg, KEY_STATUS, account.getStatus());
+  }
+
+  /**
+   * Sets/Unsets {@code account.active} in the given config.
+   *
+   * <p>{@code account.active} is set to {@code false} if the account is inactive.
+   *
+   * <p>If the account is active {@code account.active} is unset since {@code true} is the default
+   * if this field is missing.
+   *
+   * @param cfg the config
+   * @param value whether the account is active
+   */
+  private static void setActive(Config cfg, boolean value) {
+    if (!value) {
+      cfg.setBoolean(ACCOUNT, null, KEY_ACTIVE, false);
+    } else {
+      cfg.unset(ACCOUNT, null, KEY_ACTIVE);
+    }
+  }
+
+  /**
+   * Sets/Unsets the given key in the given config.
+   *
+   * <p>The key unset if the value is {@code null}.
+   *
+   * @param cfg the config
+   * @param key the key
+   * @param value the value
+   */
+  private static void set(Config cfg, String key, String value) {
+    if (!Strings.isNullOrEmpty(value)) {
+      cfg.setString(ACCOUNT, null, key, value);
+    } else {
+      cfg.unset(ACCOUNT, null, key);
+    }
+  }
+
+  /**
+   * Gets the given key from the given config.
+   *
+   * <p>Empty values are returned as {@code null}
+   *
+   * @param cfg the config
+   * @param key the key
+   * @return the value, {@code null} if key was not set or key was set to empty string
+   */
+  private static String get(Config cfg, String key) {
+    return Strings.emptyToNull(cfg.getString(ACCOUNT, null, key));
+  }
+
+  private void checkLoaded() {
+    checkState(isLoaded, "account not loaded yet");
+  }
+
+  /**
+   * Get the validation errors, if any were discovered during load.
+   *
+   * @return list of errors; empty list if there are no errors.
+   */
+  public List<ValidationError> getValidationErrors() {
+    if (validationErrors != null) {
+      return ImmutableList.copyOf(validationErrors);
+    }
+    return ImmutableList.of();
+  }
+
+  @Override
+  public void error(ValidationError error) {
+    if (validationErrors == null) {
+      validationErrors = new ArrayList<>(4);
+    }
+    validationErrors.add(error);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
index 88a2411..6f25703 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountControl.java
@@ -18,12 +18,17 @@
 
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.AccountVisibility;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.AccountsSection;
 import com.google.gerrit.server.group.SystemGroupBackend;
+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.project.ProjectCache;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -32,6 +37,7 @@
 /** Access control management for one account's access to other accounts. */
 public class AccountControl {
   public static class Factory {
+    private final PermissionBackend permissionBackend;
     private final ProjectCache projectCache;
     private final GroupControl.Factory groupControlFactory;
     private final Provider<CurrentUser> user;
@@ -40,11 +46,13 @@
 
     @Inject
     Factory(
-        final ProjectCache projectCache,
-        final GroupControl.Factory groupControlFactory,
-        final Provider<CurrentUser> user,
-        final IdentifiedUser.GenericFactory userFactory,
-        final AccountVisibility accountVisibility) {
+        PermissionBackend permissionBackend,
+        ProjectCache projectCache,
+        GroupControl.Factory groupControlFactory,
+        Provider<CurrentUser> user,
+        IdentifiedUser.GenericFactory userFactory,
+        AccountVisibility accountVisibility) {
+      this.permissionBackend = permissionBackend;
       this.projectCache = projectCache;
       this.groupControlFactory = groupControlFactory;
       this.user = user;
@@ -54,24 +62,34 @@
 
     public AccountControl get() {
       return new AccountControl(
-          projectCache, groupControlFactory, user.get(), userFactory, accountVisibility);
+          permissionBackend,
+          projectCache,
+          groupControlFactory,
+          user.get(),
+          userFactory,
+          accountVisibility);
     }
   }
 
   private final AccountsSection accountsSection;
   private final GroupControl.Factory groupControlFactory;
+  private final PermissionBackend.WithUser perm;
   private final CurrentUser user;
   private final IdentifiedUser.GenericFactory userFactory;
   private final AccountVisibility accountVisibility;
 
+  private Boolean viewAll;
+
   AccountControl(
-      final ProjectCache projectCache,
-      final GroupControl.Factory groupControlFactory,
-      final CurrentUser user,
-      final IdentifiedUser.GenericFactory userFactory,
-      final AccountVisibility accountVisibility) {
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache,
+      GroupControl.Factory groupControlFactory,
+      CurrentUser user,
+      IdentifiedUser.GenericFactory userFactory,
+      AccountVisibility accountVisibility) {
     this.accountsSection = projectCache.getAllProjects().getConfig().getAccountsSection();
     this.groupControlFactory = groupControlFactory;
+    this.perm = permissionBackend.user(user);
     this.user = user;
     this.userFactory = userFactory;
     this.accountVisibility = accountVisibility;
@@ -99,7 +117,7 @@
    * because {@link GroupMembership#getKnownGroups()} may only return a subset of the effective
    * groups.
    */
-  public boolean canSee(final Account.Id otherUser) {
+  public boolean canSee(Account.Id otherUser) {
     return canSee(
         new OtherUser() {
           @Override
@@ -121,7 +139,7 @@
    * because {@link GroupMembership#getKnownGroups()} may only return a subset of the effective
    * groups.
    */
-  public boolean canSee(final AccountState otherUser) {
+  public boolean canSee(AccountState otherUser) {
     return canSee(
         new OtherUser() {
           @Override
@@ -137,17 +155,16 @@
   }
 
   private boolean canSee(OtherUser otherUser) {
-    // Special case: I can always see myself.
-    if (user.isIdentifiedUser() && user.getAccountId().equals(otherUser.getId())) {
+    if (accountVisibility == AccountVisibility.ALL) {
       return true;
-    }
-    if (user.getCapabilities().canViewAllAccounts()) {
+    } else if (user.isIdentifiedUser() && user.getAccountId().equals(otherUser.getId())) {
+      // I can always see myself.
+      return true;
+    } else if (viewAll()) {
       return true;
     }
 
     switch (accountVisibility) {
-      case ALL:
-        return true;
       case SAME_GROUP:
         {
           Set<AccountGroup.UUID> usersGroups = groupsOf(otherUser.getUser());
@@ -178,12 +195,25 @@
         }
       case NONE:
         break;
+      case ALL:
       default:
         throw new IllegalStateException("Bad AccountVisibility " + accountVisibility);
     }
     return false;
   }
 
+  private boolean viewAll() {
+    if (viewAll == null) {
+      try {
+        perm.check(GlobalPermission.VIEW_ALL_ACCOUNTS);
+        viewAll = true;
+      } catch (AuthException | PermissionBackendException e) {
+        viewAll = false;
+      }
+    }
+    return viewAll;
+  }
+
   private Set<AccountGroup.UUID> groupsOf(IdentifiedUser user) {
     return user.getEffectiveGroups()
         .getKnownGroups()
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDeactivator.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDeactivator.java
new file mode 100644
index 0000000..a2c5fdd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDeactivator.java
@@ -0,0 +1,140 @@
+// 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.account;
+
+import static com.google.gerrit.server.config.ScheduleConfig.MISSING_CONFIG;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.ScheduleConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.query.account.AccountPredicates;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Runnable to enable scheduling account deactivations to run periodically */
+public class AccountDeactivator implements Runnable {
+  private static final Logger log = LoggerFactory.getLogger(AccountDeactivator.class);
+
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(Lifecycle.class);
+    }
+  }
+
+  static class Lifecycle implements LifecycleListener {
+    private final WorkQueue queue;
+    private final AccountDeactivator deactivator;
+    private final boolean supportAutomaticAccountActivityUpdate;
+    private final ScheduleConfig scheduleConfig;
+
+    @Inject
+    Lifecycle(WorkQueue queue, AccountDeactivator deactivator, @GerritServerConfig Config cfg) {
+      this.queue = queue;
+      this.deactivator = deactivator;
+      scheduleConfig = new ScheduleConfig(cfg, "accountDeactivation");
+      supportAutomaticAccountActivityUpdate =
+          cfg.getBoolean("auth", "autoUpdateAccountActiveStatus", false);
+    }
+
+    @Override
+    public void start() {
+      if (!supportAutomaticAccountActivityUpdate) {
+        return;
+      }
+      long interval = scheduleConfig.getInterval();
+      long delay = scheduleConfig.getInitialDelay();
+      if (delay == MISSING_CONFIG && interval == MISSING_CONFIG) {
+        log.info("Ignoring missing accountDeactivator schedule configuration");
+      } else if (delay < 0 || interval <= 0) {
+        log.warn("Ignoring invalid accountDeactivator schedule configuration: {}", scheduleConfig);
+      } else {
+        queue
+            .getDefaultQueue()
+            .scheduleAtFixedRate(deactivator, delay, interval, TimeUnit.MILLISECONDS);
+      }
+    }
+
+    @Override
+    public void stop() {
+      // handled by WorkQueue.stop() already
+    }
+  }
+
+  private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final Realm realm;
+  private final SetInactiveFlag sif;
+
+  @Inject
+  AccountDeactivator(
+      Provider<InternalAccountQuery> accountQueryProvider, SetInactiveFlag sif, Realm realm) {
+    this.accountQueryProvider = accountQueryProvider;
+    this.sif = sif;
+    this.realm = realm;
+  }
+
+  @Override
+  public void run() {
+    log.info("Running account deactivations");
+    try {
+      int numberOfAccountsDeactivated = 0;
+      for (AccountState acc : accountQueryProvider.get().query(AccountPredicates.isActive())) {
+        if (processAccount(acc)) {
+          numberOfAccountsDeactivated++;
+        }
+      }
+      log.info(
+          "Deactivations complete, {} account(s) were deactivated", numberOfAccountsDeactivated);
+    } catch (Exception e) {
+      log.error("Failed to complete deactivation of accounts: " + e.getMessage(), e);
+    }
+  }
+
+  private boolean processAccount(AccountState account) {
+    log.debug("processing account " + account.getUserName());
+    try {
+      if (account.getUserName() != null
+          && realm.accountBelongsToRealm(account.getExternalIds())
+          && !realm.isActive(account.getUserName())) {
+        sif.deactivate(account.getAccount().getId());
+        log.info("deactivated account " + account.getUserName());
+        return true;
+      }
+    } catch (ResourceConflictException e) {
+      log.info("Account {} already deactivated, continuing...", account.getUserName());
+    } catch (Exception e) {
+      log.error(
+          "Error deactivating account: {} ({}) {}",
+          account.getUserName(),
+          account.getAccount().getId(),
+          e.getMessage(),
+          e);
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    return "account deactivator";
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
index 5c14c94..6e5b42e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountDirectory.java
@@ -50,8 +50,9 @@
   public abstract void fillAccountInfo(Iterable<? extends AccountInfo> in, Set<FillOptions> options)
       throws DirectoryException;
 
-  @SuppressWarnings("serial")
   public static class DirectoryException extends Exception {
+    private static final long serialVersionUID = 1L;
+
     public DirectoryException(String message, Throwable why) {
       super(message, why);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountException.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountException.java
index a536c1a..b8b4a9e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountException.java
@@ -18,11 +18,11 @@
 public class AccountException extends Exception {
   private static final long serialVersionUID = 1L;
 
-  public AccountException(final String message) {
+  public AccountException(String message) {
     super(message);
   }
 
-  public AccountException(final String message, final Throwable why) {
+  public AccountException(String message, Throwable why) {
     super(message, why);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLimits.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLimits.java
new file mode 100644
index 0000000..4d1d1b8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountLimits.java
@@ -0,0 +1,149 @@
+// Copyright (C) 2011 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.index.query.QueryProcessor;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.QueueProvider;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.List;
+
+/** Limits which QoS a user runs as, and how many search results it can request. */
+public class AccountLimits {
+  @Singleton
+  public static class Factory {
+    private final ProjectCache projectCache;
+
+    @Inject
+    Factory(ProjectCache projectCache) {
+      this.projectCache = projectCache;
+    }
+
+    public AccountLimits create(CurrentUser user) {
+      return new AccountLimits(projectCache, user);
+    }
+  }
+
+  private final CapabilityCollection capabilities;
+  private final CurrentUser user;
+
+  private AccountLimits(ProjectCache projectCache, CurrentUser currentUser) {
+    capabilities = projectCache.getAllProjects().getCapabilityCollection();
+    user = currentUser;
+  }
+
+  /** @return which priority queue the user's tasks should be submitted to. */
+  public QueueProvider.QueueType getQueueType() {
+    // If a non-generic group (that is not Anonymous Users or Registered Users)
+    // grants us INTERACTIVE permission, use the INTERACTIVE queue even if
+    // BATCH was otherwise granted. This allows site administrators to grant
+    // INTERACTIVE to Registered Users, and BATCH to 'CI Servers' and have
+    // the 'CI Servers' actually use the BATCH queue while everyone else gets
+    // to use the INTERACTIVE queue without additional grants.
+    //
+    GroupMembership groups = user.getEffectiveGroups();
+    boolean batch = false;
+    for (PermissionRule r : capabilities.priority) {
+      if (match(groups, r)) {
+        switch (r.getAction()) {
+          case INTERACTIVE:
+            if (!SystemGroupBackend.isAnonymousOrRegistered(r.getGroup())) {
+              return QueueProvider.QueueType.INTERACTIVE;
+            }
+            break;
+
+          case BATCH:
+            batch = true;
+            break;
+
+          case ALLOW:
+          case BLOCK:
+          case DENY:
+            break;
+        }
+      }
+    }
+
+    if (batch) {
+      // If any of our groups matched to the BATCH queue, use it.
+      return QueueProvider.QueueType.BATCH;
+    }
+    return QueueProvider.QueueType.INTERACTIVE;
+  }
+
+  /**
+   * Get the limit on a {@link QueryProcessor} for a given user.
+   *
+   * @return limit according to {@link GlobalCapability#QUERY_LIMIT}.
+   */
+  public int getQueryLimit() {
+    return getRange(GlobalCapability.QUERY_LIMIT).getMax();
+  }
+
+  /** @return true if the user has a permission rule specifying the range. */
+  public boolean hasExplicitRange(String permission) {
+    return GlobalCapability.hasRange(permission) && !getRules(permission).isEmpty();
+  }
+
+  /** The range of permitted values associated with a label permission. */
+  public PermissionRange getRange(String permission) {
+    if (GlobalCapability.hasRange(permission)) {
+      return toRange(permission, getRules(permission));
+    }
+    return null;
+  }
+
+  private static PermissionRange toRange(String permissionName, List<PermissionRule> ruleList) {
+    int min = 0;
+    int max = 0;
+    if (ruleList.isEmpty()) {
+      PermissionRange.WithDefaults defaultRange = GlobalCapability.getRange(permissionName);
+      if (defaultRange != null) {
+        min = defaultRange.getDefaultMin();
+        max = defaultRange.getDefaultMax();
+      }
+    } else {
+      for (PermissionRule rule : ruleList) {
+        min = Math.min(min, rule.getMin());
+        max = Math.max(max, rule.getMax());
+      }
+    }
+    return new PermissionRange(permissionName, min, max);
+  }
+
+  private List<PermissionRule> getRules(String permissionName) {
+    List<PermissionRule> rules = capabilities.getPermission(permissionName);
+    GroupMembership groups = user.getEffectiveGroups();
+
+    List<PermissionRule> mine = new ArrayList<>(rules.size());
+    for (PermissionRule rule : rules) {
+      if (match(groups, rule)) {
+        mine.add(rule);
+      }
+    }
+    return mine;
+  }
+
+  private static boolean match(GroupMembership groups, PermissionRule rule) {
+    return groups.contains(rule.getGroup().getUUID());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
index ee33798..889f3ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountManager.java
@@ -14,33 +14,39 @@
 
 package com.google.gerrit.server.account;
 
-import static java.util.stream.Collectors.toSet;
-
 import com.google.common.base.Strings;
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.TimeUtil;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AccountFieldName;
 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.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
+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.auth.NoSuchUserException;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.group.GroupsUpdate;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.Iterator;
+import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.Consumer;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -50,45 +56,62 @@
   private static final Logger log = LoggerFactory.getLogger(AccountManager.class);
 
   private final SchemaFactory<ReviewDb> schema;
+  private final Sequences sequences;
+  private final Accounts accounts;
+  private final AccountsUpdate.Server accountsUpdateFactory;
   private final AccountCache byIdCache;
-  private final AccountByEmailCache byEmailCache;
   private final Realm realm;
   private final IdentifiedUser.GenericFactory userFactory;
   private final ChangeUserName.Factory changeUserNameFactory;
   private final ProjectCache projectCache;
   private final AtomicBoolean awaitsFirstAccountCheck;
-  private final AuditService auditService;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
+  private final GroupsUpdate.Factory groupsUpdateFactory;
+  private final boolean autoUpdateAccountActiveStatus;
+  private final SetInactiveFlag setInactiveFlag;
 
   @Inject
   AccountManager(
       SchemaFactory<ReviewDb> schema,
+      Sequences sequences,
+      @GerritServerConfig Config cfg,
+      Accounts accounts,
+      AccountsUpdate.Server accountsUpdateFactory,
       AccountCache byIdCache,
-      AccountByEmailCache byEmailCache,
       Realm accountMapper,
       IdentifiedUser.GenericFactory userFactory,
       ChangeUserName.Factory changeUserNameFactory,
       ProjectCache projectCache,
-      AuditService auditService,
-      ExternalIdsUpdate.Server externalIdsUpdateFactory) {
+      ExternalIds externalIds,
+      ExternalIdsUpdate.Server externalIdsUpdateFactory,
+      GroupsUpdate.Factory groupsUpdateFactory,
+      SetInactiveFlag setInactiveFlag) {
     this.schema = schema;
+    this.sequences = sequences;
+    this.accounts = accounts;
+    this.accountsUpdateFactory = accountsUpdateFactory;
     this.byIdCache = byIdCache;
-    this.byEmailCache = byEmailCache;
     this.realm = accountMapper;
     this.userFactory = userFactory;
     this.changeUserNameFactory = changeUserNameFactory;
     this.projectCache = projectCache;
-    this.awaitsFirstAccountCheck = new AtomicBoolean(true);
-    this.auditService = auditService;
+    this.awaitsFirstAccountCheck =
+        new AtomicBoolean(cfg.getBoolean("capability", "makeFirstUserAdmin", true));
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
+    this.groupsUpdateFactory = groupsUpdateFactory;
+    this.autoUpdateAccountActiveStatus =
+        cfg.getBoolean("auth", "autoUpdateAccountActiveStatus", false);
+    this.setInactiveFlag = setInactiveFlag;
   }
 
   /** @return user identified by this external identity string */
   public Optional<Account.Id> lookup(String externalId) throws AccountException {
-    try (ReviewDb db = schema.open()) {
-      ExternalId extId = findExternalId(db, ExternalId.Key.parse(externalId));
+    try {
+      ExternalId extId = externalIds.get(ExternalId.Key.parse(externalId));
       return extId != null ? Optional.of(extId.accountId()) : Optional.empty();
-    } catch (OrmException e) {
+    } catch (IOException | ConfigInvalidException e) {
       throw new AccountException("Cannot lookup account " + externalId, e);
     }
   }
@@ -99,13 +122,19 @@
    * @param who identity of the user, with any details we received about them.
    * @return the result of authenticating the user.
    * @throws AccountException the account does not exist, and cannot be created, or exists, but
-   *     cannot be located, or is inactive.
+   *     cannot be located, is unable to be activated or deactivated, or is inactive, or cannot be
+   *     added to the admin group (only for the first account).
    */
   public AuthResult authenticate(AuthRequest who) throws AccountException, IOException {
-    who = realm.authenticate(who);
+    try {
+      who = realm.authenticate(who);
+    } catch (NoSuchUserException e) {
+      deactivateAccountIfItExists(who);
+      throw e;
+    }
     try {
       try (ReviewDb db = schema.open()) {
-        ExternalId id = findExternalId(db, who.getExternalIdKey());
+        ExternalId id = externalIds.get(who.getExternalIdKey());
         if (id == null) {
           // New account, automatically create and return.
           //
@@ -113,13 +142,13 @@
         }
 
         // Account exists
-        Account act = byIdCache.get(id.accountId()).getAccount();
+        Account act = updateAccountActiveStatus(who, byIdCache.get(id.accountId()).getAccount());
         if (!act.isActive()) {
           throw new AccountException("Authentication error, account inactive");
         }
 
         // return the identity to the caller.
-        update(db, who, id);
+        update(who, id);
         return new AuthResult(id.accountId(), who.getExternalIdKey(), false);
       }
     } catch (OrmException | ConfigInvalidException e) {
@@ -127,14 +156,51 @@
     }
   }
 
-  private ExternalId findExternalId(ReviewDb db, ExternalId.Key key) throws OrmException {
-    return ExternalId.from(db.accountExternalIds().get(key.asAccountExternalIdKey()));
+  private void deactivateAccountIfItExists(AuthRequest authRequest) {
+    if (!shouldUpdateActiveStatus(authRequest)) {
+      return;
+    }
+    try {
+      ExternalId id = externalIds.get(authRequest.getExternalIdKey());
+      if (id == null) {
+        return;
+      }
+      setInactiveFlag.deactivate(id.accountId());
+    } catch (Exception e) {
+      log.error("Unable to deactivate account " + authRequest.getUserName(), e);
+    }
   }
 
-  private void update(ReviewDb db, AuthRequest who, ExternalId extId)
-      throws OrmException, IOException {
+  private Account updateAccountActiveStatus(AuthRequest authRequest, Account account)
+      throws AccountException {
+    if (!shouldUpdateActiveStatus(authRequest) || authRequest.isActive() == account.isActive()) {
+      return account;
+    }
+
+    if (authRequest.isActive()) {
+      try {
+        setInactiveFlag.activate(account.getId());
+      } catch (Exception e) {
+        throw new AccountException("Unable to activate account " + account.getId(), e);
+      }
+    } else {
+      try {
+        setInactiveFlag.deactivate(account.getId());
+      } catch (Exception e) {
+        throw new AccountException("Unable to deactivate account " + account.getId(), e);
+      }
+    }
+    return byIdCache.get(account.getId()).getAccount();
+  }
+
+  private boolean shouldUpdateActiveStatus(AuthRequest authRequest) {
+    return autoUpdateAccountActiveStatus && authRequest.authProvidesAccountActiveStatus();
+  }
+
+  private void update(AuthRequest who, ExternalId extId)
+      throws OrmException, IOException, ConfigInvalidException {
     IdentifiedUser user = userFactory.create(extId.accountId());
-    Account toUpdate = null;
+    List<Consumer<Account>> accountUpdates = new ArrayList<>();
 
     // If the email address was modified by the authentication provider,
     // update our records to match the changed email.
@@ -143,23 +209,25 @@
     String oldEmail = extId.email();
     if (newEmail != null && !newEmail.equals(oldEmail)) {
       if (oldEmail != null && oldEmail.equals(user.getAccount().getPreferredEmail())) {
-        toUpdate = load(toUpdate, user.getAccountId(), db);
-        toUpdate.setPreferredEmail(newEmail);
+        accountUpdates.add(a -> a.setPreferredEmail(newEmail));
       }
 
       externalIdsUpdateFactory
           .create()
           .replace(
-              db,
-              extId,
-              ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password()));
+              extId, ExternalId.create(extId.key(), extId.accountId(), newEmail, extId.password()));
     }
 
-    if (!realm.allowsEdit(AccountFieldName.FULL_NAME)
-        && !Strings.isNullOrEmpty(who.getDisplayName())
+    if (!Strings.isNullOrEmpty(who.getDisplayName())
         && !eq(user.getAccount().getFullName(), who.getDisplayName())) {
-      toUpdate = load(toUpdate, user.getAccountId(), db);
-      toUpdate.setFullName(who.getDisplayName());
+      if (realm.allowsEdit(AccountFieldName.FULL_NAME)) {
+        accountUpdates.add(a -> a.setFullName(who.getDisplayName()));
+      } else {
+        log.warn(
+            "Not changing already set display name '{}' to '{}'",
+            user.getAccount().getFullName(),
+            who.getDisplayName());
+      }
     }
 
     if (!realm.allowsEdit(AccountFieldName.USER_NAME)
@@ -168,27 +236,12 @@
       log.warn("Not changing already set username {} to {}", user.getUserName(), who.getUserName());
     }
 
-    if (toUpdate != null) {
-      db.accounts().update(Collections.singleton(toUpdate));
-    }
-
-    if (newEmail != null && !newEmail.equals(oldEmail)) {
-      byEmailCache.evict(oldEmail);
-      byEmailCache.evict(newEmail);
-    }
-    if (toUpdate != null) {
-      byIdCache.evict(toUpdate.getId());
-    }
-  }
-
-  private Account load(Account toUpdate, Account.Id accountId, ReviewDb db) throws OrmException {
-    if (toUpdate == null) {
-      toUpdate = db.accounts().get(accountId);
-      if (toUpdate == null) {
-        throw new OrmException("Account " + accountId + " has been deleted");
+    if (!accountUpdates.isEmpty()) {
+      Account account = accountsUpdateFactory.create().update(user.getAccountId(), accountUpdates);
+      if (account == null) {
+        throw new OrmException("Account " + user.getAccountId() + " has been deleted");
       }
     }
-    return toUpdate;
   }
 
   private static boolean eq(String a, String b) {
@@ -197,27 +250,30 @@
 
   private AuthResult create(ReviewDb db, AuthRequest who)
       throws OrmException, AccountException, IOException, ConfigInvalidException {
-    Account.Id newId = new Account.Id(db.nextAccountId());
+    Account.Id newId = new Account.Id(sequences.nextAccountId());
     log.debug("Assigning new Id {} to account", newId);
-    Account account = new Account(newId, TimeUtil.nowTs());
 
     ExternalId extId =
         ExternalId.createWithEmail(who.getExternalIdKey(), newId, who.getEmailAddress());
     log.debug("Created external Id: {}", extId);
-    account.setFullName(who.getDisplayName());
-    account.setPreferredEmail(extId.email());
 
-    boolean isFirstAccount =
-        awaitsFirstAccountCheck.getAndSet(false) && db.accounts().anyAccounts().toList().isEmpty();
+    boolean isFirstAccount = awaitsFirstAccountCheck.getAndSet(false) && !accounts.hasAnyAccount();
 
+    Account account;
     try {
-      db.accounts().upsert(Collections.singleton(account));
+      AccountsUpdate accountsUpdate = accountsUpdateFactory.create();
+      account =
+          accountsUpdate.insert(
+              newId,
+              a -> {
+                a.setFullName(who.getDisplayName());
+                a.setPreferredEmail(extId.email());
+              });
 
-      ExternalId existingExtId =
-          ExternalId.from(db.accountExternalIds().get(extId.key().asAccountExternalIdKey()));
+      ExternalId existingExtId = externalIds.get(extId.key());
       if (existingExtId != null && !existingExtId.accountId().equals(extId.accountId())) {
         // external ID is assigned to another account, do not overwrite
-        db.accounts().delete(Collections.singleton(account));
+        accountsUpdate.delete(account);
         throw new AccountException(
             "Cannot assign external ID \""
                 + extId.key().get()
@@ -225,7 +281,7 @@
                 + newId
                 + "; external ID already in use.");
       }
-      externalIdsUpdateFactory.create().upsert(db, extId);
+      externalIdsUpdateFactory.create().upsert(extId);
     } finally {
       // If adding the account failed, it may be that it actually was the
       // first account. So we reset the 'check for first account'-guard, as
@@ -233,6 +289,8 @@
       awaitsFirstAccountCheck.set(isFirstAccount);
     }
 
+    IdentifiedUser user = userFactory.create(newId);
+
     if (isFirstAccount) {
       // This is the first user account on our site. Assume this user
       // is going to be the site's administrator and just make them that
@@ -246,16 +304,13 @@
               .getPermission(GlobalCapability.ADMINISTRATE_SERVER);
 
       AccountGroup.UUID uuid = admin.getRules().get(0).getGroup().getUUID();
-      Iterator<AccountGroup> adminGroupIt = db.accountGroups().byUUID(uuid).iterator();
-      if (!adminGroupIt.hasNext()) {
-        throw new OrmException(
-            "Administrator group's UUID is misaligned in backend and All-Projects repository");
+      // The user initiated this request by logging in. -> Attribute all modifications to that user.
+      GroupsUpdate groupsUpdate = groupsUpdateFactory.create(user);
+      try {
+        groupsUpdate.addGroupMember(db, uuid, newId);
+      } catch (NoSuchGroupException e) {
+        throw new AccountException(String.format("Group %s not found", uuid));
       }
-      AccountGroup g = adminGroupIt.next();
-      AccountGroup.Id adminId = g.getId();
-      AccountGroupMember m = new AccountGroupMember(new AccountGroupMember.Key(newId, adminId));
-      auditService.dispatchAddAccountsToGroup(newId, Collections.singleton(m));
-      db.accountGroupMembers().insert(Collections.singleton(m));
     }
 
     log.debug("Username from AuthRequest: {}", who.getUserName());
@@ -263,10 +318,9 @@
       log.debug("Setting username for: {}", who.getUserName());
       // Only set if the name hasn't been used yet, but was given to us.
       //
-      IdentifiedUser user = userFactory.create(newId);
-      log.debug("Identified user {} was created from {}", user, who.getUserName());
       try {
-        changeUserNameFactory.create(db, user, who.getUserName()).call();
+        changeUserNameFactory.create(user, who.getUserName()).call();
+        log.debug("Identified user {} was created from {}", user, who.getUserName());
       } catch (NameAlreadyUsedException e) {
         String message =
             "Cannot assign user name \""
@@ -274,7 +328,7 @@
                 + "\" to account "
                 + newId
                 + "; name already in use.";
-        handleSettingUserNameFailure(db, account, extId, message, e, false);
+        handleSettingUserNameFailure(account, extId, message, e, false);
       } catch (InvalidUserNameException e) {
         String message =
             "Cannot assign user name \""
@@ -282,15 +336,13 @@
                 + "\" to account "
                 + newId
                 + "; name does not conform.";
-        handleSettingUserNameFailure(db, account, extId, message, e, false);
+        handleSettingUserNameFailure(account, extId, message, e, false);
       } catch (OrmException e) {
         String message = "Cannot assign user name";
-        handleSettingUserNameFailure(db, account, extId, message, e, true);
+        handleSettingUserNameFailure(account, extId, message, e, true);
       }
     }
 
-    byEmailCache.evict(account.getPreferredEmail());
-    byIdCache.evict(account.getId());
     realm.onCreateAccount(who, account);
     return new AuthResult(newId, extId.key(), true);
   }
@@ -301,7 +353,6 @@
    * deletes the newly created account and throws an {@link AccountUserNameException}. In any case
    * the error message is logged.
    *
-   * @param db the database
    * @param account the newly created account
    * @param extId the newly created external id
    * @param errorMessage the error message
@@ -312,13 +363,8 @@
    * @throws OrmException thrown if cleaning the database failed
    */
   private void handleSettingUserNameFailure(
-      ReviewDb db,
-      Account account,
-      ExternalId extId,
-      String errorMessage,
-      Exception e,
-      boolean logException)
-      throws AccountUserNameException, OrmException, IOException {
+      Account account, ExternalId extId, String errorMessage, Exception e, boolean logException)
+      throws AccountUserNameException, OrmException, IOException, ConfigInvalidException {
     if (logException) {
       log.error(errorMessage, e);
     } else {
@@ -328,13 +374,12 @@
       // setting the given user name has failed, but the realm does not
       // allow the user to manually set a user name,
       // this means we would end with an account without user name
-      // (without 'username:<USERNAME>' entry in
-      // account_external_ids table),
+      // (without 'username:<USERNAME>' external ID),
       // such an account cannot be used for uploading changes,
       // this is why the best we can do here is to fail early and cleanup
       // the database
-      db.accounts().delete(Collections.singleton(account));
-      externalIdsUpdateFactory.create().delete(db, extId);
+      accountsUpdateFactory.create().delete(account);
+      externalIdsUpdateFactory.create().delete(extId);
       throw new AccountUserNameException(errorMessage, e);
     }
   }
@@ -349,33 +394,33 @@
    *     this time.
    */
   public AuthResult link(Account.Id to, AuthRequest who)
-      throws AccountException, OrmException, IOException {
-    try (ReviewDb db = schema.open()) {
-      ExternalId extId = findExternalId(db, who.getExternalIdKey());
-      if (extId != null) {
-        if (!extId.accountId().equals(to)) {
-          throw new AccountException("Identity in use by another account");
-        }
-        update(db, who, extId);
-      } else {
-        externalIdsUpdateFactory
-            .create()
-            .insert(
-                db, ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress()));
-
-        if (who.getEmailAddress() != null) {
-          Account a = db.accounts().get(to);
-          if (a.getPreferredEmail() == null) {
-            a.setPreferredEmail(who.getEmailAddress());
-            db.accounts().update(Collections.singleton(a));
-            byIdCache.evict(to);
-          }
-          byEmailCache.evict(who.getEmailAddress());
-        }
+      throws AccountException, OrmException, IOException, ConfigInvalidException {
+    ExternalId extId = externalIds.get(who.getExternalIdKey());
+    if (extId != null) {
+      if (!extId.accountId().equals(to)) {
+        throw new AccountException(
+            "Identity '" + extId.key().get() + "' in use by another account");
       }
+      update(who, extId);
+    } else {
+      externalIdsUpdateFactory
+          .create()
+          .insert(ExternalId.createWithEmail(who.getExternalIdKey(), to, who.getEmailAddress()));
 
-      return new AuthResult(to, who.getExternalIdKey(), false);
+      if (who.getEmailAddress() != null) {
+        accountsUpdateFactory
+            .create()
+            .update(
+                to,
+                a -> {
+                  if (a.getPreferredEmail() == null) {
+                    a.setPreferredEmail(who.getEmailAddress());
+                  }
+                });
+      }
     }
+
+    return new AuthResult(to, who.getExternalIdKey(), false);
   }
 
   /**
@@ -392,64 +437,79 @@
    *     this time.
    */
   public AuthResult updateLink(Account.Id to, AuthRequest who)
-      throws OrmException, AccountException, IOException {
-    try (ReviewDb db = schema.open()) {
-      Collection<ExternalId> filteredExtIdsByScheme =
-          ExternalId.from(db.accountExternalIds().byAccount(to).toList())
-              .stream()
-              .filter(e -> e.isScheme(who.getExternalIdKey().scheme()))
-              .collect(toSet());
+      throws OrmException, AccountException, IOException, ConfigInvalidException {
+    Collection<ExternalId> filteredExtIdsByScheme =
+        externalIds.byAccount(to, who.getExternalIdKey().scheme());
 
-      if (!filteredExtIdsByScheme.isEmpty()
-          && (filteredExtIdsByScheme.size() > 1
-              || !filteredExtIdsByScheme
-                  .stream()
-                  .filter(e -> e.key().equals(who.getExternalIdKey()))
-                  .findAny()
-                  .isPresent())) {
-        externalIdsUpdateFactory.create().delete(db, filteredExtIdsByScheme);
-      }
-      byIdCache.evict(to);
-      return link(to, who);
+    if (!filteredExtIdsByScheme.isEmpty()
+        && (filteredExtIdsByScheme.size() > 1
+            || !filteredExtIdsByScheme
+                .stream()
+                .filter(e -> e.key().equals(who.getExternalIdKey()))
+                .findAny()
+                .isPresent())) {
+      externalIdsUpdateFactory.create().delete(filteredExtIdsByScheme);
     }
+    return link(to, who);
   }
 
   /**
-   * Unlink an authentication identity from an existing account.
+   * Unlink an external identity from an existing account.
    *
-   * @param from account to unlink the identity from.
-   * @param who the identity to delete
-   * @return the result of unlinking the identity from the user.
-   * @throws AccountException the identity belongs to a different account, or it cannot be unlinked
-   *     at this time.
+   * @param from account to unlink the external identity from
+   * @param extIdKey the key of the external ID that should be deleted
+   * @throws AccountException the identity belongs to a different account, or the identity was not
+   *     found
    */
-  public AuthResult unlink(Account.Id from, AuthRequest who)
-      throws AccountException, OrmException, IOException {
-    try (ReviewDb db = schema.open()) {
-      ExternalId extId = findExternalId(db, who.getExternalIdKey());
+  public void unlink(Account.Id from, ExternalId.Key extIdKey)
+      throws AccountException, OrmException, IOException, ConfigInvalidException {
+    unlink(from, ImmutableList.of(extIdKey));
+  }
+
+  /**
+   * Unlink an external identities from an existing account.
+   *
+   * @param from account to unlink the external identity from
+   * @param extIdKeys the keys of the external IDs that should be deleted
+   * @throws AccountException any of the identity belongs to a different account, or any of the
+   *     identity was not found
+   */
+  public void unlink(Account.Id from, Collection<ExternalId.Key> extIdKeys)
+      throws AccountException, OrmException, IOException, ConfigInvalidException {
+    if (extIdKeys.isEmpty()) {
+      return;
+    }
+
+    List<ExternalId> extIds = new ArrayList<>(extIdKeys.size());
+    for (ExternalId.Key extIdKey : extIdKeys) {
+      ExternalId extId = externalIds.get(extIdKey);
       if (extId != null) {
         if (!extId.accountId().equals(from)) {
-          throw new AccountException(
-              "Identity '" + who.getExternalIdKey().get() + "' in use by another account");
+          throw new AccountException("Identity '" + extIdKey.get() + "' in use by another account");
         }
-        externalIdsUpdateFactory.create().delete(db, extId);
-
-        if (who.getEmailAddress() != null) {
-          Account a = db.accounts().get(from);
-          if (a.getPreferredEmail() != null
-              && a.getPreferredEmail().equals(who.getEmailAddress())) {
-            a.setPreferredEmail(null);
-            db.accounts().update(Collections.singleton(a));
-            byIdCache.evict(from);
-          }
-          byEmailCache.evict(who.getEmailAddress());
-        }
-
+        extIds.add(extId);
       } else {
-        throw new AccountException("Identity '" + who.getExternalIdKey().get() + "' not found");
+        throw new AccountException("Identity '" + extIdKey.get() + "' not found");
       }
+    }
 
-      return new AuthResult(from, who.getExternalIdKey(), false);
+    externalIdsUpdateFactory.create().delete(extIds);
+
+    if (extIds.stream().anyMatch(e -> e.email() != null)) {
+      accountsUpdateFactory
+          .create()
+          .update(
+              from,
+              a -> {
+                if (a.getPreferredEmail() != null) {
+                  for (ExternalId extId : extIds) {
+                    if (a.getPreferredEmail().equals(extId.email())) {
+                      a.setPreferredEmail(null);
+                      break;
+                    }
+                  }
+                }
+              });
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
index 7e1157c..5eee8d1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountResolver.java
@@ -17,36 +17,41 @@
 import static java.util.stream.Collectors.toSet;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class AccountResolver {
   private final Realm realm;
-  private final AccountByEmailCache byEmail;
+  private final Accounts accounts;
   private final AccountCache byId;
   private final Provider<InternalAccountQuery> accountQueryProvider;
+  private final Emails emails;
 
   @Inject
   AccountResolver(
       Realm realm,
-      AccountByEmailCache byEmail,
+      Accounts accounts,
       AccountCache byId,
-      Provider<InternalAccountQuery> accountQueryProvider) {
+      Provider<InternalAccountQuery> accountQueryProvider,
+      Emails emails) {
     this.realm = realm;
-    this.byEmail = byEmail;
+    this.accounts = accounts;
     this.byId = byId;
     this.accountQueryProvider = accountQueryProvider;
+    this.emails = emails;
   }
 
   /**
@@ -58,8 +63,8 @@
    * @return the single account that matches; null if no account matches or there are multiple
    *     candidates.
    */
-  public Account find(ReviewDb db, String nameOrEmail) throws OrmException {
-    Set<Account.Id> r = findAll(db, nameOrEmail);
+  public Account find(String nameOrEmail) throws OrmException, IOException, ConfigInvalidException {
+    Set<Account.Id> r = findAll(nameOrEmail);
     if (r.size() == 1) {
       return byId.get(r.iterator().next()).getAccount();
     }
@@ -81,17 +86,17 @@
   /**
    * Find all accounts matching the name or name/email string.
    *
-   * @param db open database handle.
    * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
    *     address ("email@example"), a full name ("Full Name"), an account id ("18419") or an user
    *     name ("username").
    * @return the accounts that match, empty collection if none. Never null.
    */
-  public Set<Account.Id> findAll(ReviewDb db, String nameOrEmail) throws OrmException {
+  public Set<Account.Id> findAll(String nameOrEmail)
+      throws OrmException, IOException, ConfigInvalidException {
     Matcher m = Pattern.compile("^.* \\(([1-9][0-9]*)\\)$").matcher(nameOrEmail);
     if (m.matches()) {
       Account.Id id = Account.Id.parse(m.group(1));
-      if (exists(db, id)) {
+      if (accounts.get(id) != null) {
         return Collections.singleton(id);
       }
       return Collections.emptySet();
@@ -99,7 +104,7 @@
 
     if (nameOrEmail.matches("^[1-9][0-9]*$")) {
       Account.Id id = Account.Id.parse(nameOrEmail);
-      if (exists(db, id)) {
+      if (accounts.get(id) != null) {
         return Collections.singleton(id);
       }
       return Collections.emptySet();
@@ -112,40 +117,34 @@
       }
     }
 
-    return findAllByNameOrEmail(db, nameOrEmail);
-  }
-
-  private boolean exists(ReviewDb db, Account.Id id) throws OrmException {
-    return db.accounts().get(id) != null;
+    return findAllByNameOrEmail(nameOrEmail);
   }
 
   /**
    * Locate exactly one account matching the name or name/email string.
    *
-   * @param db open database handle.
    * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
    *     address ("email@example"), a full name ("Full Name").
    * @return the single account that matches; null if no account matches or there are multiple
    *     candidates.
    */
-  public Account findByNameOrEmail(ReviewDb db, String nameOrEmail) throws OrmException {
-    Set<Account.Id> r = findAllByNameOrEmail(db, nameOrEmail);
+  public Account findByNameOrEmail(String nameOrEmail) throws OrmException, IOException {
+    Set<Account.Id> r = findAllByNameOrEmail(nameOrEmail);
     return r.size() == 1 ? byId.get(r.iterator().next()).getAccount() : null;
   }
 
   /**
    * Locate exactly one account matching the name or name/email string.
    *
-   * @param db open database handle.
    * @param nameOrEmail a string of the format "Full Name &lt;email@example&gt;", just the email
    *     address ("email@example"), a full name ("Full Name").
    * @return the accounts that match, empty collection if none. Never null.
    */
-  public Set<Account.Id> findAllByNameOrEmail(ReviewDb db, String nameOrEmail) throws OrmException {
+  public Set<Account.Id> findAllByNameOrEmail(String nameOrEmail) throws OrmException, IOException {
     int lt = nameOrEmail.indexOf('<');
     int gt = nameOrEmail.indexOf('>');
     if (lt >= 0 && gt > lt && nameOrEmail.contains("@")) {
-      Set<Account.Id> ids = byEmail.get(nameOrEmail.substring(lt + 1, gt));
+      Set<Account.Id> ids = emails.getAccountFor(nameOrEmail.substring(lt + 1, gt));
       if (ids.isEmpty() || ids.size() == 1) {
         return ids;
       }
@@ -163,7 +162,7 @@
     }
 
     if (nameOrEmail.contains("@")) {
-      return byEmail.get(nameOrEmail);
+      return emails.getAccountFor(nameOrEmail);
     }
 
     Account.Id id = realm.lookup(nameOrEmail);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
index ad3b634..770ecf5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountState.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Function;
 import com.google.common.base.Strings;
@@ -23,11 +23,12 @@
 import com.google.common.cache.CacheBuilder;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser.PropertyKey;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.config.AllUsersName;
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.Map;
@@ -42,24 +43,28 @@
   public static final Function<AccountState, Account.Id> ACCOUNT_ID_FUNCTION =
       a -> a.getAccount().getId();
 
+  private final AllUsersName allUsersName;
   private final Account account;
-  private final Set<AccountGroup.UUID> internalGroups;
   private final Collection<ExternalId> externalIds;
   private final Map<ProjectWatchKey, Set<NotifyType>> projectWatches;
   private Cache<IdentifiedUser.PropertyKey<Object>, Object> properties;
 
   public AccountState(
+      AllUsersName allUsersName,
       Account account,
-      Set<AccountGroup.UUID> actualGroups,
       Collection<ExternalId> externalIds,
       Map<ProjectWatchKey, Set<NotifyType>> projectWatches) {
+    this.allUsersName = allUsersName;
     this.account = account;
-    this.internalGroups = actualGroups;
     this.externalIds = externalIds;
     this.projectWatches = projectWatches;
     this.account.setUserName(getUserName(externalIds));
   }
 
+  public AllUsersName getAllUsersNameForIndexing() {
+    return allUsersName;
+  }
+
   /** Get the cached account metadata. */
   public Account getAccount() {
     return account;
@@ -107,11 +112,6 @@
     return projectWatches;
   }
 
-  /** The set of groups maintained directly within the Gerrit database. */
-  public Set<AccountGroup.UUID> getInternalGroups() {
-    return internalGroups;
-  }
-
   public static String getUserName(Collection<ExternalId> ids) {
     for (ExternalId extId : ids) {
       if (extId.isScheme(SCHEME_USERNAME)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java
index 19fd34d..f1a2555 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountUserNameException.java
@@ -21,7 +21,7 @@
 public class AccountUserNameException extends AccountException {
   private static final long serialVersionUID = 1L;
 
-  public AccountUserNameException(final String message, final Throwable why) {
+  public AccountUserNameException(String message, Throwable why) {
     super(message, why);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibility.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibility.java
deleted file mode 100644
index 9957134..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibility.java
+++ /dev/null
@@ -1,33 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-/** 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-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
index 4521cd5..ef0a917 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountVisibilityProvider.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Accounts.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Accounts.java
new file mode 100644
index 0000000..939eaf2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Accounts.java
@@ -0,0 +1,151 @@
+// 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.account;
+
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Class to access accounts. */
+@Singleton
+public class Accounts {
+  private static final Logger log = LoggerFactory.getLogger(Accounts.class);
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final OutgoingEmailValidator emailValidator;
+
+  @Inject
+  Accounts(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      OutgoingEmailValidator emailValidator) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.emailValidator = emailValidator;
+  }
+
+  public Account get(Account.Id accountId) throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return read(repo, accountId);
+    }
+  }
+
+  public List<Account> get(Collection<Account.Id> accountIds)
+      throws IOException, ConfigInvalidException {
+    List<Account> accounts = new ArrayList<>(accountIds.size());
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      for (Account.Id accountId : accountIds) {
+        accounts.add(read(repo, accountId));
+      }
+    }
+    return accounts;
+  }
+
+  /**
+   * Returns all accounts.
+   *
+   * @return all accounts
+   */
+  public List<Account> all() throws IOException {
+    Set<Account.Id> accountIds = allIds();
+    List<Account> accounts = new ArrayList<>(accountIds.size());
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      for (Account.Id accountId : accountIds) {
+        try {
+          accounts.add(read(repo, accountId));
+        } catch (Exception e) {
+          log.error("Ignoring invalid account {}", accountId.get(), e);
+        }
+      }
+    }
+    return accounts;
+  }
+
+  /**
+   * Returns all account IDs.
+   *
+   * @return all account IDs
+   */
+  public Set<Account.Id> allIds() throws IOException {
+    return readUserRefs().collect(toSet());
+  }
+
+  /**
+   * Returns the first n account IDs.
+   *
+   * @param n the number of account IDs that should be returned
+   * @return first n account IDs
+   */
+  public List<Account.Id> firstNIds(int n) throws IOException {
+    return readUserRefs().sorted(comparing(id -> id.get())).limit(n).collect(toList());
+  }
+
+  /**
+   * Checks if any account exists.
+   *
+   * @return {@code true} if at least one account exists, otherwise {@code false}
+   */
+  public boolean hasAnyAccount() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return hasAnyAccount(repo);
+    }
+  }
+
+  public static boolean hasAnyAccount(Repository repo) throws IOException {
+    return readUserRefs(repo).findAny().isPresent();
+  }
+
+  private Stream<Account.Id> readUserRefs() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return readUserRefs(repo);
+    }
+  }
+
+  private Account read(Repository allUsersRepository, Account.Id accountId)
+      throws IOException, ConfigInvalidException {
+    AccountConfig accountConfig = new AccountConfig(emailValidator, accountId);
+    accountConfig.load(allUsersRepository);
+    return accountConfig.getAccount();
+  }
+
+  public static Stream<Account.Id> readUserRefs(Repository repo) throws IOException {
+    return repo.getRefDatabase()
+        .getRefs(RefNames.REFS_USERS)
+        .values()
+        .stream()
+        .map(r -> Account.Id.fromRef(r.getName()))
+        .filter(Objects::nonNull);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
index 081ea26..19a8259 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
@@ -33,11 +32,12 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class AccountsCollection
     implements RestCollection<TopLevelResource, AccountResource>, AcceptsCreate<TopLevelResource> {
-  private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> self;
   private final AccountResolver resolver;
   private final AccountControl.Factory accountControlFactory;
@@ -48,7 +48,6 @@
 
   @Inject
   AccountsCollection(
-      Provider<ReviewDb> db,
       Provider<CurrentUser> self,
       AccountResolver resolver,
       AccountControl.Factory accountControlFactory,
@@ -56,7 +55,6 @@
       Provider<QueryAccounts> list,
       DynamicMap<RestView<AccountResource>> views,
       CreateAccount.Factory createAccountFactory) {
-    this.db = db;
     this.self = self;
     this.resolver = resolver;
     this.accountControlFactory = accountControlFactory;
@@ -68,7 +66,8 @@
 
   @Override
   public AccountResource parse(TopLevelResource root, IdString id)
-      throws ResourceNotFoundException, AuthException, OrmException {
+      throws ResourceNotFoundException, AuthException, OrmException, IOException,
+          ConfigInvalidException {
     IdentifiedUser user = parseId(id.get());
     if (user == null) {
       throw new ResourceNotFoundException(id);
@@ -89,7 +88,8 @@
    *     account is not visible to the calling user
    */
   public IdentifiedUser parse(String id)
-      throws AuthException, UnprocessableEntityException, OrmException {
+      throws AuthException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException {
     return parseOnBehalfOf(null, id);
   }
 
@@ -104,8 +104,11 @@
    * @throws AuthException thrown if 'self' is used as account ID and the current user is not
    *     authenticated
    * @throws OrmException
+   * @throws ConfigInvalidException
+   * @throws IOException
    */
-  public IdentifiedUser parseId(String id) throws AuthException, OrmException {
+  public IdentifiedUser parseId(String id)
+      throws AuthException, OrmException, IOException, ConfigInvalidException {
     return parseIdOnBehalfOf(null, id);
   }
 
@@ -113,7 +116,8 @@
    * Like {@link #parse(String)}, but also sets the {@link CurrentUser#getRealUser()} on the result.
    */
   public IdentifiedUser parseOnBehalfOf(@Nullable CurrentUser caller, String id)
-      throws AuthException, UnprocessableEntityException, OrmException {
+      throws AuthException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException {
     IdentifiedUser user = parseIdOnBehalfOf(caller, id);
     if (user == null) {
       throw new UnprocessableEntityException(String.format("Account Not Found: %s", id));
@@ -124,7 +128,7 @@
   }
 
   private IdentifiedUser parseIdOnBehalfOf(@Nullable CurrentUser caller, String id)
-      throws AuthException, OrmException {
+      throws AuthException, OrmException, IOException, ConfigInvalidException {
     if (id.equals("self")) {
       CurrentUser user = self.get();
       if (user.isIdentifiedUser()) {
@@ -136,7 +140,7 @@
       }
     }
 
-    Account match = resolver.find(db.get(), id);
+    Account match = resolver.find(id);
     if (match == null) {
       return null;
     }
@@ -154,7 +158,6 @@
     return views;
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public CreateAccount create(TopLevelResource parent, IdString username) {
     return createAccountFactory.create(username.get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
new file mode 100644
index 0000000..0085303
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsConsistencyChecker.java
@@ -0,0 +1,61 @@
+// 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.account;
+
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+@Singleton
+public class AccountsConsistencyChecker {
+  private final Accounts accounts;
+  private final ExternalIds externalIds;
+
+  @Inject
+  AccountsConsistencyChecker(Accounts accounts, ExternalIds externalIds) {
+    this.accounts = accounts;
+    this.externalIds = externalIds;
+  }
+
+  public List<ConsistencyProblemInfo> check() throws IOException {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    for (Account account : accounts.all()) {
+      if (account.getPreferredEmail() != null) {
+        if (!externalIds
+            .byAccount(account.getId())
+            .stream()
+            .anyMatch(e -> account.getPreferredEmail().equals(e.email()))) {
+          addError(
+              String.format(
+                  "Account '%s' has no external ID for its preferred email '%s'",
+                  account.getId().get(), account.getPreferredEmail()),
+              problems);
+        }
+      }
+    }
+
+    return problems;
+  }
+
+  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
+    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java
new file mode 100644
index 0000000..6f11015
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsUpdate.java
@@ -0,0 +1,356 @@
+// 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.account;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.function.Consumer;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+
+/**
+ * Updates accounts.
+ *
+ * <p>The account updates are written to NoteDb.
+ *
+ * <p>In NoteDb accounts are represented as user branches in the All-Users repository. Optionally a
+ * user branch can contain a 'account.config' file that stores account properties, such as full
+ * name, preferred email, status and the active flag. The timestamp of the first commit on a user
+ * branch denotes the registration date. The initial commit on the user branch may be empty (since
+ * having an 'account.config' is optional). See {@link AccountConfig} for details of the
+ * 'account.config' file format.
+ *
+ * <p>On updating accounts the accounts are evicted from the account cache and thus reindexed. The
+ * eviction from the account cache is done by the {@link ReindexAfterRefUpdate} class which receives
+ * the event about updating the user branch that is triggered by this class.
+ */
+@Singleton
+public class AccountsUpdate {
+  /**
+   * Factory to create an AccountsUpdate instance for updating accounts by the Gerrit server.
+   *
+   * <p>The Gerrit server identity will be used as author and committer for all commits that update
+   * the accounts.
+   */
+  @Singleton
+  public static class Server {
+    private final GitRepositoryManager repoManager;
+    private final GitReferenceUpdated gitRefUpdated;
+    private final AllUsersName allUsersName;
+    private final OutgoingEmailValidator emailValidator;
+    private final Provider<PersonIdent> serverIdent;
+    private final Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory;
+
+    @Inject
+    public Server(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        OutgoingEmailValidator emailValidator,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        Provider<MetaDataUpdate.Server> metaDataUpdateServerFactory) {
+      this.repoManager = repoManager;
+      this.gitRefUpdated = gitRefUpdated;
+      this.allUsersName = allUsersName;
+      this.emailValidator = emailValidator;
+      this.serverIdent = serverIdent;
+      this.metaDataUpdateServerFactory = metaDataUpdateServerFactory;
+    }
+
+    public AccountsUpdate create() {
+      PersonIdent i = serverIdent.get();
+      return new AccountsUpdate(
+          repoManager,
+          gitRefUpdated,
+          null,
+          allUsersName,
+          emailValidator,
+          i,
+          () -> metaDataUpdateServerFactory.get().create(allUsersName));
+    }
+  }
+
+  /**
+   * Factory to create an AccountsUpdate instance for updating accounts by the current user.
+   *
+   * <p>The identity of the current user will be used as author for all commits that update the
+   * accounts. The Gerrit server identity will be used as committer.
+   */
+  @Singleton
+  public static class User {
+    private final GitRepositoryManager repoManager;
+    private final GitReferenceUpdated gitRefUpdated;
+    private final AllUsersName allUsersName;
+    private final OutgoingEmailValidator emailValidator;
+    private final Provider<PersonIdent> serverIdent;
+    private final Provider<IdentifiedUser> identifiedUser;
+    private final Provider<MetaDataUpdate.User> metaDataUpdateUserFactory;
+
+    @Inject
+    public User(
+        GitRepositoryManager repoManager,
+        GitReferenceUpdated gitRefUpdated,
+        AllUsersName allUsersName,
+        OutgoingEmailValidator emailValidator,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        Provider<IdentifiedUser> identifiedUser,
+        Provider<MetaDataUpdate.User> metaDataUpdateUserFactory) {
+      this.repoManager = repoManager;
+      this.gitRefUpdated = gitRefUpdated;
+      this.allUsersName = allUsersName;
+      this.serverIdent = serverIdent;
+      this.emailValidator = emailValidator;
+      this.identifiedUser = identifiedUser;
+      this.metaDataUpdateUserFactory = metaDataUpdateUserFactory;
+    }
+
+    public AccountsUpdate create() {
+      IdentifiedUser user = identifiedUser.get();
+      PersonIdent i = serverIdent.get();
+      return new AccountsUpdate(
+          repoManager,
+          gitRefUpdated,
+          user,
+          allUsersName,
+          emailValidator,
+          createPersonIdent(i, user),
+          () -> metaDataUpdateUserFactory.get().create(allUsersName));
+    }
+
+    private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
+      return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+    }
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
+  @Nullable private final IdentifiedUser currentUser;
+  private final AllUsersName allUsersName;
+  private final OutgoingEmailValidator emailValidator;
+  private final PersonIdent committerIdent;
+  private final MetaDataUpdateFactory metaDataUpdateFactory;
+
+  private AccountsUpdate(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      @Nullable IdentifiedUser currentUser,
+      AllUsersName allUsersName,
+      OutgoingEmailValidator emailValidator,
+      PersonIdent committerIdent,
+      MetaDataUpdateFactory metaDataUpdateFactory) {
+    this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
+    this.currentUser = currentUser;
+    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
+    this.emailValidator = checkNotNull(emailValidator, "emailValidator");
+    this.committerIdent = checkNotNull(committerIdent, "committerIdent");
+    this.metaDataUpdateFactory = checkNotNull(metaDataUpdateFactory, "metaDataUpdateFactory");
+  }
+
+  /**
+   * Inserts a new account.
+   *
+   * @param accountId ID of the new account
+   * @param init consumer to populate the new account
+   * @return the newly created account
+   * @throws OrmDuplicateKeyException if the account already exists
+   * @throws IOException if updating the user branch fails
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  public Account insert(Account.Id accountId, Consumer<Account> init)
+      throws OrmDuplicateKeyException, IOException, ConfigInvalidException {
+    AccountConfig accountConfig = read(accountId);
+    Account account = accountConfig.getNewAccount();
+    init.accept(account);
+
+    // Create in NoteDb
+    commitNew(accountConfig);
+    return account;
+  }
+
+  /**
+   * Gets the account and updates it atomically.
+   *
+   * <p>Changing the registration date of an account is not supported.
+   *
+   * @param accountId ID of the account
+   * @param consumer consumer to update the account, only invoked if the account exists
+   * @return the updated account, {@code null} if the account doesn't exist
+   * @throws IOException if updating the user branch fails
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  public Account update(Account.Id accountId, Consumer<Account> consumer)
+      throws IOException, ConfigInvalidException {
+    return update(accountId, ImmutableList.of(consumer));
+  }
+
+  /**
+   * Gets the account and updates it atomically.
+   *
+   * <p>Changing the registration date of an account is not supported.
+   *
+   * @param accountId ID of the account
+   * @param consumers consumers to update the account, only invoked if the account exists
+   * @return the updated account, {@code null} if the account doesn't exist
+   * @throws IOException if updating the user branch fails
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   */
+  public Account update(Account.Id accountId, List<Consumer<Account>> consumers)
+      throws IOException, ConfigInvalidException {
+    if (consumers.isEmpty()) {
+      return null;
+    }
+
+    AccountConfig accountConfig = read(accountId);
+    Account account = accountConfig.getAccount();
+    if (account != null) {
+      consumers.stream().forEach(c -> c.accept(account));
+      commit(accountConfig);
+    }
+
+    return account;
+  }
+
+  /**
+   * Replaces the account.
+   *
+   * <p>The existing account with the same account ID is overwritten by the given account. Choosing
+   * to overwrite an account means that any updates that were done to the account by a racing
+   * request after the account was read are lost. Updates are also lost if the account was read from
+   * a stale account index. This is why using {@link
+   * #update(com.google.gerrit.reviewdb.client.Account.Id, Consumer)} to do an atomic update is
+   * always preferred.
+   *
+   * <p>Changing the registration date of an account is not supported.
+   *
+   * @param account the new account
+   * @throws IOException if updating the user branch fails
+   * @throws ConfigInvalidException if any of the account fields has an invalid value
+   * @see #update(com.google.gerrit.reviewdb.client.Account.Id, Consumer)
+   */
+  public void replace(Account account) throws IOException, ConfigInvalidException {
+    AccountConfig accountConfig = read(account.getId());
+    accountConfig.setAccount(account);
+    commit(accountConfig);
+  }
+
+  /**
+   * Deletes the account.
+   *
+   * @param account the account that should be deleted
+   * @throws IOException if updating the user branch fails
+   */
+  public void delete(Account account) throws IOException {
+    deleteByKey(account.getId());
+  }
+
+  /**
+   * Deletes the account.
+   *
+   * @param accountId the ID of the account that should be deleted
+   * @throws IOException if updating the user branch fails
+   */
+  public void deleteByKey(Account.Id accountId) throws IOException {
+    deleteUserBranch(accountId);
+  }
+
+  private void deleteUserBranch(Account.Id accountId) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      deleteUserBranch(repo, allUsersName, gitRefUpdated, currentUser, committerIdent, accountId);
+    }
+  }
+
+  public static void deleteUserBranch(
+      Repository repo,
+      Project.NameKey project,
+      GitReferenceUpdated gitRefUpdated,
+      @Nullable IdentifiedUser user,
+      PersonIdent refLogIdent,
+      Account.Id accountId)
+      throws IOException {
+    String refName = RefNames.refsUsers(accountId);
+    Ref ref = repo.exactRef(refName);
+    if (ref == null) {
+      return;
+    }
+
+    RefUpdate ru = repo.updateRef(refName);
+    ru.setExpectedOldObjectId(ref.getObjectId());
+    ru.setNewObjectId(ObjectId.zeroId());
+    ru.setForceUpdate(true);
+    ru.setRefLogIdent(refLogIdent);
+    ru.setRefLogMessage("Delete Account", true);
+    Result result = ru.delete();
+    if (result != Result.FORCED) {
+      throw new IOException(String.format("Failed to delete ref %s: %s", refName, result.name()));
+    }
+    gitRefUpdated.fire(project, ru, user != null ? user.getAccount() : null);
+  }
+
+  private AccountConfig read(Account.Id accountId) throws IOException, ConfigInvalidException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      AccountConfig accountConfig = new AccountConfig(emailValidator, accountId);
+      accountConfig.load(repo);
+      return accountConfig;
+    }
+  }
+
+  private void commitNew(AccountConfig accountConfig) throws IOException {
+    // When creating a new account we must allow empty commits so that the user branch gets created
+    // with an empty commit when no account properties are set and hence no 'account.config' file
+    // will be created.
+    commit(accountConfig, true);
+  }
+
+  private void commit(AccountConfig accountConfig) throws IOException {
+    commit(accountConfig, false);
+  }
+
+  private void commit(AccountConfig accountConfig, boolean allowEmptyCommit) throws IOException {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create()) {
+      md.setAllowEmpty(allowEmptyCommit);
+      accountConfig.commit(md);
+    }
+  }
+
+  @FunctionalInterface
+  private static interface MetaDataUpdateFactory {
+    MetaDataUpdate create() throws IOException;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
index 44b632a..ad9cd68 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AddSshKey.java
@@ -30,6 +30,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AddSshKey.Input;
 import com.google.gerrit.server.mail.send.AddKeySender;
+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.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -50,6 +53,7 @@
   }
 
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
   private final AddKeySender.Factory addKeyFactory;
@@ -57,10 +61,12 @@
   @Inject
   AddSshKey(
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
       AddKeySender.Factory addKeyFactory) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
     this.addKeyFactory = addKeyFactory;
@@ -68,10 +74,10 @@
 
   @Override
   public Response<SshKeyInfo> apply(AccountResource rsrc, Input input)
-      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to add SSH keys");
+      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
     return apply(rsrc.getUser(), input);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
index d1dd4b0..6647ca4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java
@@ -14,9 +14,11 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_EXTERNAL;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
+
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 /**
  * Information for {@link AccountManager#authenticate(AuthRequest)}.
@@ -61,6 +63,8 @@
   private boolean skipAuthentication;
   private String authPlugin;
   private String authProvider;
+  private boolean authProvidesAccountActiveStatus;
+  private boolean active;
 
   public AuthRequest(ExternalId.Key externalId) {
     this.externalId = externalId;
@@ -87,7 +91,7 @@
     return password;
   }
 
-  public void setPassword(final String pass) {
+  public void setPassword(String pass) {
     password = pass;
   }
 
@@ -95,7 +99,7 @@
     return displayName;
   }
 
-  public void setDisplayName(final String name) {
+  public void setDisplayName(String name) {
     displayName = name != null && name.length() > 0 ? name : null;
   }
 
@@ -103,7 +107,7 @@
     return emailAddress;
   }
 
-  public void setEmailAddress(final String email) {
+  public void setEmailAddress(String email) {
     emailAddress = email != null && email.length() > 0 ? email : null;
   }
 
@@ -111,7 +115,7 @@
     return userName;
   }
 
-  public void setUserName(final String user) {
+  public void setUserName(String user) {
     userName = user;
   }
 
@@ -138,4 +142,20 @@
   public void setAuthProvider(String authProvider) {
     this.authProvider = authProvider;
   }
+
+  public boolean authProvidesAccountActiveStatus() {
+    return authProvidesAccountActiveStatus;
+  }
+
+  public void setAuthProvidesAccountActiveStatus(boolean authProvidesAccountActiveStatus) {
+    this.authProvidesAccountActiveStatus = authProvidesAccountActiveStatus;
+  }
+
+  public boolean isActive() {
+    return active;
+  }
+
+  public void setActive(Boolean isActive) {
+    this.active = isActive;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
index 4aced52..2b1bc96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthResult.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 /** Result from {@link AccountManager#authenticate(AuthRequest)}. */
 public class AuthResult {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
index e53b7d0..8c97e17 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Capabilities.java
@@ -14,7 +14,8 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
@@ -22,7 +23,11 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource.Capability;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -30,15 +35,18 @@
 @Singleton
 class Capabilities implements ChildCollection<AccountResource, AccountResource.Capability> {
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final DynamicMap<RestView<AccountResource.Capability>> views;
   private final Provider<GetCapabilities> get;
 
   @Inject
   Capabilities(
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       DynamicMap<RestView<AccountResource.Capability>> views,
       Provider<GetCapabilities> get) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.views = views;
     this.get = get;
   }
@@ -50,21 +58,39 @@
 
   @Override
   public Capability parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException {
-    if (!self.get().hasSameAccountId(parent.getUser())
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("restricted to administrator");
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
+    IdentifiedUser target = parent.getUser();
+    if (!self.get().hasSameAccountId(target)) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
-    String name = id.get();
-    CapabilityControl cap = parent.getUser().getCapabilities();
-    if (cap.canPerform(name)
-        || (cap.canAdministrateServer() && GlobalCapability.isCapability(name))) {
-      return new AccountResource.Capability(parent.getUser(), name);
+    GlobalOrPluginPermission perm = parse(id);
+    if (permissionBackend.user(target).test(perm)) {
+      return new AccountResource.Capability(target, perm.permissionName());
     }
     throw new ResourceNotFoundException(id);
   }
 
+  private GlobalOrPluginPermission parse(IdString id) throws ResourceNotFoundException {
+    String name = id.get();
+    GlobalOrPluginPermission perm = GlobalPermission.byName(name);
+    if (perm != null) {
+      return perm;
+    }
+
+    int dash = name.lastIndexOf('-');
+    if (dash < 0) {
+      throw new ResourceNotFoundException(id);
+    }
+
+    String pluginName = name.substring(0, dash);
+    String capability = name.substring(dash + 1);
+    if (pluginName.isEmpty() || capability.isEmpty()) {
+      throw new ResourceNotFoundException(id);
+    }
+    return new PluginPermission(pluginName, capability);
+  }
+
   @Override
   public DynamicMap<RestView<Capability>> views() {
     return views;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
deleted file mode 100644
index 66d0bf9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityControl.java
+++ /dev/null
@@ -1,276 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import static com.google.common.base.Predicates.not;
-
-import com.google.common.base.Predicate;
-import com.google.common.collect.FluentIterable;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PeerDaemonUser;
-import com.google.gerrit.server.git.QueueProvider;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-/** Access control management for server-wide capabilities. */
-public class CapabilityControl {
-  public interface Factory {
-    CapabilityControl create(CurrentUser user);
-  }
-
-  private final CapabilityCollection capabilities;
-  private final CurrentUser user;
-  private final Map<String, List<PermissionRule>> effective;
-
-  private Boolean canAdministrateServer;
-  private Boolean canEmailReviewers;
-
-  @Inject
-  CapabilityControl(ProjectCache projectCache, @Assisted CurrentUser currentUser) {
-    capabilities = projectCache.getAllProjects().getCapabilityCollection();
-    user = currentUser;
-    effective = new HashMap<>();
-  }
-
-  /** Identity of the user the control will compute for. */
-  public CurrentUser getUser() {
-    return user;
-  }
-
-  /** @return true if the user can administer this server. */
-  public boolean canAdministrateServer() {
-    if (canAdministrateServer == null) {
-      if (user.getRealUser() != user) {
-        canAdministrateServer = false;
-      } else {
-        canAdministrateServer =
-            user instanceof PeerDaemonUser
-                || matchAny(capabilities.administrateServer, ALLOWED_RULE);
-      }
-    }
-    return canAdministrateServer;
-  }
-
-  /** @return true if the user can create an account for another user. */
-  public boolean canCreateAccount() {
-    return canPerform(GlobalCapability.CREATE_ACCOUNT) || canAdministrateServer();
-  }
-
-  /** @return true if the user can create a group. */
-  public boolean canCreateGroup() {
-    return canPerform(GlobalCapability.CREATE_GROUP) || canAdministrateServer();
-  }
-
-  /** @return true if the user can create a project. */
-  public boolean canCreateProject() {
-    return canPerform(GlobalCapability.CREATE_PROJECT) || canAdministrateServer();
-  }
-
-  /** @return true if the user can email reviewers. */
-  public boolean canEmailReviewers() {
-    if (canEmailReviewers == null) {
-      canEmailReviewers =
-          matchAny(capabilities.emailReviewers, ALLOWED_RULE)
-              || !matchAny(capabilities.emailReviewers, not(ALLOWED_RULE));
-    }
-    return canEmailReviewers;
-  }
-
-  /** @return true if the user can kill any running task. */
-  public boolean canKillTask() {
-    return canPerform(GlobalCapability.KILL_TASK) || canMaintainServer();
-  }
-
-  /** @return true if the user can modify an account for another user. */
-  public boolean canModifyAccount() {
-    return canPerform(GlobalCapability.MODIFY_ACCOUNT) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view all accounts. */
-  public boolean canViewAllAccounts() {
-    return canPerform(GlobalCapability.VIEW_ALL_ACCOUNTS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view the server caches. */
-  public boolean canViewCaches() {
-    return canPerform(GlobalCapability.VIEW_CACHES) || canMaintainServer();
-  }
-
-  /** @return true if the user can flush the server's caches. */
-  public boolean canFlushCaches() {
-    return canPerform(GlobalCapability.FLUSH_CACHES) || canMaintainServer();
-  }
-
-  /** @return true if the user can perform basic server maintenance. */
-  public boolean canMaintainServer() {
-    return canPerform(GlobalCapability.MAINTAIN_SERVER) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view open connections. */
-  public boolean canViewConnections() {
-    return canPerform(GlobalCapability.VIEW_CONNECTIONS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view the installed plugins. */
-  public boolean canViewPlugins() {
-    return canPerform(GlobalCapability.VIEW_PLUGINS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can view the entire queue. */
-  public boolean canViewQueue() {
-    return canPerform(GlobalCapability.VIEW_QUEUE) || canMaintainServer();
-  }
-
-  /** @return true if the user can access the database (with gsql). */
-  public boolean canAccessDatabase() {
-    return canPerform(GlobalCapability.ACCESS_DATABASE);
-  }
-
-  /** @return true if the user can stream Gerrit events. */
-  public boolean canStreamEvents() {
-    return canPerform(GlobalCapability.STREAM_EVENTS) || canAdministrateServer();
-  }
-
-  /** @return true if the user can run the Git garbage collection. */
-  public boolean canRunGC() {
-    return canPerform(GlobalCapability.RUN_GC) || canMaintainServer();
-  }
-
-  /** @return true if the user can impersonate another user. */
-  public boolean canRunAs() {
-    return canPerform(GlobalCapability.RUN_AS);
-  }
-
-  /** @return which priority queue the user's tasks should be submitted to. */
-  public QueueProvider.QueueType getQueueType() {
-    // If a non-generic group (that is not Anonymous Users or Registered Users)
-    // grants us INTERACTIVE permission, use the INTERACTIVE queue even if
-    // BATCH was otherwise granted. This allows site administrators to grant
-    // INTERACTIVE to Registered Users, and BATCH to 'CI Servers' and have
-    // the 'CI Servers' actually use the BATCH queue while everyone else gets
-    // to use the INTERACTIVE queue without additional grants.
-    //
-    GroupMembership groups = user.getEffectiveGroups();
-    boolean batch = false;
-    for (PermissionRule r : capabilities.priority) {
-      if (match(groups, r)) {
-        switch (r.getAction()) {
-          case INTERACTIVE:
-            if (!SystemGroupBackend.isAnonymousOrRegistered(r.getGroup())) {
-              return QueueProvider.QueueType.INTERACTIVE;
-            }
-            break;
-
-          case BATCH:
-            batch = true;
-            break;
-
-          case ALLOW:
-          case BLOCK:
-          case DENY:
-            break;
-        }
-      }
-    }
-
-    if (batch) {
-      // If any of our groups matched to the BATCH queue, use it.
-      return QueueProvider.QueueType.BATCH;
-    }
-    return QueueProvider.QueueType.INTERACTIVE;
-  }
-
-  /** True if the user has this permission. Works only for non labels. */
-  public boolean canPerform(String permissionName) {
-    if (GlobalCapability.ADMINISTRATE_SERVER.equals(permissionName)) {
-      return canAdministrateServer();
-    }
-    return !access(permissionName).isEmpty();
-  }
-
-  /** The range of permitted values associated with a label permission. */
-  public PermissionRange getRange(String permission) {
-    if (GlobalCapability.hasRange(permission)) {
-      return toRange(permission, access(permission));
-    }
-    return null;
-  }
-
-  private static PermissionRange toRange(String permissionName, List<PermissionRule> ruleList) {
-    int min = 0;
-    int max = 0;
-    if (ruleList.isEmpty()) {
-      PermissionRange.WithDefaults defaultRange = GlobalCapability.getRange(permissionName);
-      if (defaultRange != null) {
-        min = defaultRange.getDefaultMin();
-        max = defaultRange.getDefaultMax();
-      }
-    } else {
-      for (PermissionRule rule : ruleList) {
-        min = Math.min(min, rule.getMin());
-        max = Math.max(max, rule.getMax());
-      }
-    }
-    return new PermissionRange(permissionName, min, max);
-  }
-
-  /** Rules for the given permission, or the empty list. */
-  private List<PermissionRule> access(String permissionName) {
-    List<PermissionRule> rules = effective.get(permissionName);
-    if (rules != null) {
-      return rules;
-    }
-
-    rules = capabilities.getPermission(permissionName);
-    GroupMembership groups = user.getEffectiveGroups();
-
-    List<PermissionRule> mine = new ArrayList<>(rules.size());
-    for (PermissionRule rule : rules) {
-      if (match(groups, rule)) {
-        mine.add(rule);
-      }
-    }
-
-    if (mine.isEmpty()) {
-      mine = Collections.emptyList();
-    }
-    effective.put(permissionName, mine);
-    return mine;
-  }
-
-  private static final Predicate<PermissionRule> ALLOWED_RULE = r -> r.getAction() == Action.ALLOW;
-
-  private boolean matchAny(Collection<PermissionRule> rules, Predicate<PermissionRule> predicate) {
-    return user.getEffectiveGroups()
-        .containsAnyOf(
-            FluentIterable.from(rules).filter(predicate).transform(r -> r.getGroup().getUUID()));
-  }
-
-  private static boolean match(GroupMembership groups, PermissionRule rule) {
-    return groups.contains(rule.getGroup().getUUID());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
deleted file mode 100644
index 27a196c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CapabilityUtils.java
+++ /dev/null
@@ -1,130 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.extensions.annotations.CapabilityScope;
-import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.server.CurrentUser;
-import com.google.inject.Provider;
-import java.lang.annotation.Annotation;
-import java.util.Arrays;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class CapabilityUtils {
-  private static final Logger log = LoggerFactory.getLogger(CapabilityUtils.class);
-
-  public static void checkRequiresCapability(
-      Provider<CurrentUser> userProvider, String pluginName, Class<?> clazz) throws AuthException {
-    checkRequiresCapability(userProvider.get(), pluginName, clazz);
-  }
-
-  public static void checkRequiresCapability(CurrentUser user, String pluginName, Class<?> clazz)
-      throws AuthException {
-    RequiresCapability rc = getClassAnnotation(clazz, RequiresCapability.class);
-    RequiresAnyCapability rac = getClassAnnotation(clazz, RequiresAnyCapability.class);
-    if (rc != null && rac != null) {
-      log.error(
-          "Class {} uses both @{} and @{}",
-          clazz.getName(),
-          RequiresCapability.class.getSimpleName(),
-          RequiresAnyCapability.class.getSimpleName());
-      throw new AuthException("cannot check capability");
-    }
-    CapabilityControl ctl = user.getCapabilities();
-    if (ctl.canAdministrateServer()) {
-      return;
-    }
-    checkRequiresCapability(ctl, pluginName, clazz, rc);
-    checkRequiresAnyCapability(ctl, pluginName, clazz, rac);
-  }
-
-  private static void checkRequiresCapability(
-      CapabilityControl ctl, String pluginName, Class<?> clazz, RequiresCapability rc)
-      throws AuthException {
-    if (rc == null) {
-      return;
-    }
-    String capability = resolveCapability(pluginName, rc.value(), rc.scope(), clazz);
-    if (!ctl.canPerform(capability)) {
-      throw new AuthException(
-          String.format("Capability %s is required to access this resource", capability));
-    }
-  }
-
-  private static void checkRequiresAnyCapability(
-      CapabilityControl ctl, String pluginName, Class<?> clazz, RequiresAnyCapability rac)
-      throws AuthException {
-    if (rac == null) {
-      return;
-    }
-    if (rac.value().length == 0) {
-      log.error(
-          "Class {} uses @{} with no capabilities listed",
-          clazz.getName(),
-          RequiresAnyCapability.class.getSimpleName());
-      throw new AuthException("cannot check capability");
-    }
-    for (String capability : rac.value()) {
-      capability = resolveCapability(pluginName, capability, rac.scope(), clazz);
-      if (ctl.canPerform(capability)) {
-        return;
-      }
-    }
-    throw new AuthException(
-        "One of the following capabilities is required to access this"
-            + " resource: "
-            + Arrays.asList(rac.value()));
-  }
-
-  private static String resolveCapability(
-      String pluginName, String capability, CapabilityScope scope, Class<?> clazz)
-      throws AuthException {
-    if (pluginName != null
-        && !"gerrit".equals(pluginName)
-        && (scope == CapabilityScope.PLUGIN || scope == CapabilityScope.CONTEXT)) {
-      capability = String.format("%s-%s", pluginName, capability);
-    } else if (scope == CapabilityScope.PLUGIN) {
-      log.error(
-          "Class {} uses @{}(scope={}), but is not within a plugin",
-          clazz.getName(),
-          RequiresCapability.class.getSimpleName(),
-          CapabilityScope.PLUGIN.name());
-      throw new AuthException("cannot check capability");
-    }
-    return capability;
-  }
-
-  /**
-   * Find an instance of the specified annotation, walking up the inheritance tree if necessary.
-   *
-   * @param <T> Annotation type to search for
-   * @param clazz root class to search, may be null
-   * @param annotationClass class object of Annotation subclass to search for
-   * @return the requested annotation or null if none
-   */
-  private static <T extends Annotation> T getClassAnnotation(
-      Class<?> clazz, Class<T> annotationClass) {
-    for (; clazz != null; clazz = clazz.getSuperclass()) {
-      T t = clazz.getAnnotation(annotationClass);
-      if (t != null) {
-        return t;
-      }
-    }
-    return null;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
index b58edfb..aa6baa1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/ChangeUserName.java
@@ -14,13 +14,14 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
-import static java.util.stream.Collectors.toSet;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.errors.NameAlreadyUsedException;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
+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.ssh.SshKeyCache;
 import com.google.gwtjsonrpc.common.VoidResult;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -42,29 +43,26 @@
 
   /** Generic factory to change any user's username. */
   public interface Factory {
-    ChangeUserName create(ReviewDb db, IdentifiedUser user, String newUsername);
+    ChangeUserName create(IdentifiedUser user, String newUsername);
   }
 
-  private final AccountCache accountCache;
   private final SshKeyCache sshKeyCache;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.Server externalIdsUpdateFactory;
 
-  private final ReviewDb db;
   private final IdentifiedUser user;
   private final String newUsername;
 
   @Inject
   ChangeUserName(
-      AccountCache accountCache,
       SshKeyCache sshKeyCache,
+      ExternalIds externalIds,
       ExternalIdsUpdate.Server externalIdsUpdateFactory,
-      @Assisted ReviewDb db,
       @Assisted IdentifiedUser user,
       @Nullable @Assisted String newUsername) {
-    this.accountCache = accountCache;
     this.sshKeyCache = sshKeyCache;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
-    this.db = db;
     this.user = user;
     this.newUsername = newUsername;
   }
@@ -73,11 +71,7 @@
   public VoidResult call()
       throws OrmException, NameAlreadyUsedException, InvalidUserNameException, IOException,
           ConfigInvalidException {
-    Collection<ExternalId> old =
-        ExternalId.from(db.accountExternalIds().byAccount(user.getAccountId()).toList())
-            .stream()
-            .filter(e -> e.isScheme(SCHEME_USERNAME))
-            .collect(toSet());
+    Collection<ExternalId> old = externalIds.byAccount(user.getAccountId(), SCHEME_USERNAME);
     if (!old.isEmpty()) {
       log.error(
           "External id with scheme \"username:\" already exists for the user {}",
@@ -99,13 +93,12 @@
             password = i.password();
           }
         }
-        externalIdsUpdate.insert(db, ExternalId.create(key, user.getAccountId(), null, password));
+        externalIdsUpdate.insert(ExternalId.create(key, user.getAccountId(), null, password));
         log.info("Created the new external Id with key: {}", key);
       } catch (OrmDuplicateKeyException dupeErr) {
         // If we are using this identity, don't report the exception.
         //
-        ExternalId other =
-            ExternalId.from(db.accountExternalIds().get(key.asAccountExternalIdKey()));
+        ExternalId other = externalIds.get(key);
         if (other != null && other.accountId().equals(user.getAccountId())) {
           return VoidResult.INSTANCE;
         }
@@ -118,13 +111,11 @@
 
     // If we have any older user names, remove them.
     //
-    externalIdsUpdate.delete(db, old);
+    externalIdsUpdate.delete(old);
     for (ExternalId extId : old) {
       sshKeyCache.evict(extId.key().id());
-      accountCache.evictByUsername(extId.key().id());
     }
 
-    accountCache.evictByUsername(newUsername);
     sshKeyCache.evict(newUsername);
     return VoidResult.INSTANCE;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
index e7d6994..ef1e8cc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateAccount.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_MAILTO;
 
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescriptions;
+import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.InvalidSshKeyException;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -33,11 +33,15 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.Sequences;
+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.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.group.GroupsUpdate;
+import com.google.gerrit.server.group.UserInitiated;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
@@ -47,7 +51,6 @@
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
@@ -60,53 +63,59 @@
   }
 
   private final ReviewDb db;
-  private final Provider<IdentifiedUser> currentUser;
+  private final Sequences seq;
   private final GroupsCollection groupsCollection;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
-  private final AccountCache accountCache;
-  private final AccountByEmailCache byEmailCache;
+  private final AccountsUpdate.User accountsUpdate;
   private final AccountLoader.Factory infoLoader;
   private final DynamicSet<AccountExternalIdCreator> externalIdCreators;
-  private final AuditService auditService;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdateFactory;
+  private final Provider<GroupsUpdate> groupsUpdate;
+  private final OutgoingEmailValidator validator;
   private final String username;
 
   @Inject
   CreateAccount(
       ReviewDb db,
-      Provider<IdentifiedUser> currentUser,
+      Sequences seq,
       GroupsCollection groupsCollection,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache,
-      AccountCache accountCache,
-      AccountByEmailCache byEmailCache,
+      AccountsUpdate.User accountsUpdate,
       AccountLoader.Factory infoLoader,
       DynamicSet<AccountExternalIdCreator> externalIdCreators,
-      AuditService auditService,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdateFactory,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdate,
+      OutgoingEmailValidator validator,
       @Assisted String username) {
     this.db = db;
-    this.currentUser = currentUser;
+    this.seq = seq;
     this.groupsCollection = groupsCollection;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
-    this.accountCache = accountCache;
-    this.byEmailCache = byEmailCache;
+    this.accountsUpdate = accountsUpdate;
     this.infoLoader = infoLoader;
     this.externalIdCreators = externalIdCreators;
-    this.auditService = auditService;
+    this.externalIds = externalIds;
     this.externalIdsUpdateFactory = externalIdsUpdateFactory;
+    this.groupsUpdate = groupsUpdate;
+    this.validator = validator;
     this.username = username;
   }
 
   @Override
-  public Response<AccountInfo> apply(TopLevelResource rsrc, AccountInput input)
+  public Response<AccountInfo> apply(TopLevelResource rsrc, @Nullable AccountInput input)
       throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
           OrmException, IOException, ConfigInvalidException {
-    if (input == null) {
-      input = new AccountInput();
-    }
+    return apply(input != null ? input : new AccountInput());
+  }
+
+  public Response<AccountInfo> apply(AccountInput input)
+      throws BadRequestException, ResourceConflictException, UnprocessableEntityException,
+          OrmException, IOException, ConfigInvalidException {
     if (input.username != null && !username.equals(input.username)) {
       throw new BadRequestException("username must match URL");
     }
@@ -115,21 +124,19 @@
       throw new BadRequestException("Invalid username '" + username + "'");
     }
 
-    Set<AccountGroup.Id> groups = parseGroups(input.groups);
+    Set<AccountGroup.UUID> groups = parseGroups(input.groups);
 
-    Account.Id id = new Account.Id(db.nextAccountId());
+    Account.Id id = new Account.Id(seq.nextAccountId());
 
     ExternalId extUser = ExternalId.createUsername(username, id, input.httpPassword);
-    if (db.accountExternalIds().get(extUser.key().asAccountExternalIdKey()) != null) {
+    if (externalIds.get(extUser.key()) != null) {
       throw new ResourceConflictException("username '" + username + "' already exists");
     }
     if (input.email != null) {
-      if (db.accountExternalIds()
-              .get(ExternalId.Key.create(SCHEME_MAILTO, input.email).asAccountExternalIdKey())
-          != null) {
+      if (externalIds.get(ExternalId.Key.create(SCHEME_MAILTO, input.email)) != null) {
         throw new UnprocessableEntityException("email '" + input.email + "' already exists");
       }
-      if (!OutgoingEmailValidator.isValid(input.email)) {
+      if (!validator.isValid(input.email)) {
         throw new BadRequestException("invalid email address");
       }
     }
@@ -142,34 +149,39 @@
 
     ExternalIdsUpdate externalIdsUpdate = externalIdsUpdateFactory.create();
     try {
-      externalIdsUpdate.insert(db, extIds);
+      externalIdsUpdate.insert(extIds);
     } catch (OrmDuplicateKeyException duplicateKey) {
       throw new ResourceConflictException("username '" + username + "' already exists");
     }
 
     if (input.email != null) {
       try {
-        externalIdsUpdate.insert(db, ExternalId.createEmail(id, input.email));
+        externalIdsUpdate.insert(ExternalId.createEmail(id, input.email));
       } catch (OrmDuplicateKeyException duplicateKey) {
         try {
-          externalIdsUpdate.delete(db, extUser);
-        } catch (IOException | OrmException cleanupError) {
+          externalIdsUpdate.delete(extUser);
+        } catch (IOException | ConfigInvalidException cleanupError) {
           // Ignored
         }
         throw new UnprocessableEntityException("email '" + input.email + "' already exists");
       }
     }
 
-    Account a = new Account(id, TimeUtil.nowTs());
-    a.setFullName(input.name);
-    a.setPreferredEmail(input.email);
-    db.accounts().insert(Collections.singleton(a));
+    accountsUpdate
+        .create()
+        .insert(
+            id,
+            a -> {
+              a.setFullName(input.name);
+              a.setPreferredEmail(input.email);
+            });
 
-    for (AccountGroup.Id groupId : groups) {
-      AccountGroupMember m = new AccountGroupMember(new AccountGroupMember.Key(id, groupId));
-      auditService.dispatchAddAccountsToGroup(
-          currentUser.get().getAccountId(), Collections.singleton(m));
-      db.accountGroupMembers().insert(Collections.singleton(m));
+    for (AccountGroup.UUID groupUuid : groups) {
+      try {
+        groupsUpdate.get().addGroupMember(db, groupUuid, id);
+      } catch (NoSuchGroupException e) {
+        throw new UnprocessableEntityException(String.format("Group %s not found", groupUuid));
+      }
     }
 
     if (input.sshKey != null) {
@@ -181,24 +193,21 @@
       }
     }
 
-    accountCache.evict(id); // triggers reindex
-    accountCache.evictByUsername(username);
-    byEmailCache.evict(input.email);
-
     AccountLoader loader = infoLoader.create(true);
     AccountInfo info = loader.get(id);
     loader.fill();
     return Response.created(info);
   }
 
-  private Set<AccountGroup.Id> parseGroups(List<String> groups)
+  private Set<AccountGroup.UUID> parseGroups(List<String> groups)
       throws UnprocessableEntityException {
-    Set<AccountGroup.Id> groupIds = new HashSet<>();
+    Set<AccountGroup.UUID> groupUuids = new HashSet<>();
     if (groups != null) {
       for (String g : groups) {
-        groupIds.add(GroupDescriptions.toAccountGroup(groupsCollection.parseInternal(g)).getId());
+        GroupDescription.Internal internalGroup = groupsCollection.parseInternal(g);
+        groupUuids.add(internalGroup.getGroupUUID());
       }
     }
-    return groupIds;
+    return groupUuids;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
index 00cf4e3..9189134 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/CreateEmail.java
@@ -32,6 +32,9 @@
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
 import com.google.gerrit.server.mail.send.RegisterNewEmailSender;
+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;
@@ -50,9 +53,11 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
+  private final PermissionBackend permissionBackend;
   private final AccountManager accountManager;
   private final RegisterNewEmailSender.Factory registerNewEmailFactory;
   private final PutPreferred putPreferred;
+  private final OutgoingEmailValidator validator;
   private final String email;
   private final boolean isDevMode;
 
@@ -60,16 +65,20 @@
   CreateEmail(
       Provider<CurrentUser> self,
       Realm realm,
+      PermissionBackend permissionBackend,
       AuthConfig authConfig,
       AccountManager accountManager,
       RegisterNewEmailSender.Factory registerNewEmailFactory,
       PutPreferred putPreferred,
+      OutgoingEmailValidator validator,
       @Assisted String email) {
     this.self = self;
     this.realm = realm;
+    this.permissionBackend = permissionBackend;
     this.accountManager = accountManager;
     this.registerNewEmailFactory = registerNewEmailFactory;
     this.putPreferred = putPreferred;
+    this.validator = validator;
     this.email = email != null ? email.trim() : null;
     this.isDevMode = authConfig.getAuthType() == DEVELOPMENT_BECOME_ANY_ACCOUNT;
   }
@@ -78,18 +87,13 @@
   public Response<EmailInfo> apply(AccountResource rsrc, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
           ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException, ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to add email address");
-    }
-
+          IOException, ConfigInvalidException, PermissionBackendException {
     if (input == null) {
       input = new EmailInput();
     }
 
-    if (input.noConfirmation && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to use no_confirmation");
+    if (!self.get().hasSameAccountId(rsrc.getUser()) || input.noConfirmation) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
@@ -103,7 +107,7 @@
   public Response<EmailInfo> apply(IdentifiedUser user, EmailInput input)
       throws AuthException, BadRequestException, ResourceConflictException,
           ResourceNotFoundException, OrmException, EmailException, MethodNotAllowedException,
-          IOException {
+          IOException, ConfigInvalidException, PermissionBackendException {
     if (input == null) {
       input = new EmailInput();
     }
@@ -112,7 +116,7 @@
       throw new BadRequestException("email address must match URL");
     }
 
-    if (!OutgoingEmailValidator.isValid(email)) {
+    if (!validator.isValid(email)) {
       throw new BadRequestException("invalid email address");
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
index 795f1c5..9d9cf23 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DefaultRealm.java
@@ -19,25 +19,28 @@
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.config.AuthConfig;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Set;
 
 @Singleton
 public class DefaultRealm extends AbstractRealm {
   private final EmailExpander emailExpander;
-  private final AccountByEmailCache byEmail;
+  private final Provider<Emails> emails;
   private final AuthConfig authConfig;
 
   @Inject
-  DefaultRealm(EmailExpander emailExpander, AccountByEmailCache byEmail, AuthConfig authConfig) {
+  DefaultRealm(EmailExpander emailExpander, Provider<Emails> emails, AuthConfig authConfig) {
     this.emailExpander = emailExpander;
-    this.byEmail = byEmail;
+    this.emails = emails;
     this.authConfig = authConfig;
   }
 
   @Override
-  public boolean allowsEdit(final AccountFieldName field) {
+  public boolean allowsEdit(AccountFieldName field) {
     if (authConfig.getAuthType() == AuthType.HTTP) {
       switch (field) {
         case USER_NAME:
@@ -62,7 +65,7 @@
   }
 
   @Override
-  public AuthRequest authenticate(final AuthRequest who) {
+  public AuthRequest authenticate(AuthRequest who) {
     if (who.getEmailAddress() == null
         && who.getLocalUser() != null
         && emailExpander.canExpand(who.getLocalUser())) {
@@ -72,14 +75,18 @@
   }
 
   @Override
-  public void onCreateAccount(final AuthRequest who, final Account account) {}
+  public void onCreateAccount(AuthRequest who, Account account) {}
 
   @Override
-  public Account.Id lookup(final String accountName) {
+  public Account.Id lookup(String accountName) throws IOException {
     if (emailExpander.canExpand(accountName)) {
-      final Set<Account.Id> c = byEmail.get(emailExpander.expand(accountName));
-      if (1 == c.size()) {
-        return c.iterator().next();
+      try {
+        Set<Account.Id> c = emails.get().getAccountFor(emailExpander.expand(accountName));
+        if (1 == c.size()) {
+          return c.iterator().next();
+        }
+      } catch (OrmException e) {
+        throw new IOException("Failed to query accounts by email", e);
       }
     }
     return null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
index 8b713ca..6f474b4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteActive.java
@@ -17,71 +17,38 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.DeleteActive.Input;
-import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 @Singleton
 public class DeleteActive implements RestModifyView<AccountResource, Input> {
   public static class Input {}
 
-  private final Provider<ReviewDb> dbProvider;
-  private final AccountCache byIdCache;
   private final Provider<IdentifiedUser> self;
+  private final SetInactiveFlag setInactiveFlag;
 
   @Inject
-  DeleteActive(
-      Provider<ReviewDb> dbProvider, AccountCache byIdCache, Provider<IdentifiedUser> self) {
-    this.dbProvider = dbProvider;
-    this.byIdCache = byIdCache;
+  DeleteActive(SetInactiveFlag setInactiveFlag, Provider<IdentifiedUser> self) {
+    this.setInactiveFlag = setInactiveFlag;
     this.self = self;
   }
 
   @Override
   public Response<?> apply(AccountResource rsrc, Input input)
-      throws RestApiException, OrmException, IOException {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException {
     if (self.get().hasSameAccountId(rsrc.getUser())) {
       throw new ResourceConflictException("cannot deactivate own account");
     }
-
-    AtomicBoolean alreadyInactive = new AtomicBoolean(false);
-    Account a =
-        dbProvider
-            .get()
-            .accounts()
-            .atomicUpdate(
-                rsrc.getUser().getAccountId(),
-                new AtomicUpdate<Account>() {
-                  @Override
-                  public Account update(Account a) {
-                    if (!a.isActive()) {
-                      alreadyInactive.set(true);
-                    } else {
-                      a.setActive(false);
-                    }
-                    return a;
-                  }
-                });
-    if (a == null) {
-      throw new ResourceNotFoundException("account not found");
-    }
-    if (alreadyInactive.get()) {
-      throw new ResourceConflictException("account not active");
-    }
-    byIdCache.evict(a.getId());
-    return Response.none();
+    return setInactiveFlag.deactivate(rsrc.getUser().getAccountId());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
index 79edaa7..dd58d59 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteEmail.java
@@ -23,10 +23,14 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.DeleteEmail.Input;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+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;
@@ -41,47 +45,46 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
-  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
   private final AccountManager accountManager;
+  private final ExternalIds externalIds;
 
   @Inject
   DeleteEmail(
       Provider<CurrentUser> self,
       Realm realm,
-      Provider<ReviewDb> dbProvider,
-      AccountManager accountManager) {
+      PermissionBackend permissionBackend,
+      AccountManager accountManager,
+      ExternalIds externalIds) {
     this.self = self;
     this.realm = realm;
-    this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
     this.accountManager = accountManager;
+    this.externalIds = externalIds;
   }
 
   @Override
   public Response<?> apply(AccountResource.Email rsrc, Input input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException,
-          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to delete email address");
+          MethodNotAllowedException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), rsrc.getEmail());
   }
 
   public Response<?> apply(IdentifiedUser user, String email)
       throws ResourceNotFoundException, ResourceConflictException, MethodNotAllowedException,
-          OrmException, IOException {
+          OrmException, IOException, ConfigInvalidException {
     if (!realm.allowsEdit(AccountFieldName.REGISTER_NEW_EMAIL)) {
       throw new MethodNotAllowedException("realm does not allow deleting emails");
     }
 
     Set<ExternalId> extIds =
-        dbProvider
-            .get()
-            .accountExternalIds()
+        externalIds
             .byAccount(user.getAccountId())
-            .toList()
             .stream()
-            .map(ExternalId::from)
             .filter(e -> email.equals(e.email()))
             .collect(toSet());
     if (extIds.isEmpty()) {
@@ -89,11 +92,8 @@
     }
 
     try {
-      for (ExternalId extId : extIds) {
-        AuthRequest authRequest = new AuthRequest(extId.key());
-        authRequest.setEmailAddress(email);
-        accountManager.unlink(user.getAccountId(), authRequest);
-      }
+      accountManager.unlink(
+          user.getAccountId(), extIds.stream().map(e -> e.key()).collect(toSet()));
     } catch (AccountException e) {
       throw new ResourceConflictException(e.getMessage());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
index 7ab8aaf..1dc2615 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteExternalIds.java
@@ -14,74 +14,72 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 import static java.util.stream.Collectors.toMap;
+import static java.util.stream.Collectors.toSet;
 
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+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 java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
+@Singleton
 public class DeleteExternalIds implements RestModifyView<AccountResource, List<String>> {
-  private final AccountByEmailCache accountByEmailCache;
-  private final AccountCache accountCache;
-  private final ExternalIdsUpdate.User externalIdsUpdateFactory;
+  private final PermissionBackend permissionBackend;
+  private final AccountManager accountManager;
+  private final ExternalIds externalIds;
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> dbProvider;
 
   @Inject
   DeleteExternalIds(
-      AccountByEmailCache accountByEmailCache,
-      AccountCache accountCache,
-      ExternalIdsUpdate.User externalIdsUpdateFactory,
-      Provider<CurrentUser> self,
-      Provider<ReviewDb> dbProvider) {
-    this.accountByEmailCache = accountByEmailCache;
-    this.accountCache = accountCache;
-    this.externalIdsUpdateFactory = externalIdsUpdateFactory;
+      PermissionBackend permissionBackend,
+      AccountManager accountManager,
+      ExternalIds externalIds,
+      Provider<CurrentUser> self) {
+    this.permissionBackend = permissionBackend;
+    this.accountManager = accountManager;
+    this.externalIds = externalIds;
     this.self = self;
-    this.dbProvider = dbProvider;
   }
 
   @Override
-  public Response<?> apply(AccountResource resource, List<String> externalIds)
-      throws RestApiException, IOException, OrmException, ConfigInvalidException {
+  public Response<?> apply(AccountResource resource, List<String> extIds)
+      throws RestApiException, IOException, OrmException, ConfigInvalidException,
+          PermissionBackendException {
     if (!self.get().hasSameAccountId(resource.getUser())) {
-      throw new AuthException("not allowed to delete external IDs");
+      permissionBackend.user(self).check(GlobalPermission.ACCESS_DATABASE);
     }
 
-    if (externalIds == null || externalIds.size() == 0) {
+    if (extIds == null || extIds.size() == 0) {
       throw new BadRequestException("external IDs are required");
     }
 
-    Account.Id accountId = resource.getUser().getAccountId();
     Map<ExternalId.Key, ExternalId> externalIdMap =
-        dbProvider
-            .get()
-            .accountExternalIds()
+        externalIds
             .byAccount(resource.getUser().getAccountId())
-            .toList()
             .stream()
-            .map(ExternalId::from)
             .collect(toMap(i -> i.key(), i -> i));
 
     List<ExternalId> toDelete = new ArrayList<>();
     ExternalId.Key last = resource.getUser().getLastLoginExternalIdKey();
-    for (String externalIdStr : externalIds) {
+    for (String externalIdStr : extIds) {
       ExternalId id = externalIdMap.get(ExternalId.Key.parse(externalIdStr));
 
       if (id == null) {
@@ -98,12 +96,11 @@
       }
     }
 
-    if (!toDelete.isEmpty()) {
-      externalIdsUpdateFactory.create().delete(dbProvider.get(), toDelete);
-      accountCache.evict(accountId);
-      for (ExternalId e : toDelete) {
-        accountByEmailCache.evict(e.email());
-      }
+    try {
+      accountManager.unlink(
+          resource.getUser().getAccountId(), toDelete.stream().map(e -> e.key()).collect(toSet()));
+    } catch (AccountException e) {
+      throw new ResourceConflictException(e.getMessage());
     }
 
     return Response.none();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
index abb0118..74616bf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteSshKey.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.DeleteSshKey.Input;
+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.ssh.SshKeyCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -33,15 +36,18 @@
   public static class Input {}
 
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
   private final SshKeyCache sshKeyCache;
 
   @Inject
   DeleteSshKey(
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys,
       SshKeyCache sshKeyCache) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
     this.sshKeyCache = sshKeyCache;
   }
@@ -49,10 +55,9 @@
   @Override
   public Response<?> apply(AccountResource.SshKey rsrc, Input input)
       throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to delete SSH keys");
+          ConfigInvalidException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     authorizedKeys.deleteKey(rsrc.getUser().getAccountId(), rsrc.getSshKey().getKey().get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
index 8cd979f..d57934f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/DeleteWatchedProjects.java
@@ -25,25 +25,34 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+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 java.io.IOException;
 import java.util.List;
+import java.util.Objects;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteWatchedProjects
     implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
   private final Provider<IdentifiedUser> self;
+  private final PermissionBackend permissionBackend;
   private final AccountCache accountCache;
   private final WatchConfig.Accessor watchConfig;
 
   @Inject
   DeleteWatchedProjects(
-      Provider<IdentifiedUser> self, AccountCache accountCache, WatchConfig.Accessor watchConfig) {
+      Provider<IdentifiedUser> self,
+      PermissionBackend permissionBackend,
+      AccountCache accountCache,
+      WatchConfig.Accessor watchConfig) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.accountCache = accountCache;
     this.watchConfig = watchConfig;
   }
@@ -51,10 +60,9 @@
   @Override
   public Response<?> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
       throws AuthException, UnprocessableEntityException, OrmException, IOException,
-          ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("It is not allowed to edit project watches of other users");
+          ConfigInvalidException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
     if (input == null) {
       return Response.none();
@@ -65,6 +73,7 @@
         accountId,
         input
             .stream()
+            .filter(Objects::nonNull)
             .map(w -> ProjectWatchKey.create(new Project.NameKey(w.project), w.filter))
             .collect(toList()));
     accountCache.evict(accountId);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
index 3c501e9..af2ab19 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailExpander.java
@@ -23,7 +23,7 @@
   class None implements EmailExpander {
     public static final None INSTANCE = new None();
 
-    public static boolean canHandle(final String fmt) {
+    public static boolean canHandle(String fmt) {
       return fmt == null || fmt.isEmpty();
     }
 
@@ -43,26 +43,26 @@
   class Simple implements EmailExpander {
     private static final String PLACEHOLDER = "{0}";
 
-    public static boolean canHandle(final String fmt) {
+    public static boolean canHandle(String fmt) {
       return fmt != null && fmt.contains(PLACEHOLDER);
     }
 
     private final String lhs;
     private final String rhs;
 
-    public Simple(final String fmt) {
+    public Simple(String fmt) {
       final int p = fmt.indexOf(PLACEHOLDER);
       lhs = fmt.substring(0, p);
       rhs = fmt.substring(p + PLACEHOLDER.length());
     }
 
     @Override
-    public boolean canExpand(final String user) {
+    public boolean canExpand(String user) {
       return !user.contains(" ");
     }
 
     @Override
-    public String expand(final String user) {
+    public String expand(String user) {
       return lhs + user + rhs;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
index 8cfb66c..3e97265 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Emails.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2013 The Android Open Source Project
+// 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.
@@ -14,74 +14,77 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.base.Strings;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.AccountResource.Email;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Streams;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.externalids.ExternalIds;
+import com.google.gerrit.server.query.account.InternalAccountQuery;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 
+/** Class to access accounts by email. */
 @Singleton
-public class Emails
-    implements ChildCollection<AccountResource, AccountResource.Email>,
-        AcceptsCreate<AccountResource> {
-  private final DynamicMap<RestView<AccountResource.Email>> views;
-  private final GetEmails list;
-  private final Provider<CurrentUser> self;
-  private final CreateEmail.Factory createEmailFactory;
+public class Emails {
+  private final ExternalIds externalIds;
+  private final Provider<InternalAccountQuery> queryProvider;
 
   @Inject
-  Emails(
-      DynamicMap<RestView<AccountResource.Email>> views,
-      GetEmails list,
-      Provider<CurrentUser> self,
-      CreateEmail.Factory createEmailFactory) {
-    this.views = views;
-    this.list = list;
-    this.self = self;
-    this.createEmailFactory = createEmailFactory;
+  public Emails(ExternalIds externalIds, Provider<InternalAccountQuery> queryProvider) {
+    this.externalIds = externalIds;
+    this.queryProvider = queryProvider;
   }
 
-  @Override
-  public RestView<AccountResource> list() {
-    return list;
+  /**
+   * Returns the accounts with the given email.
+   *
+   * <p>Each email should belong to a single account only. This means if more than one account is
+   * returned there is an inconsistency in the external IDs.
+   *
+   * <p>The accounts are retrieved via the external ID cache. Each access to the external ID cache
+   * requires reading the SHA1 of the refs/meta/external-ids branch. If accounts for multiple emails
+   * are needed it is more efficient to use {@link #getAccountsFor(String...)} as this method reads
+   * the SHA1 of the refs/meta/external-ids branch only once (and not once per email).
+   *
+   * <p>In addition accounts are included that have the given email as preferred email even if they
+   * have no external ID for the preferred email. Having accounts with a preferred email that does
+   * not exist as external ID is an inconsistency, but existing functionality relies on still
+   * getting those accounts, which is why they are included. Accounts by preferred email are fetched
+   * from the account index.
+   *
+   * @see #getAccountsFor(String...)
+   */
+  public ImmutableSet<Account.Id> getAccountFor(String email) throws IOException, OrmException {
+    return Streams.concat(
+            externalIds.byEmail(email).stream().map(e -> e.accountId()),
+            queryProvider.get().byPreferredEmail(email).stream().map(a -> a.getAccount().getId()))
+        .collect(toImmutableSet());
   }
 
-  @Override
-  public AccountResource.Email parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new ResourceNotFoundException();
-    }
-
-    if ("preferred".equals(id.get())) {
-      String email = rsrc.getUser().getAccount().getPreferredEmail();
-      if (Strings.isNullOrEmpty(email)) {
-        throw new ResourceNotFoundException();
-      }
-      return new AccountResource.Email(rsrc.getUser(), email);
-    } else if (rsrc.getUser().hasEmailAddress(id.get())) {
-      return new AccountResource.Email(rsrc.getUser(), id.get());
-    } else {
-      throw new ResourceNotFoundException();
-    }
-  }
-
-  @Override
-  public DynamicMap<RestView<Email>> views() {
-    return views;
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public CreateEmail create(AccountResource parent, IdString email) {
-    return createEmailFactory.create(email.get());
+  /**
+   * Returns the accounts for the given emails.
+   *
+   * @see #getAccountFor(String)
+   */
+  public ImmutableSetMultimap<String, Account.Id> getAccountsFor(String... emails)
+      throws IOException, OrmException {
+    ImmutableSetMultimap.Builder<String, Account.Id> builder = ImmutableSetMultimap.builder();
+    externalIds
+        .byEmails(emails)
+        .entries()
+        .stream()
+        .forEach(e -> builder.put(e.getKey(), e.getValue().accountId()));
+    queryProvider
+        .get()
+        .byPreferredEmail(emails)
+        .entries()
+        .stream()
+        .forEach(e -> builder.put(e.getKey(), e.getValue().getAccount().getId()));
+    return builder.build();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailsCollection.java
new file mode 100644
index 0000000..b1a50c0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/EmailsCollection.java
@@ -0,0 +1,92 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountResource.Email;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class EmailsCollection
+    implements ChildCollection<AccountResource, AccountResource.Email>,
+        AcceptsCreate<AccountResource> {
+  private final DynamicMap<RestView<AccountResource.Email>> views;
+  private final GetEmails list;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final CreateEmail.Factory createEmailFactory;
+
+  @Inject
+  EmailsCollection(
+      DynamicMap<RestView<AccountResource.Email>> views,
+      GetEmails list,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      CreateEmail.Factory createEmailFactory) {
+    this.views = views;
+    this.list = list;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.createEmailFactory = createEmailFactory;
+  }
+
+  @Override
+  public RestView<AccountResource> list() {
+    return list;
+  }
+
+  @Override
+  public AccountResource.Email parse(AccountResource rsrc, IdString id)
+      throws ResourceNotFoundException, PermissionBackendException, AuthException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
+    if ("preferred".equals(id.get())) {
+      String email = rsrc.getUser().getAccount().getPreferredEmail();
+      if (Strings.isNullOrEmpty(email)) {
+        throw new ResourceNotFoundException(id);
+      }
+      return new AccountResource.Email(rsrc.getUser(), email);
+    } else if (rsrc.getUser().hasEmailAddress(id.get())) {
+      return new AccountResource.Email(rsrc.getUser(), id.get());
+    } else {
+      throw new ResourceNotFoundException(id);
+    }
+  }
+
+  @Override
+  public DynamicMap<RestView<Email>> views() {
+    return views;
+  }
+
+  @Override
+  public CreateEmail create(AccountResource parent, IdString email) {
+    return createEmailFactory.create(email.get());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
deleted file mode 100644
index 0a8a028..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalId.java
+++ /dev/null
@@ -1,364 +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.server.account;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static java.util.stream.Collectors.toSet;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Iterables;
-import com.google.common.hash.Hashing;
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.extensions.client.AuthType;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountExternalId;
-import java.io.Serializable;
-import java.util.Collection;
-import java.util.Set;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-
-@AutoValue
-public abstract class ExternalId implements Serializable {
-  // If these regular expressions are modified the same modifications should be done to the
-  // corresponding regular expressions in the
-  // com.google.gerrit.client.account.UsernameField class.
-  private static final String USER_NAME_PATTERN_FIRST_REGEX = "[a-zA-Z0-9]";
-  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9.!#$%&’*+=?^_`\\{|\\}~@-]";
-  private static final String USER_NAME_PATTERN_LAST_REGEX = "[a-zA-Z0-9]";
-
-  /** Regular expression that a username must match. */
-  private static final String USER_NAME_PATTERN_REGEX =
-      "^("
-          + //
-          USER_NAME_PATTERN_FIRST_REGEX
-          + //
-          USER_NAME_PATTERN_REST_REGEX
-          + "*"
-          + //
-          USER_NAME_PATTERN_LAST_REGEX
-          + //
-          "|"
-          + //
-          USER_NAME_PATTERN_FIRST_REGEX
-          + //
-          ")$";
-
-  private static final Pattern USER_NAME_PATTERN = Pattern.compile(USER_NAME_PATTERN_REGEX);
-
-  public static boolean isValidUsername(String username) {
-    return USER_NAME_PATTERN.matcher(username).matches();
-  }
-
-  private static final long serialVersionUID = 1L;
-
-  private static final String EXTERNAL_ID_SECTION = "externalId";
-  private static final String ACCOUNT_ID_KEY = "accountId";
-  private static final String EMAIL_KEY = "email";
-  private static final String PASSWORD_KEY = "password";
-
-  /**
-   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
-   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
-   *
-   * <p>The name {@code gerrit:} was a very poor choice.
-   */
-  public static final String SCHEME_GERRIT = "gerrit";
-
-  /** Scheme used for randomly created identities constructed by a UUID. */
-  public static final String SCHEME_UUID = "uuid";
-
-  /** Scheme used to represent only an email address. */
-  public static final String SCHEME_MAILTO = "mailto";
-
-  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
-  public static final String SCHEME_USERNAME = "username";
-
-  /** Scheme used for GPG public keys. */
-  public static final String SCHEME_GPGKEY = "gpgkey";
-
-  /** Scheme for external auth used during authentication, e.g. OAuth Token */
-  public static final String SCHEME_EXTERNAL = "external";
-
-  @AutoValue
-  public abstract static class Key implements Serializable {
-    private static final long serialVersionUID = 1L;
-
-    public static Key create(@Nullable String scheme, String id) {
-      return new AutoValue_ExternalId_Key(Strings.emptyToNull(scheme), id);
-    }
-
-    public static ExternalId.Key from(AccountExternalId.Key externalIdKey) {
-      return parse(externalIdKey.get());
-    }
-
-    /**
-     * Parses an external ID key from a string in the format "scheme:id" or "id".
-     *
-     * @return the parsed external ID key
-     */
-    public static Key parse(String externalId) {
-      int c = externalId.indexOf(':');
-      if (c < 1 || c >= externalId.length() - 1) {
-        return create(null, externalId);
-      }
-      return create(externalId.substring(0, c), externalId.substring(c + 1));
-    }
-
-    public static Set<AccountExternalId.Key> toAccountExternalIdKeys(
-        Collection<ExternalId.Key> extIdKeys) {
-      return extIdKeys.stream().map(k -> k.asAccountExternalIdKey()).collect(toSet());
-    }
-
-    public abstract @Nullable String scheme();
-
-    public abstract String id();
-
-    public boolean isScheme(String scheme) {
-      return scheme.equals(scheme());
-    }
-
-    public AccountExternalId.Key asAccountExternalIdKey() {
-      if (scheme() != null) {
-        return new AccountExternalId.Key(scheme(), id());
-      }
-      return new AccountExternalId.Key(id());
-    }
-
-    /**
-     * Returns the SHA1 of the external ID that is used as note ID in the refs/meta/external-ids
-     * notes branch.
-     */
-    @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
-    public ObjectId sha1() {
-      return ObjectId.fromRaw(Hashing.sha1().hashString(get(), UTF_8).asBytes());
-    }
-
-    /**
-     * Exports this external ID key as string with the format "scheme:id", or "id" id scheme is
-     * null.
-     *
-     * <p>This string representation is used as subsection name in the Git config file that stores
-     * the external ID.
-     */
-    public String get() {
-      if (scheme() != null) {
-        return scheme() + ":" + id();
-      }
-      return id();
-    }
-
-    @Override
-    public String toString() {
-      return get();
-    }
-  }
-
-  public static ExternalId create(String scheme, String id, Account.Id accountId) {
-    return new AutoValue_ExternalId(Key.create(scheme, id), accountId, null, null);
-  }
-
-  public static ExternalId create(
-      String scheme,
-      String id,
-      Account.Id accountId,
-      @Nullable String email,
-      @Nullable String hashedPassword) {
-    return create(Key.create(scheme, id), accountId, email, hashedPassword);
-  }
-
-  public static ExternalId create(Key key, Account.Id accountId) {
-    return create(key, accountId, null, null);
-  }
-
-  public static ExternalId create(
-      Key key, Account.Id accountId, @Nullable String email, @Nullable String hashedPassword) {
-    return new AutoValue_ExternalId(
-        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword));
-  }
-
-  public static ExternalId createWithPassword(
-      Key key, Account.Id accountId, @Nullable String email, String plainPassword) {
-    plainPassword = Strings.emptyToNull(plainPassword);
-    String hashedPassword =
-        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
-    return create(key, accountId, email, hashedPassword);
-  }
-
-  public static ExternalId createUsername(String id, Account.Id accountId, String plainPassword) {
-    return createWithPassword(Key.create(SCHEME_USERNAME, id), accountId, null, plainPassword);
-  }
-
-  public static ExternalId createWithEmail(
-      String scheme, String id, Account.Id accountId, @Nullable String email) {
-    return createWithEmail(Key.create(scheme, id), accountId, email);
-  }
-
-  public static ExternalId createWithEmail(Key key, Account.Id accountId, @Nullable String email) {
-    return new AutoValue_ExternalId(key, accountId, Strings.emptyToNull(email), null);
-  }
-
-  public static ExternalId createEmail(Account.Id accountId, String email) {
-    return createWithEmail(SCHEME_MAILTO, email, accountId, checkNotNull(email));
-  }
-
-  /**
-   * Parses an external ID from a byte array that contain the external ID as an Git config file
-   * text.
-   *
-   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
-   * email and password:
-   *
-   * <pre>
-   * [externalId "username:jdoe"]
-   *   accountId = 1003407
-   *   email = jdoe@example.com
-   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
-   * </pre>
-   */
-  public static ExternalId parse(String noteId, byte[] raw) throws ConfigInvalidException {
-    Config externalIdConfig = new Config();
-    try {
-      externalIdConfig.fromText(new String(raw, UTF_8));
-    } catch (ConfigInvalidException e) {
-      throw invalidConfig(noteId, e.getMessage());
-    }
-
-    Set<String> externalIdKeys = externalIdConfig.getSubsections(EXTERNAL_ID_SECTION);
-    if (externalIdKeys.size() != 1) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Expected exactly 1 %s section, found %d",
-              EXTERNAL_ID_SECTION, externalIdKeys.size()));
-    }
-
-    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
-    Key externalIdKey = Key.parse(externalIdKeyStr);
-    if (externalIdKey == null) {
-      throw invalidConfig(noteId, String.format("Invalid external id: %s", externalIdKeyStr));
-    }
-
-    String accountIdStr =
-        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
-    String email = externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, EMAIL_KEY);
-    String password =
-        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, PASSWORD_KEY);
-    if (accountIdStr == null) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Missing value for %s.%s.%s", EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-    }
-    Integer accountId = Ints.tryParse(accountIdStr);
-    if (accountId == null) {
-      throw invalidConfig(
-          noteId,
-          String.format(
-              "Value %s for %s.%s.%s is invalid, expected account ID",
-              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
-    }
-
-    return new AutoValue_ExternalId(
-        externalIdKey,
-        new Account.Id(accountId),
-        Strings.emptyToNull(email),
-        Strings.emptyToNull(password));
-  }
-
-  private static ConfigInvalidException invalidConfig(String noteId, String message) {
-    return new ConfigInvalidException(
-        String.format("Invalid external id config for note %s: %s", noteId, message));
-  }
-
-  public static ExternalId from(AccountExternalId externalId) {
-    if (externalId == null) {
-      return null;
-    }
-
-    return new AutoValue_ExternalId(
-        ExternalId.Key.parse(externalId.getExternalId()),
-        externalId.getAccountId(),
-        Strings.emptyToNull(externalId.getEmailAddress()),
-        Strings.emptyToNull(externalId.getPassword()));
-  }
-
-  public static Set<ExternalId> from(Collection<AccountExternalId> externalIds) {
-    if (externalIds == null) {
-      return ImmutableSet.of();
-    }
-    return externalIds.stream().map(ExternalId::from).collect(toSet());
-  }
-
-  public static Set<AccountExternalId> toAccountExternalIds(Collection<ExternalId> extIds) {
-    return extIds.stream().map(e -> e.asAccountExternalId()).collect(toSet());
-  }
-
-  public abstract Key key();
-
-  public abstract Account.Id accountId();
-
-  public abstract @Nullable String email();
-
-  public abstract @Nullable String password();
-
-  public boolean isScheme(String scheme) {
-    return key().isScheme(scheme);
-  }
-
-  public AccountExternalId asAccountExternalId() {
-    AccountExternalId extId = new AccountExternalId(accountId(), key().asAccountExternalIdKey());
-    extId.setEmailAddress(email());
-    extId.setPassword(password());
-    return extId;
-  }
-
-  /**
-   * Exports this external ID as Git config file text.
-   *
-   * <p>The Git config has exactly one externalId subsection with an accountId and optionally email
-   * and password:
-   *
-   * <pre>
-   * [externalId "username:jdoe"]
-   *   accountId = 1003407
-   *   email = jdoe@example.com
-   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
-   * </pre>
-   */
-  @Override
-  public String toString() {
-    Config c = new Config();
-    writeToConfig(c);
-    return c.toText();
-  }
-
-  public void writeToConfig(Config c) {
-    String externalIdKey = key().get();
-    c.setInt(EXTERNAL_ID_SECTION, externalIdKey, ACCOUNT_ID_KEY, accountId().get());
-    if (email() != null) {
-      c.setString(EXTERNAL_ID_SECTION, externalIdKey, EMAIL_KEY, email());
-    }
-    if (password() != null) {
-      c.setString(EXTERNAL_ID_SECTION, externalIdKey, PASSWORD_KEY, password());
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
deleted file mode 100644
index c937935..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIds.java
+++ /dev/null
@@ -1,105 +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.server.account;
-
-import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-/**
- * Class to read external IDs from NoteDb.
- *
- * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
- * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
- * is a git config file that contains an external ID. It has exactly one externalId subsection with
- * an accountId and optionally email and password:
- *
- * <pre>
- * [externalId "username:jdoe"]
- *   accountId = 1003407
- *   email = jdoe@example.com
- *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
- * </pre>
- */
-@Singleton
-public class ExternalIds {
-  public static final int MAX_NOTE_SZ = 1 << 19;
-
-  public static ObjectId readRevision(Repository repo) throws IOException {
-    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
-    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
-  }
-
-  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
-    if (!rev.equals(ObjectId.zeroId())) {
-      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
-    }
-    return NoteMap.newEmptyMap();
-  }
-
-  private final GitRepositoryManager repoManager;
-  private final AllUsersName allUsersName;
-
-  @Inject
-  public ExternalIds(GitRepositoryManager repoManager, AllUsersName allUsersName) {
-    this.repoManager = repoManager;
-    this.allUsersName = allUsersName;
-  }
-
-  public ObjectId readRevision() throws IOException {
-    try (Repository repo = repoManager.openRepository(allUsersName)) {
-      return readRevision(repo);
-    }
-  }
-
-  /** Reads and returns the specified external ID. */
-  @Nullable
-  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
-    try (Repository repo = repoManager.openRepository(allUsersName);
-        RevWalk rw = new RevWalk(repo)) {
-      ObjectId rev = readRevision(repo);
-      if (rev.equals(ObjectId.zeroId())) {
-        return null;
-      }
-
-      return parse(key, rw, rev);
-    }
-  }
-
-  private ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
-      throws IOException, ConfigInvalidException {
-    NoteMap noteMap = readNoteMap(rw, rev);
-    ObjectId noteId = key.sha1();
-    if (!noteMap.contains(noteId)) {
-      return null;
-    }
-
-    byte[] raw =
-        rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
-    return ExternalId.parse(noteId.name(), raw);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
deleted file mode 100644
index 68d3d0b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsBatchUpdate.java
+++ /dev/null
@@ -1,57 +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.server.account;
-
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-import java.util.HashSet;
-import java.util.Set;
-
-/** This class allows to do batch updates to external IDs. */
-public class ExternalIdsBatchUpdate {
-  private final Set<ExternalId> toAdd = new HashSet<>();
-  private final Set<ExternalId> toDelete = new HashSet<>();
-
-  /** Adds an external ID replacement to the batch. */
-  public void replace(ExternalId extIdToDelete, ExternalId extIdToAdd) {
-    ExternalIdsUpdate.checkSameAccount(ImmutableSet.of(extIdToDelete, extIdToAdd));
-    toAdd.add(extIdToAdd);
-    toDelete.add(extIdToDelete);
-  }
-
-  /**
-   * Commits this batch.
-   *
-   * <p>This means external ID replacements which were prepared by invoking {@link
-   * #replace(ExternalId, ExternalId)} are now executed. Deletion of external IDs is done before
-   * adding the new external IDs. This means if an external ID is specified for deletion and an
-   * external ID with the same key is specified to be added, the old external ID with that key is
-   * deleted first and then the new external ID is added (so the external ID for that key is
-   * replaced).
-   */
-  public void commit(ReviewDb db) throws OrmException {
-    if (toDelete.isEmpty() && toAdd.isEmpty()) {
-      return;
-    }
-
-    db.accountExternalIds().delete(toAccountExternalIds(toDelete));
-    db.accountExternalIds().insert(toAccountExternalIds(toAdd));
-    toAdd.clear();
-    toDelete.clear();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
deleted file mode 100644
index ca9bfaa..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/ExternalIdsUpdate.java
+++ /dev/null
@@ -1,274 +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.server.account;
-
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.server.account.ExternalId.Key.toAccountExternalIdKeys;
-import static com.google.gerrit.server.account.ExternalId.toAccountExternalIds;
-import static java.util.stream.Collectors.toSet;
-
-import com.github.rholder.retry.RetryerBuilder;
-import com.github.rholder.retry.StopStrategies;
-import com.github.rholder.retry.WaitStrategies;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.Iterables;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.LockFailureException;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.concurrent.TimeUnit;
-
-// Updates externalIds in ReviewDb.
-public class ExternalIdsUpdate {
-  /**
-   * Factory to create an ExternalIdsUpdate instance for updating external IDs by the Gerrit server.
-   */
-  @Singleton
-  public static class Server {
-    private final AccountCache accountCache;
-
-    @Inject
-    public Server(AccountCache accountCache) {
-      this.accountCache = accountCache;
-    }
-
-    public ExternalIdsUpdate create() {
-      return new ExternalIdsUpdate(accountCache);
-    }
-  }
-
-  @Singleton
-  public static class User {
-    private final AccountCache accountCache;
-
-    @Inject
-    public User(AccountCache accountCache) {
-      this.accountCache = accountCache;
-    }
-
-    public ExternalIdsUpdate create() {
-      return new ExternalIdsUpdate(accountCache);
-    }
-  }
-
-  @VisibleForTesting
-  public static RetryerBuilder<Void> retryerBuilder() {
-    return RetryerBuilder.<Void>newBuilder()
-        .retryIfException(e -> e instanceof LockFailureException)
-        .withWaitStrategy(
-            WaitStrategies.join(
-                WaitStrategies.exponentialWait(2, TimeUnit.SECONDS),
-                WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
-        .withStopStrategy(StopStrategies.stopAfterDelay(10, TimeUnit.SECONDS));
-  }
-
-  private final AccountCache accountCache;
-
-  @VisibleForTesting
-  public ExternalIdsUpdate(AccountCache accountCache) {
-    this.accountCache = accountCache;
-  }
-
-  /**
-   * Inserts a new external ID.
-   *
-   * <p>If the external ID already exists, the insert fails with {@link OrmDuplicateKeyException}.
-   */
-  public void insert(ReviewDb db, ExternalId extId) throws IOException, OrmException {
-    insert(db, Collections.singleton(extId));
-  }
-
-  /**
-   * Inserts new external IDs.
-   *
-   * <p>If any of the external ID already exists, the insert fails with {@link
-   * OrmDuplicateKeyException}.
-   */
-  public void insert(ReviewDb db, Collection<ExternalId> extIds) throws IOException, OrmException {
-    db.accountExternalIds().insert(toAccountExternalIds(extIds));
-    evictAccounts(extIds);
-  }
-
-  /**
-   * Inserts or updates an external ID.
-   *
-   * <p>If the external ID already exists, it is overwritten, otherwise it is inserted.
-   */
-  public void upsert(ReviewDb db, ExternalId extId) throws IOException, OrmException {
-    upsert(db, Collections.singleton(extId));
-  }
-
-  /**
-   * Inserts or updates external IDs.
-   *
-   * <p>If any of the external IDs already exists, it is overwritten. New external IDs are inserted.
-   */
-  public void upsert(ReviewDb db, Collection<ExternalId> extIds) throws IOException, OrmException {
-    db.accountExternalIds().upsert(toAccountExternalIds(extIds));
-    evictAccounts(extIds);
-  }
-
-  /**
-   * Deletes an external ID.
-   *
-   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key, but otherwise doesn't match the specified external ID.
-   */
-  public void delete(ReviewDb db, ExternalId extId) throws IOException, OrmException {
-    delete(db, Collections.singleton(extId));
-  }
-
-  /**
-   * Deletes external IDs.
-   *
-   * <p>The deletion fails with {@link IllegalStateException} if there is an existing external ID
-   * that has the same key as any of the external IDs that should be deleted, but otherwise doesn't
-   * match the that external ID.
-   */
-  public void delete(ReviewDb db, Collection<ExternalId> extIds) throws IOException, OrmException {
-    db.accountExternalIds().delete(toAccountExternalIds(extIds));
-    evictAccounts(extIds);
-  }
-
-  /**
-   * Delete an external ID by key.
-   *
-   * <p>The external ID is only deleted if it belongs to the specified account. If it belongs to
-   * another account the deletion fails with {@link IllegalStateException}.
-   */
-  public void delete(ReviewDb db, Account.Id accountId, ExternalId.Key extIdKey)
-      throws IOException, OrmException {
-    delete(db, accountId, Collections.singleton(extIdKey));
-  }
-
-  /**
-   * Delete external IDs by external ID key.
-   *
-   * <p>The external IDs are only deleted if they belongs to the specified account. If any of the
-   * external IDs belongs to another account the deletion fails with {@link IllegalStateException}.
-   */
-  public void delete(ReviewDb db, Account.Id accountId, Collection<ExternalId.Key> extIdKeys)
-      throws IOException, OrmException {
-    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(extIdKeys));
-    accountCache.evict(accountId);
-  }
-
-  /** Deletes all external IDs of the specified account. */
-  public void deleteAll(ReviewDb db, Account.Id accountId) throws IOException, OrmException {
-    delete(db, ExternalId.from(db.accountExternalIds().byAccount(accountId).toList()));
-  }
-
-  /**
-   * Replaces external IDs for an account by external ID keys.
-   *
-   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
-   * external ID key is specified for deletion and an external ID with the same key is specified to
-   * be added, the old external ID with that key is deleted first and then the new external ID is
-   * added (so the external ID for that key is replaced).
-   *
-   * <p>If any of the specified external IDs belongs to another account the replacement fails with
-   * {@link IllegalStateException}.
-   */
-  public void replace(
-      ReviewDb db,
-      Account.Id accountId,
-      Collection<ExternalId.Key> toDelete,
-      Collection<ExternalId> toAdd)
-      throws IOException, OrmException {
-    checkSameAccount(toAdd, accountId);
-
-    db.accountExternalIds().deleteKeys(toAccountExternalIdKeys(toDelete));
-    db.accountExternalIds().insert(toAccountExternalIds(toAdd));
-    accountCache.evict(accountId);
-  }
-
-  /**
-   * Replaces an external ID.
-   *
-   * <p>If the specified external IDs belongs to different accounts the replacement fails with
-   * {@link IllegalStateException}.
-   */
-  public void replace(ReviewDb db, ExternalId toDelete, ExternalId toAdd)
-      throws IOException, OrmException {
-    replace(db, Collections.singleton(toDelete), Collections.singleton(toAdd));
-  }
-
-  /**
-   * Replaces external IDs.
-   *
-   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
-   * external ID is specified for deletion and an external ID with the same key is specified to be
-   * added, the old external ID with that key is deleted first and then the new external ID is added
-   * (so the external ID for that key is replaced).
-   *
-   * <p>If the specified external IDs belong to different accounts the replacement fails with {@link
-   * IllegalStateException}.
-   */
-  public void replace(ReviewDb db, Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
-      throws IOException, OrmException {
-    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
-    if (accountId == null) {
-      // toDelete and toAdd are empty -> nothing to do
-      return;
-    }
-
-    replace(db, accountId, toDelete.stream().map(e -> e.key()).collect(toSet()), toAdd);
-  }
-
-  /**
-   * Checks that all specified external IDs belong to the same account.
-   *
-   * @return the ID of the account to which all specified external IDs belong.
-   */
-  public static Account.Id checkSameAccount(Iterable<ExternalId> extIds) {
-    return checkSameAccount(extIds, null);
-  }
-
-  /**
-   * Checks that all specified external IDs belong to specified account. If no account is specified
-   * it is checked that all specified external IDs belong to the same account.
-   *
-   * @return the ID of the account to which all specified external IDs belong.
-   */
-  public static Account.Id checkSameAccount(
-      Iterable<ExternalId> extIds, @Nullable Account.Id accountId) {
-    for (ExternalId extId : extIds) {
-      if (accountId == null) {
-        accountId = extId.accountId();
-        continue;
-      }
-      checkState(
-          accountId.equals(extId.accountId()),
-          "external id %s belongs to account %s, expected account %s",
-          extId.key().get(),
-          extId.accountId().get(),
-          accountId.get());
-    }
-    return accountId;
-  }
-
-  private void evictAccounts(Collection<ExternalId> extIds) throws IOException {
-    for (Account.Id id : extIds.stream().map(ExternalId::accountId).collect(toSet())) {
-      accountCache.evict(id);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
index df64c0b..8043773 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java
@@ -141,7 +141,6 @@
     }
     if (r.my.isEmpty()) {
       r.my.add(new MenuItem("Changes", "#/dashboard/self", null));
-      r.my.add(new MenuItem("Drafts", "#/q/owner:self+is:draft", null));
       r.my.add(new MenuItem("Draft Comments", "#/q/has:draft", null));
       r.my.add(new MenuItem("Edits", "#/q/has:edit", null));
       r.my.add(new MenuItem("Watched Changes", "#/q/is:watched+is:open", null));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
index fa36d1d..4058a16 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetCapabilities.java
@@ -14,27 +14,13 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.common.data.GlobalCapability.ACCESS_DATABASE;
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_ACCOUNT;
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_GROUP;
-import static com.google.gerrit.common.data.GlobalCapability.CREATE_PROJECT;
-import static com.google.gerrit.common.data.GlobalCapability.EMAIL_REVIEWERS;
-import static com.google.gerrit.common.data.GlobalCapability.FLUSH_CACHES;
-import static com.google.gerrit.common.data.GlobalCapability.KILL_TASK;
-import static com.google.gerrit.common.data.GlobalCapability.MAINTAIN_SERVER;
-import static com.google.gerrit.common.data.GlobalCapability.MODIFY_ACCOUNT;
 import static com.google.gerrit.common.data.GlobalCapability.PRIORITY;
-import static com.google.gerrit.common.data.GlobalCapability.RUN_GC;
-import static com.google.gerrit.common.data.GlobalCapability.STREAM_EVENTS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_ALL_ACCOUNTS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_CACHES;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_CONNECTIONS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_PLUGINS;
-import static com.google.gerrit.common.data.GlobalCapability.VIEW_QUEUE;
 
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -45,12 +31,14 @@
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.account.AccountResource.Capability;
 import com.google.gerrit.server.git.QueueProvider;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.HashSet;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Set;
@@ -67,84 +55,84 @@
 
   private Set<String> query;
 
+  private final PermissionBackend permissionBackend;
+  private final AccountLimits.Factory limitsFactory;
   private final Provider<CurrentUser> self;
   private final DynamicMap<CapabilityDefinition> pluginCapabilities;
 
   @Inject
-  GetCapabilities(Provider<CurrentUser> self, DynamicMap<CapabilityDefinition> pluginCapabilities) {
+  GetCapabilities(
+      PermissionBackend permissionBackend,
+      AccountLimits.Factory limitsFactory,
+      Provider<CurrentUser> self,
+      DynamicMap<CapabilityDefinition> pluginCapabilities) {
+    this.permissionBackend = permissionBackend;
+    this.limitsFactory = limitsFactory;
     this.self = self;
     this.pluginCapabilities = pluginCapabilities;
   }
 
   @Override
-  public Object apply(AccountResource resource) throws AuthException {
-    if (!self.get().hasSameAccountId(resource.getUser())
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("restricted to administrator");
+  public Object apply(AccountResource resource) throws AuthException, PermissionBackendException {
+    PermissionBackend.WithUser perm = permissionBackend.user(self);
+    if (!self.get().hasSameAccountId(resource.getUser())) {
+      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
+      perm = permissionBackend.user(resource.getUser());
     }
 
-    CapabilityControl cc = resource.getUser().getCapabilities();
     Map<String, Object> have = new LinkedHashMap<>();
-    for (String name : GlobalCapability.getAllNames()) {
-      if (!name.equals(PRIORITY) && want(name) && cc.canPerform(name)) {
-        if (GlobalCapability.hasRange(name)) {
-          have.put(name, new Range(cc.getRange(name)));
-        } else {
-          have.put(name, true);
-        }
-      }
-    }
-    for (String pluginName : pluginCapabilities.plugins()) {
-      for (String capability : pluginCapabilities.byPlugin(pluginName).keySet()) {
-        String name = String.format("%s-%s", pluginName, capability);
-        if (want(name) && cc.canPerform(name)) {
-          have.put(name, true);
-        }
-      }
+    for (GlobalOrPluginPermission p : perm.test(permissionsToTest())) {
+      have.put(p.permissionName(), true);
     }
 
-    have.put(ACCESS_DATABASE, cc.canAccessDatabase());
-    have.put(CREATE_ACCOUNT, cc.canCreateAccount());
-    have.put(CREATE_GROUP, cc.canCreateGroup());
-    have.put(CREATE_PROJECT, cc.canCreateProject());
-    have.put(EMAIL_REVIEWERS, cc.canEmailReviewers());
-    have.put(FLUSH_CACHES, cc.canFlushCaches());
-    have.put(KILL_TASK, cc.canKillTask());
-    have.put(MAINTAIN_SERVER, cc.canMaintainServer());
-    have.put(MODIFY_ACCOUNT, cc.canModifyAccount());
-    have.put(RUN_GC, cc.canRunGC());
-    have.put(STREAM_EVENTS, cc.canStreamEvents());
-    have.put(VIEW_ALL_ACCOUNTS, cc.canViewAllAccounts());
-    have.put(VIEW_CACHES, cc.canViewCaches());
-    have.put(VIEW_CONNECTIONS, cc.canViewConnections());
-    have.put(VIEW_PLUGINS, cc.canViewPlugins());
-    have.put(VIEW_QUEUE, cc.canViewQueue());
-
-    QueueProvider.QueueType queue = cc.getQueueType();
-    if (queue != QueueProvider.QueueType.INTERACTIVE
-        || (query != null && query.contains(PRIORITY))) {
-      have.put(PRIORITY, queue);
-    }
-
-    Iterator<Map.Entry<String, Object>> itr = have.entrySet().iterator();
-    while (itr.hasNext()) {
-      Map.Entry<String, Object> e = itr.next();
-      if (!want(e.getKey())) {
-        itr.remove();
-      } else if (e.getValue() instanceof Boolean && !((Boolean) e.getValue())) {
-        itr.remove();
-      }
-    }
+    AccountLimits limits = limitsFactory.create(resource.getUser());
+    addRanges(have, limits);
+    addPriority(have, limits);
 
     return OutputFormat.JSON
         .newGson()
         .toJsonTree(have, new TypeToken<Map<String, Object>>() {}.getType());
   }
 
+  private Set<GlobalOrPluginPermission> permissionsToTest() {
+    Set<GlobalOrPluginPermission> toTest = new HashSet<>();
+    for (GlobalPermission p : GlobalPermission.values()) {
+      if (want(p.permissionName())) {
+        toTest.add(p);
+      }
+    }
+
+    for (String pluginName : pluginCapabilities.plugins()) {
+      for (String capability : pluginCapabilities.byPlugin(pluginName).keySet()) {
+        PluginPermission p = new PluginPermission(pluginName, capability);
+        if (want(p.permissionName())) {
+          toTest.add(p);
+        }
+      }
+    }
+    return toTest;
+  }
+
   private boolean want(String name) {
     return query == null || query.contains(name.toLowerCase());
   }
 
+  private void addRanges(Map<String, Object> have, AccountLimits limits) {
+    for (String name : GlobalCapability.getRangeNames()) {
+      if (want(name) && limits.hasExplicitRange(name)) {
+        have.put(name, new Range(limits.getRange(name)));
+      }
+    }
+  }
+
+  private void addPriority(Map<String, Object> have, AccountLimits limits) {
+    QueueProvider.QueueType queue = limits.getQueueType();
+    if (queue != QueueProvider.QueueType.INTERACTIVE
+        || (query != null && query.contains(PRIORITY))) {
+      have.put(PRIORITY, queue);
+    }
+  }
+
   private static class Range {
     private transient PermissionRange range;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
index 9eafec0..30eb377 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDetail.java
@@ -42,6 +42,7 @@
     Account a = rsrc.getUser().getAccount();
     AccountDetailInfo info = new AccountDetailInfo(a.getId().get());
     info.registeredOn = a.getRegisteredOn();
+    info.inactive = !a.isActive() ? true : null;
     try {
       directory.fillAccountInfo(Collections.singleton(info), EnumSet.allOf(FillOptions.class));
     } catch (DirectoryException e) {
@@ -53,6 +54,7 @@
 
   public static class AccountDetailInfo extends AccountInfo {
     public Timestamp registeredOn;
+    public Boolean inactive;
 
     public AccountDetailInfo(Integer id) {
       super(id);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
index c2f7b8f..5a68732 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetDiffPreferences.java
@@ -25,6 +25,9 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -42,24 +45,26 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<AllUsersName> allUsersName;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitMgr;
 
   @Inject
   GetDiffPreferences(
       Provider<CurrentUser> self,
       Provider<AllUsersName> allUsersName,
+      PermissionBackend permissionBackend,
       GitRepositoryManager gitMgr) {
     this.self = self;
     this.allUsersName = allUsersName;
+    this.permissionBackend = permissionBackend;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public DiffPreferencesInfo apply(AccountResource rsrc)
-      throws AuthException, ConfigInvalidException, IOException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("restricted to administrator");
+      throws AuthException, ConfigInvalidException, IOException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
index e795f83..e321ca4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetEditPreferences.java
@@ -24,6 +24,9 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -35,23 +38,27 @@
 @Singleton
 public class GetEditPreferences implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final AllUsersName allUsersName;
   private final GitRepositoryManager gitMgr;
 
   @Inject
   GetEditPreferences(
-      Provider<CurrentUser> self, AllUsersName allUsersName, GitRepositoryManager gitMgr) {
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AllUsersName allUsersName,
+      GitRepositoryManager gitMgr) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.allUsersName = allUsersName;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public EditPreferencesInfo apply(AccountResource rsrc)
-      throws AuthException, IOException, ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+      throws AuthException, IOException, ConfigInvalidException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     return readFromGit(rsrc.getUser().getAccountId(), gitMgr, allUsersName, null);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
index c926cff..3e2d459 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetExternalIds.java
@@ -14,48 +14,56 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
 import com.google.gerrit.extensions.common.AccountExternalIdInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 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 java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 
 @Singleton
 public class GetExternalIds implements RestReadView<AccountResource> {
-  private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
+  private final ExternalIds externalIds;
   private final Provider<CurrentUser> self;
   private final AuthConfig authConfig;
 
   @Inject
-  GetExternalIds(Provider<ReviewDb> db, Provider<CurrentUser> self, AuthConfig authConfig) {
-    this.db = db;
+  GetExternalIds(
+      PermissionBackend permissionBackend,
+      ExternalIds externalIds,
+      Provider<CurrentUser> self,
+      AuthConfig authConfig) {
+    this.permissionBackend = permissionBackend;
+    this.externalIds = externalIds;
     this.self = self;
     this.authConfig = authConfig;
   }
 
   @Override
   public List<AccountExternalIdInfo> apply(AccountResource resource)
-      throws RestApiException, OrmException {
+      throws RestApiException, IOException, OrmException, PermissionBackendException {
     if (!self.get().hasSameAccountId(resource.getUser())) {
-      throw new AuthException("not allowed to get external IDs");
+      permissionBackend.user(self).check(GlobalPermission.ACCESS_DATABASE);
     }
 
-    Collection<ExternalId> ids =
-        ExternalId.from(
-            db.get().accountExternalIds().byAccount(resource.getUser().getAccountId()).toList());
+    Collection<ExternalId> ids = externalIds.byAccount(resource.getUser().getAccountId());
     if (ids.isEmpty()) {
       return ImmutableList.of();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
index 61f5b84..d3394f5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetOAuthToken.java
@@ -27,11 +27,14 @@
 import com.google.inject.Singleton;
 import java.net.URI;
 import java.net.URISyntaxException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 class GetOAuthToken implements RestReadView<AccountResource> {
 
   private static final String BEARER_TYPE = "bearer";
+  private static final Logger log = LoggerFactory.getLogger(GetOAuthToken.class);
 
   private final Provider<CurrentUser> self;
   private final OAuthTokenCache tokenCache;
@@ -69,9 +72,15 @@
   }
 
   private static String getHostName(String canonicalWebUrl) {
+    if (canonicalWebUrl == null) {
+      log.error("No canonicalWebUrl defined in gerrit.config, OAuth may not work properly");
+      return null;
+    }
+
     try {
       return new URI(canonicalWebUrl).getHost();
     } catch (URISyntaxException e) {
+      log.error("Invalid canonicalWebUrl '" + canonicalWebUrl + "'", e);
       return null;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
index 95b115f..e79cdbd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetPreferences.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -26,19 +29,22 @@
 @Singleton
 public class GetPreferences implements RestReadView<AccountResource> {
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final AccountCache accountCache;
 
   @Inject
-  GetPreferences(Provider<CurrentUser> self, AccountCache accountCache) {
+  GetPreferences(
+      Provider<CurrentUser> self, PermissionBackend permissionBackend, AccountCache accountCache) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.accountCache = accountCache;
   }
 
   @Override
-  public GeneralPreferencesInfo apply(AccountResource rsrc) throws AuthException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+  public GeneralPreferencesInfo apply(AccountResource rsrc)
+      throws AuthException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     Account.Id id = rsrc.getUser().getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
index a169f6f..66a8bf3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetSshKeys.java
@@ -22,6 +22,9 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+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;
@@ -35,21 +38,25 @@
 public class GetSshKeys implements RestReadView<AccountResource> {
 
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
   @Inject
-  GetSshKeys(Provider<CurrentUser> self, VersionedAuthorizedKeys.Accessor authorizedKeys) {
+  GetSshKeys(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      VersionedAuthorizedKeys.Accessor authorizedKeys) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
   }
 
   @Override
   public List<SshKeyInfo> apply(AccountResource rsrc)
       throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to get SSH keys");
+          ConfigInvalidException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
index d8580eb..cb12a36 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+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;
@@ -38,23 +41,28 @@
 
 @Singleton
 public class GetWatchedProjects implements RestReadView<AccountResource> {
-
+  private final PermissionBackend permissionBackend;
   private final Provider<IdentifiedUser> self;
   private final WatchConfig.Accessor watchConfig;
 
   @Inject
-  public GetWatchedProjects(Provider<IdentifiedUser> self, WatchConfig.Accessor watchConfig) {
+  public GetWatchedProjects(
+      PermissionBackend permissionBackend,
+      Provider<IdentifiedUser> self,
+      WatchConfig.Accessor watchConfig) {
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.watchConfig = watchConfig;
   }
 
   @Override
   public List<ProjectWatchInfo> apply(AccountResource rsrc)
-      throws OrmException, AuthException, IOException, ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("It is not allowed to list project watches of other users");
+      throws OrmException, AuthException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
+
     Account.Id accountId = rsrc.getUser().getAccountId();
     List<ProjectWatchInfo> projectWatchInfos = new ArrayList<>();
     for (Map.Entry<ProjectWatchKey, Set<NotifyType>> e :
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackend.java
index bf71732..2d46260 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackend.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import java.util.Collection;
 
 /** Implementations of GroupBackend provide lookup and membership accessors to a group system. */
@@ -39,7 +39,7 @@
   GroupDescription.Basic get(AccountGroup.UUID uuid);
 
   /** @return suggestions for the group name sorted by name. */
-  Collection<GroupReference> suggest(String name, @Nullable ProjectControl project);
+  Collection<GroupReference> suggest(String name, @Nullable ProjectState project);
 
   /** @return the group membership checker for the backend. */
   GroupMembership membershipsOf(IdentifiedUser user);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackends.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackends.java
index e029954..803d491 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackends.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupBackends.java
@@ -17,7 +17,7 @@
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import java.util.Collection;
 import java.util.Comparator;
 
@@ -33,7 +33,7 @@
       };
 
   /**
-   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the result to return the
+   * Runs {@link GroupBackend#suggest(String, ProjectState)} and filters the result to return the
    * best suggestion, or null if one does not exist.
    *
    * @param groupBackend the group backend
@@ -46,7 +46,7 @@
   }
 
   /**
-   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the result to return the
+   * Runs {@link GroupBackend#suggest(String, ProjectState)} and filters the result to return the
    * best suggestion, or null if one does not exist.
    *
    * @param groupBackend the group backend
@@ -56,7 +56,7 @@
    */
   @Nullable
   public static GroupReference findBestSuggestion(
-      GroupBackend groupBackend, String name, @Nullable ProjectControl project) {
+      GroupBackend groupBackend, String name, @Nullable ProjectState project) {
     Collection<GroupReference> refs = groupBackend.suggest(name, project);
     if (refs.size() == 1) {
       return Iterables.getOnlyElement(refs);
@@ -71,7 +71,7 @@
   }
 
   /**
-   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the result to return the
+   * Runs {@link GroupBackend#suggest(String, ProjectState)} and filters the result to return the
    * exact suggestion, or null if one does not exist.
    *
    * @param groupBackend the group backend
@@ -84,7 +84,7 @@
   }
 
   /**
-   * Runs {@link GroupBackend#suggest(String, ProjectControl)} and filters the result to return the
+   * Runs {@link GroupBackend#suggest(String, ProjectState)} and filters the result to return the
    * exact suggestion, or null if one does not exist.
    *
    * @param groupBackend the group backend
@@ -94,7 +94,7 @@
    */
   @Nullable
   public static GroupReference findExactSuggestion(
-      GroupBackend groupBackend, String name, ProjectControl project) {
+      GroupBackend groupBackend, String name, ProjectState project) {
     Collection<GroupReference> refs = groupBackend.suggest(name, project);
     for (GroupReference ref : refs) {
       if (isExactSuggestion(ref, name)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
index 8e30a24..d985426 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCache.java
@@ -14,32 +14,45 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.group.InternalGroup;
 import java.io.IOException;
+import java.util.Optional;
 
 /** Tracks group objects in memory for efficient access. */
 public interface GroupCache {
-  AccountGroup get(AccountGroup.Id groupId);
-
-  AccountGroup get(AccountGroup.NameKey name);
+  /**
+   * Looks up an internal group by its ID.
+   *
+   * @param groupId the ID of the internal group
+   * @return an {@code Optional} of the internal group, or an empty {@code Optional} if no internal
+   *     group with this ID exists on this server or an error occurred during lookup
+   */
+  Optional<InternalGroup> get(AccountGroup.Id groupId);
 
   /**
-   * Lookup a group definition by its UUID. The returned definition may be null if the group has
-   * been deleted and the UUID reference is stale, or was copied from another server.
+   * Looks up an internal group by its name.
+   *
+   * @param name the name of the internal group
+   * @return an {@code Optional} of the internal group, or an empty {@code Optional} if no internal
+   *     group with this name exists on this server or an error occurred during lookup
    */
-  @Nullable
-  AccountGroup get(AccountGroup.UUID uuid);
+  Optional<InternalGroup> get(AccountGroup.NameKey name);
 
-  /** @return sorted list of groups. */
-  ImmutableList<AccountGroup> all();
+  /**
+   * Looks up an internal group by its UUID.
+   *
+   * @param groupUuid the UUID of the internal group
+   * @return an {@code Optional} of the internal group, or an empty {@code Optional} if no internal
+   *     group with this UUID exists on this server or an error occurred during lookup
+   */
+  Optional<InternalGroup> get(AccountGroup.UUID groupUuid);
 
   /** Notify the cache that a new group was constructed. */
-  void onCreateGroup(AccountGroup.NameKey newGroupName) throws IOException;
+  void onCreateGroup(AccountGroup group) throws IOException;
 
-  void evict(AccountGroup group) throws IOException;
-
-  void evictAfterRename(AccountGroup.NameKey oldName, AccountGroup.NameKey newName)
+  void evict(AccountGroup.UUID groupUuid, AccountGroup.Id groupId, AccountGroup.NameKey groupName)
       throws IOException;
+
+  void evictAfterRename(AccountGroup.NameKey oldName) throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
index 06a4680..393d49a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupCacheImpl.java
@@ -16,14 +16,14 @@
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
-import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.group.Groups;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexer;
-import com.google.gwtorm.server.OrmDuplicateKeyException;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.query.group.InternalGroupQuery;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -32,9 +32,9 @@
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.io.IOException;
-import java.util.List;
 import java.util.Optional;
 import java.util.concurrent.ExecutionException;
+import java.util.function.BooleanSupplier;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -51,13 +51,13 @@
     return new CacheModule() {
       @Override
       protected void configure() {
-        cache(BYID_NAME, AccountGroup.Id.class, new TypeLiteral<Optional<AccountGroup>>() {})
+        cache(BYID_NAME, AccountGroup.Id.class, new TypeLiteral<Optional<InternalGroup>>() {})
             .loader(ByIdLoader.class);
 
-        cache(BYNAME_NAME, String.class, new TypeLiteral<Optional<AccountGroup>>() {})
+        cache(BYNAME_NAME, String.class, new TypeLiteral<Optional<InternalGroup>>() {})
             .loader(ByNameLoader.class);
 
-        cache(BYUUID_NAME, String.class, new TypeLiteral<Optional<AccountGroup>>() {})
+        cache(BYUUID_NAME, String.class, new TypeLiteral<Optional<InternalGroup>>() {})
             .loader(ByUUIDLoader.class);
 
         bind(GroupCacheImpl.class);
@@ -66,168 +66,146 @@
     };
   }
 
-  private final LoadingCache<AccountGroup.Id, Optional<AccountGroup>> byId;
-  private final LoadingCache<String, Optional<AccountGroup>> byName;
-  private final LoadingCache<String, Optional<AccountGroup>> byUUID;
-  private final SchemaFactory<ReviewDb> schema;
+  private final LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId;
+  private final LoadingCache<String, Optional<InternalGroup>> byName;
+  private final LoadingCache<String, Optional<InternalGroup>> byUUID;
   private final Provider<GroupIndexer> indexer;
 
   @Inject
   GroupCacheImpl(
-      @Named(BYID_NAME) LoadingCache<AccountGroup.Id, Optional<AccountGroup>> byId,
-      @Named(BYNAME_NAME) LoadingCache<String, Optional<AccountGroup>> byName,
-      @Named(BYUUID_NAME) LoadingCache<String, Optional<AccountGroup>> byUUID,
-      SchemaFactory<ReviewDb> schema,
+      @Named(BYID_NAME) LoadingCache<AccountGroup.Id, Optional<InternalGroup>> byId,
+      @Named(BYNAME_NAME) LoadingCache<String, Optional<InternalGroup>> byName,
+      @Named(BYUUID_NAME) LoadingCache<String, Optional<InternalGroup>> byUUID,
       Provider<GroupIndexer> indexer) {
     this.byId = byId;
     this.byName = byName;
     this.byUUID = byUUID;
-    this.schema = schema;
     this.indexer = indexer;
   }
 
   @Override
-  public AccountGroup get(final AccountGroup.Id groupId) {
+  public Optional<InternalGroup> get(AccountGroup.Id groupId) {
     try {
-      Optional<AccountGroup> g = byId.get(groupId);
-      return g.isPresent() ? g.get() : missing(groupId);
+      return byId.get(groupId);
     } catch (ExecutionException e) {
       log.warn("Cannot load group {}", groupId, e);
-      return missing(groupId);
+      return Optional.empty();
     }
   }
 
   @Override
-  public void evict(final AccountGroup group) throws IOException {
-    if (group.getId() != null) {
-      byId.invalidate(group.getId());
+  public void evict(
+      AccountGroup.UUID groupUuid, AccountGroup.Id groupId, AccountGroup.NameKey groupName)
+      throws IOException {
+    if (groupId != null) {
+      byId.invalidate(groupId);
     }
-    if (group.getNameKey() != null) {
-      byName.invalidate(group.getNameKey().get());
+    if (groupName != null) {
+      byName.invalidate(groupName.get());
     }
-    if (group.getGroupUUID() != null) {
-      byUUID.invalidate(group.getGroupUUID().get());
+    if (groupUuid != null) {
+      byUUID.invalidate(groupUuid.get());
     }
-    indexer.get().index(group.getGroupUUID());
+    indexer.get().index(groupUuid);
   }
 
   @Override
-  public void evictAfterRename(
-      final AccountGroup.NameKey oldName, final AccountGroup.NameKey newName) throws IOException {
+  public void evictAfterRename(AccountGroup.NameKey oldName) throws IOException {
     if (oldName != null) {
       byName.invalidate(oldName.get());
     }
-    if (newName != null) {
-      byName.invalidate(newName.get());
-    }
-    indexer.get().index(get(newName).getGroupUUID());
   }
 
   @Override
-  public AccountGroup get(AccountGroup.NameKey name) {
+  public Optional<InternalGroup> get(AccountGroup.NameKey name) {
     if (name == null) {
-      return null;
+      return Optional.empty();
     }
     try {
-      return byName.get(name.get()).orElse(null);
+      return byName.get(name.get());
     } catch (ExecutionException e) {
-      log.warn("Cannot lookup group {} by name", name.get(), e);
-      return null;
+      log.warn("Cannot look up group {} by name", name.get(), e);
+      return Optional.empty();
     }
   }
 
   @Override
-  public AccountGroup get(AccountGroup.UUID uuid) {
-    if (uuid == null) {
-      return null;
+  public Optional<InternalGroup> get(AccountGroup.UUID groupUuid) {
+    if (groupUuid == null) {
+      return Optional.empty();
     }
+
     try {
-      return byUUID.get(uuid.get()).orElse(null);
+      return byUUID.get(groupUuid.get());
     } catch (ExecutionException e) {
-      log.warn("Cannot lookup group {} by uuid", uuid.get(), e);
-      return null;
+      log.warn("Cannot look up group {} by uuid", groupUuid.get(), e);
+      return Optional.empty();
     }
   }
 
   @Override
-  public ImmutableList<AccountGroup> all() {
-    try (ReviewDb db = schema.open()) {
-      return ImmutableList.copyOf(db.accountGroups().all());
-    } catch (OrmException e) {
-      log.warn("Cannot list internal groups", e);
-      return ImmutableList.of();
-    }
+  public void onCreateGroup(AccountGroup group) throws IOException {
+    indexer.get().index(group.getGroupUUID());
   }
 
-  @Override
-  public void onCreateGroup(AccountGroup.NameKey newGroupName) throws IOException {
-    byName.invalidate(newGroupName.get());
-    indexer.get().index(get(newGroupName).getGroupUUID());
-  }
-
-  private static AccountGroup missing(AccountGroup.Id key) {
-    AccountGroup.NameKey name = new AccountGroup.NameKey("Deleted Group" + key);
-    return new AccountGroup(name, key, null);
-  }
-
-  static class ByIdLoader extends CacheLoader<AccountGroup.Id, Optional<AccountGroup>> {
+  static class ByIdLoader extends CacheLoader<AccountGroup.Id, Optional<InternalGroup>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final Groups groups;
+    private final BooleanSupplier hasGroupIndex;
+    private final Provider<InternalGroupQuery> groupQueryProvider;
 
     @Inject
-    ByIdLoader(final SchemaFactory<ReviewDb> sf) {
-      schema = sf;
+    ByIdLoader(
+        SchemaFactory<ReviewDb> schema,
+        Groups groups,
+        GroupIndexCollection groupIndexCollection,
+        Provider<InternalGroupQuery> groupQueryProvider) {
+      this.schema = schema;
+      this.groups = groups;
+      hasGroupIndex = () -> groupIndexCollection.getSearchIndex() != null;
+      this.groupQueryProvider = groupQueryProvider;
     }
 
     @Override
-    public Optional<AccountGroup> load(final AccountGroup.Id key) throws Exception {
+    public Optional<InternalGroup> load(AccountGroup.Id key) throws Exception {
+      if (hasGroupIndex.getAsBoolean()) {
+        return groupQueryProvider.get().byId(key);
+      }
+
       try (ReviewDb db = schema.open()) {
-        return Optional.ofNullable(db.accountGroups().get(key));
+        return groups.getGroup(db, key);
       }
     }
   }
 
-  static class ByNameLoader extends CacheLoader<String, Optional<AccountGroup>> {
-    private final SchemaFactory<ReviewDb> schema;
+  static class ByNameLoader extends CacheLoader<String, Optional<InternalGroup>> {
+    private final Provider<InternalGroupQuery> groupQueryProvider;
 
     @Inject
-    ByNameLoader(final SchemaFactory<ReviewDb> sf) {
-      schema = sf;
+    ByNameLoader(Provider<InternalGroupQuery> groupQueryProvider) {
+      this.groupQueryProvider = groupQueryProvider;
     }
 
     @Override
-    public Optional<AccountGroup> load(String name) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        AccountGroup.NameKey key = new AccountGroup.NameKey(name);
-        AccountGroupName r = db.accountGroupNames().get(key);
-        if (r != null) {
-          return Optional.ofNullable(db.accountGroups().get(r.getId()));
-        }
-        return Optional.empty();
-      }
+    public Optional<InternalGroup> load(String name) throws Exception {
+      return groupQueryProvider.get().byName(new AccountGroup.NameKey(name));
     }
   }
 
-  static class ByUUIDLoader extends CacheLoader<String, Optional<AccountGroup>> {
+  static class ByUUIDLoader extends CacheLoader<String, Optional<InternalGroup>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final Groups groups;
 
     @Inject
-    ByUUIDLoader(final SchemaFactory<ReviewDb> sf) {
+    ByUUIDLoader(SchemaFactory<ReviewDb> sf, Groups groups) {
       schema = sf;
+      this.groups = groups;
     }
 
     @Override
-    public Optional<AccountGroup> load(String uuid) throws Exception {
+    public Optional<InternalGroup> load(String uuid) throws Exception {
       try (ReviewDb db = schema.open()) {
-        List<AccountGroup> r;
-
-        r = db.accountGroups().byUUID(new AccountGroup.UUID(uuid)).toList();
-        if (r.size() == 1) {
-          return Optional.of(r.get(0));
-        } else if (r.size() == 0) {
-          return Optional.empty();
-        } else {
-          throw new OrmDuplicateKeyException("Duplicate group UUID " + uuid);
-        }
+        return groups.getGroup(db, new AccountGroup.UUID(uuid));
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupComparator.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupComparator.java
deleted file mode 100644
index 4bab3a7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupComparator.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2011 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.account;
-
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import java.util.Comparator;
-
-public class GroupComparator implements Comparator<AccountGroup> {
-
-  @Override
-  public int compare(final AccountGroup group1, final AccountGroup group2) {
-    return group1.getName().compareTo(group2.getName());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
index ee788ec..020a04d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupControl.java
@@ -17,56 +17,71 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.util.Optional;
 
 /** Access control management for a group of accounts managed in Gerrit. */
 public class GroupControl {
 
   @Singleton
   public static class GenericFactory {
+    private final PermissionBackend permissionBackend;
     private final GroupBackend groupBackend;
 
     @Inject
-    GenericFactory(final GroupBackend gb) {
+    GenericFactory(PermissionBackend permissionBackend, GroupBackend gb) {
+      this.permissionBackend = permissionBackend;
       groupBackend = gb;
     }
 
-    public GroupControl controlFor(final CurrentUser who, final AccountGroup.UUID groupId)
+    public GroupControl controlFor(CurrentUser who, AccountGroup.UUID groupId)
         throws NoSuchGroupException {
-      final GroupDescription.Basic group = groupBackend.get(groupId);
+      GroupDescription.Basic group = groupBackend.get(groupId);
       if (group == null) {
         throw new NoSuchGroupException(groupId);
       }
-      return new GroupControl(who, group, groupBackend);
+      return new GroupControl(who, group, permissionBackend, groupBackend);
     }
   }
 
   public static class Factory {
+    private final PermissionBackend permissionBackend;
     private final GroupCache groupCache;
     private final Provider<CurrentUser> user;
     private final GroupBackend groupBackend;
 
     @Inject
-    Factory(final GroupCache gc, final Provider<CurrentUser> cu, final GroupBackend gb) {
+    Factory(
+        PermissionBackend permissionBackend,
+        GroupCache gc,
+        Provider<CurrentUser> cu,
+        GroupBackend gb) {
+      this.permissionBackend = permissionBackend;
       groupCache = gc;
       user = cu;
       groupBackend = gb;
     }
 
-    public GroupControl controlFor(final AccountGroup.Id groupId) throws NoSuchGroupException {
-      final AccountGroup group = groupCache.get(groupId);
-      if (group == null) {
-        throw new NoSuchGroupException(groupId);
-      }
-      return controlFor(GroupDescriptions.forAccountGroup(group));
+    public GroupControl controlFor(AccountGroup.Id groupId) throws NoSuchGroupException {
+      Optional<InternalGroup> group = groupCache.get(groupId);
+      return group
+          .map(InternalGroupDescription::new)
+          .map(this::controlFor)
+          .orElseThrow(() -> new NoSuchGroupException(groupId));
     }
 
-    public GroupControl controlFor(final AccountGroup.UUID groupId) throws NoSuchGroupException {
+    public GroupControl controlFor(AccountGroup.UUID groupId) throws NoSuchGroupException {
       final GroupDescription.Basic group = groupBackend.get(groupId);
       if (group == null) {
         throw new NoSuchGroupException(groupId);
@@ -79,18 +94,10 @@
     }
 
     public GroupControl controlFor(GroupDescription.Basic group) {
-      return new GroupControl(user.get(), group, groupBackend);
+      return new GroupControl(user.get(), group, permissionBackend, groupBackend);
     }
 
-    public GroupControl validateFor(final AccountGroup.Id groupId) throws NoSuchGroupException {
-      final GroupControl c = controlFor(groupId);
-      if (!c.isVisible()) {
-        throw new NoSuchGroupException(groupId);
-      }
-      return c;
-    }
-
-    public GroupControl validateFor(final AccountGroup.UUID groupUUID) throws NoSuchGroupException {
+    public GroupControl validateFor(AccountGroup.UUID groupUUID) throws NoSuchGroupException {
       final GroupControl c = controlFor(groupUUID);
       if (!c.isVisible()) {
         throw new NoSuchGroupException(groupUUID);
@@ -102,11 +109,17 @@
   private final CurrentUser user;
   private final GroupDescription.Basic group;
   private Boolean isOwner;
+  private final PermissionBackend.WithUser perm;
   private final GroupBackend groupBackend;
 
-  GroupControl(CurrentUser who, GroupDescription.Basic gd, GroupBackend gb) {
+  GroupControl(
+      CurrentUser who,
+      GroupDescription.Basic gd,
+      PermissionBackend permissionBackend,
+      GroupBackend gb) {
     user = who;
     group = gd;
+    this.perm = permissionBackend.user(user);
     groupBackend = gb;
   }
 
@@ -127,23 +140,33 @@
     return user.isInternalUser()
         || groupBackend.isVisibleToAll(group.getGroupUUID())
         || user.getEffectiveGroups().contains(group.getGroupUUID())
-        || user.getCapabilities().canAdministrateServer()
-        || isOwner();
+        || isOwner()
+        || canAdministrateServer();
   }
 
   public boolean isOwner() {
-    AccountGroup accountGroup = GroupDescriptions.toAccountGroup(group);
-    if (accountGroup == null) {
+    if (isOwner != null) {
+      return isOwner;
+    }
+
+    if (group instanceof GroupDescription.Internal) {
+      AccountGroup.UUID ownerUUID = ((GroupDescription.Internal) group).getOwnerGroupUUID();
+      isOwner = getUser().getEffectiveGroups().contains(ownerUUID) || canAdministrateServer();
+    } else {
       isOwner = false;
-    } else if (isOwner == null) {
-      AccountGroup.UUID ownerUUID = accountGroup.getOwnerGroupUUID();
-      isOwner =
-          getUser().getEffectiveGroups().contains(ownerUUID)
-              || getUser().getCapabilities().canAdministrateServer();
     }
     return isOwner;
   }
 
+  private boolean canAdministrateServer() {
+    try {
+      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
+      return true;
+    } catch (AuthException | PermissionBackendException denied) {
+      return false;
+    }
+  }
+
   public boolean canAddMember() {
     return isOwner();
   }
@@ -172,7 +195,9 @@
   }
 
   private boolean canSeeMembers() {
-    AccountGroup accountGroup = GroupDescriptions.toAccountGroup(group);
-    return (accountGroup != null && accountGroup.isVisibleToAll()) || isOwner();
+    if (group instanceof GroupDescription.Internal) {
+      return ((GroupDescription.Internal) group).isVisibleToAll() || isOwner();
+    }
+    return false;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java
deleted file mode 100644
index fb7d7e7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupDetailFactory.java
+++ /dev/null
@@ -1,87 +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.account;
-
-import com.google.gerrit.common.data.GroupDetail;
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.assistedinject.Assisted;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.concurrent.Callable;
-
-public class GroupDetailFactory implements Callable<GroupDetail> {
-  public interface Factory {
-    GroupDetailFactory create(AccountGroup.Id groupId);
-  }
-
-  private final ReviewDb db;
-  private final GroupControl.Factory groupControl;
-  private final GroupCache groupCache;
-
-  private final AccountGroup.Id groupId;
-  private GroupControl control;
-
-  @Inject
-  GroupDetailFactory(
-      ReviewDb db,
-      GroupControl.Factory groupControl,
-      GroupCache groupCache,
-      @Assisted AccountGroup.Id groupId) {
-    this.db = db;
-    this.groupControl = groupControl;
-    this.groupCache = groupCache;
-
-    this.groupId = groupId;
-  }
-
-  @Override
-  public GroupDetail call() throws OrmException, NoSuchGroupException {
-    control = groupControl.validateFor(groupId);
-    AccountGroup group = groupCache.get(groupId);
-    GroupDetail detail = new GroupDetail();
-    detail.setGroup(group);
-    detail.setMembers(loadMembers());
-    detail.setIncludes(loadIncludes());
-    return detail;
-  }
-
-  private List<AccountGroupMember> loadMembers() throws OrmException {
-    List<AccountGroupMember> members = new ArrayList<>();
-    for (AccountGroupMember m : db.accountGroupMembers().byGroup(groupId)) {
-      if (control.canSeeMember(m.getAccountId())) {
-        members.add(m);
-      }
-    }
-    return members;
-  }
-
-  private List<AccountGroupById> loadIncludes() throws OrmException {
-    List<AccountGroupById> groups = new ArrayList<>();
-
-    for (AccountGroupById m : db.accountGroupById().byGroup(groupId)) {
-      if (control.canSeeGroup()) {
-        groups.add(m);
-      }
-    }
-
-    return groups;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
index c702aef..157afb8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCache.java
@@ -14,20 +14,42 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import java.util.Collection;
 
 /** Tracks group inclusions in memory for efficient access. */
 public interface GroupIncludeCache {
-  /** @return groups directly a member of the passed group. */
+
+  /**
+   * Returns the UUIDs of all groups of which the specified account is a direct member.
+   *
+   * @param memberId the ID of the account
+   * @return the UUIDs of all groups having the account as member
+   */
+  Collection<AccountGroup.UUID> getGroupsWithMember(Account.Id memberId);
+
+  /**
+   * Returns the subgroups of a group.
+   *
+   * @param group the UUID of the group
+   * @return the UUIDs of all direct subgroups
+   */
   Collection<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID group);
 
-  /** @return any groups the passed group belongs to. */
+  /**
+   * Returns the parent groups of a subgroup.
+   *
+   * @param groupId the UUID of the subgroup
+   * @return the UUIDs of all direct parent groups
+   */
   Collection<AccountGroup.UUID> parentGroupsOf(AccountGroup.UUID groupId);
 
   /** @return set of any UUIDs that are not internal groups. */
   Collection<AccountGroup.UUID> allExternalMembers();
 
+  void evictGroupsWithMember(Account.Id memberId);
+
   void evictSubgroupsOf(AccountGroup.UUID groupId);
 
   void evictParentGroupsOf(AccountGroup.UUID groupId);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
index 1c9baf8..5d8c04a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupIncludeCacheImpl.java
@@ -14,25 +14,35 @@
 
 package com.google.gerrit.server.account;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
+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.AccountGroupById;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.cache.CacheModule;
+import com.google.gerrit.server.group.Groups;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gerrit.server.query.group.InternalGroupQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Named;
 import java.util.Collection;
 import java.util.Collections;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -41,8 +51,9 @@
 @Singleton
 public class GroupIncludeCacheImpl implements GroupIncludeCache {
   private static final Logger log = LoggerFactory.getLogger(GroupIncludeCacheImpl.class);
-  private static final String PARENT_GROUPS_NAME = "groups_byinclude";
-  private static final String SUBGROUPS_NAME = "groups_members";
+  private static final String PARENT_GROUPS_NAME = "groups_bysubgroup";
+  private static final String SUBGROUPS_NAME = "groups_subgroups";
+  private static final String GROUPS_WITH_MEMBER_NAME = "groups_bymember";
   private static final String EXTERNAL_NAME = "groups_external";
 
   public static Module module() {
@@ -50,6 +61,12 @@
       @Override
       protected void configure() {
         cache(
+                GROUPS_WITH_MEMBER_NAME,
+                Account.Id.class,
+                new TypeLiteral<ImmutableSet<AccountGroup.UUID>>() {})
+            .loader(GroupsWithMemberLoader.class);
+
+        cache(
                 PARENT_GROUPS_NAME,
                 AccountGroup.UUID.class,
                 new TypeLiteral<ImmutableList<AccountGroup.UUID>>() {})
@@ -70,23 +87,37 @@
     };
   }
 
+  private final LoadingCache<Account.Id, ImmutableSet<AccountGroup.UUID>> groupsWithMember;
   private final LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> subgroups;
   private final LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups;
   private final LoadingCache<String, ImmutableList<AccountGroup.UUID>> external;
 
   @Inject
   GroupIncludeCacheImpl(
+      @Named(GROUPS_WITH_MEMBER_NAME)
+          LoadingCache<Account.Id, ImmutableSet<AccountGroup.UUID>> groupsWithMember,
       @Named(SUBGROUPS_NAME)
           LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> subgroups,
       @Named(PARENT_GROUPS_NAME)
           LoadingCache<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> parentGroups,
       @Named(EXTERNAL_NAME) LoadingCache<String, ImmutableList<AccountGroup.UUID>> external) {
+    this.groupsWithMember = groupsWithMember;
     this.subgroups = subgroups;
     this.parentGroups = parentGroups;
     this.external = external;
   }
 
   @Override
+  public Collection<AccountGroup.UUID> getGroupsWithMember(Account.Id memberId) {
+    try {
+      return groupsWithMember.get(memberId);
+    } catch (ExecutionException e) {
+      log.warn("Cannot load groups containing {} as member", memberId.get());
+      return ImmutableSet.of();
+    }
+  }
+
+  @Override
   public Collection<AccountGroup.UUID> subgroupsOf(AccountGroup.UUID groupId) {
     try {
       return subgroups.get(groupId);
@@ -107,6 +138,13 @@
   }
 
   @Override
+  public void evictGroupsWithMember(Account.Id memberId) {
+    if (memberId != null) {
+      groupsWithMember.invalidate(memberId);
+    }
+  }
+
+  @Override
   public void evictSubgroupsOf(AccountGroup.UUID groupId) {
     if (groupId != null) {
       subgroups.invalidate(groupId);
@@ -134,28 +172,63 @@
     }
   }
 
-  static class SubgroupsLoader
-      extends CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
+  static class GroupsWithMemberLoader
+      extends CacheLoader<Account.Id, ImmutableSet<AccountGroup.UUID>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final Provider<GroupIndex> groupIndexProvider;
+    private final Provider<InternalGroupQuery> groupQueryProvider;
+    private final GroupCache groupCache;
 
     @Inject
-    SubgroupsLoader(final SchemaFactory<ReviewDb> sf) {
-      schema = sf;
+    GroupsWithMemberLoader(
+        SchemaFactory<ReviewDb> schema,
+        GroupIndexCollection groupIndexCollection,
+        Provider<InternalGroupQuery> groupQueryProvider,
+        GroupCache groupCache) {
+      this.schema = schema;
+      groupIndexProvider = groupIndexCollection::getSearchIndex;
+      this.groupQueryProvider = groupQueryProvider;
+      this.groupCache = groupCache;
     }
 
     @Override
-    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
+    public ImmutableSet<AccountGroup.UUID> load(Account.Id memberId)
+        throws OrmException, NoSuchGroupException {
+      GroupIndex groupIndex = groupIndexProvider.get();
+      if (groupIndex != null && groupIndex.getSchema().hasField(GroupField.MEMBER)) {
+        return groupQueryProvider
+            .get()
+            .byMember(memberId)
+            .stream()
+            .map(InternalGroup::getGroupUUID)
+            .collect(toImmutableSet());
+      }
       try (ReviewDb db = schema.open()) {
-        List<AccountGroup> group = db.accountGroups().byUUID(key).toList();
-        if (group.size() != 1) {
-          return ImmutableList.of();
-        }
+        return Groups.getGroupsWithMemberFromReviewDb(db, memberId)
+            .map(groupCache::get)
+            .flatMap(Streams::stream)
+            .map(InternalGroup::getGroupUUID)
+            .collect(toImmutableSet());
+      }
+    }
+  }
 
-        Set<AccountGroup.UUID> ids = new HashSet<>();
-        for (AccountGroupById agi : db.accountGroupById().byGroup(group.get(0).getId())) {
-          ids.add(agi.getIncludeUUID());
-        }
-        return ImmutableList.copyOf(ids);
+  static class SubgroupsLoader
+      extends CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
+    private final SchemaFactory<ReviewDb> schema;
+    private final Groups groups;
+
+    @Inject
+    SubgroupsLoader(SchemaFactory<ReviewDb> sf, Groups groups) {
+      schema = sf;
+      this.groups = groups;
+    }
+
+    @Override
+    public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key)
+        throws OrmException, NoSuchGroupException {
+      try (ReviewDb db = schema.open()) {
+        return groups.getSubgroups(db, key).collect(toImmutableList());
       }
     }
   }
@@ -163,47 +236,57 @@
   static class ParentGroupsLoader
       extends CacheLoader<AccountGroup.UUID, ImmutableList<AccountGroup.UUID>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final Provider<GroupIndex> groupIndexProvider;
+    private final Provider<InternalGroupQuery> groupQueryProvider;
+    private final GroupCache groupCache;
 
     @Inject
-    ParentGroupsLoader(final SchemaFactory<ReviewDb> sf) {
+    ParentGroupsLoader(
+        SchemaFactory<ReviewDb> sf,
+        GroupIndexCollection groupIndexCollection,
+        Provider<InternalGroupQuery> groupQueryProvider,
+        GroupCache groupCache) {
       schema = sf;
+      this.groupIndexProvider = groupIndexCollection::getSearchIndex;
+      this.groupQueryProvider = groupQueryProvider;
+      this.groupCache = groupCache;
     }
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(AccountGroup.UUID key) throws OrmException {
+      GroupIndex groupIndex = groupIndexProvider.get();
+      if (groupIndex != null && groupIndex.getSchema().hasField(GroupField.SUBGROUP)) {
+        return groupQueryProvider
+            .get()
+            .bySubgroup(key)
+            .stream()
+            .map(InternalGroup::getGroupUUID)
+            .collect(toImmutableList());
+      }
       try (ReviewDb db = schema.open()) {
-        Set<AccountGroup.Id> ids = new HashSet<>();
-        for (AccountGroupById agi : db.accountGroupById().byIncludeUUID(key)) {
-          ids.add(agi.getGroupId());
-        }
-
-        Set<AccountGroup.UUID> groupArray = new HashSet<>();
-        for (AccountGroup g : db.accountGroups().get(ids)) {
-          groupArray.add(g.getGroupUUID());
-        }
-        return ImmutableList.copyOf(groupArray);
+        return Groups.getParentGroupsFromReviewDb(db, key)
+            .map(groupCache::get)
+            .flatMap(Streams::stream)
+            .map(InternalGroup::getGroupUUID)
+            .collect(toImmutableList());
       }
     }
   }
 
   static class AllExternalLoader extends CacheLoader<String, ImmutableList<AccountGroup.UUID>> {
     private final SchemaFactory<ReviewDb> schema;
+    private final Groups groups;
 
     @Inject
-    AllExternalLoader(final SchemaFactory<ReviewDb> sf) {
+    AllExternalLoader(SchemaFactory<ReviewDb> sf, Groups groups) {
       schema = sf;
+      this.groups = groups;
     }
 
     @Override
     public ImmutableList<AccountGroup.UUID> load(String key) throws Exception {
       try (ReviewDb db = schema.open()) {
-        Set<AccountGroup.UUID> ids = new HashSet<>();
-        for (AccountGroupById agi : db.accountGroupById().all()) {
-          if (!AccountGroup.isInternalGroup(agi.getIncludeUUID())) {
-            ids.add(agi.getIncludeUUID());
-          }
-        }
-        return ImmutableList.copyOf(ids);
+        return groups.getExternalGroups(db).collect(toImmutableList());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
index ea99b9b..4dc960d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GroupMembers.java
@@ -14,14 +14,16 @@
 
 package com.google.gerrit.server.account;
 
-import com.google.gerrit.common.data.GroupDetail;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.Sets;
 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.AccountGroupById;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.InternalGroupDescription;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
@@ -31,6 +33,7 @@
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.Optional;
 import java.util.Set;
 
 public class GroupMembers {
@@ -39,26 +42,26 @@
   }
 
   private final GroupCache groupCache;
-  private final GroupDetailFactory.Factory groupDetailFactory;
+  private final GroupControl.Factory groupControlFactory;
   private final AccountCache accountCache;
   private final ProjectControl.GenericFactory projectControl;
   private final CurrentUser currentUser;
 
   @Inject
   GroupMembers(
-      final GroupCache groupCache,
-      final GroupDetailFactory.Factory groupDetailFactory,
-      final AccountCache accountCache,
-      final ProjectControl.GenericFactory projectControl,
-      @Assisted final CurrentUser currentUser) {
+      GroupCache groupCache,
+      GroupControl.Factory groupControlFactory,
+      AccountCache accountCache,
+      ProjectControl.GenericFactory projectControl,
+      @Assisted CurrentUser currentUser) {
     this.groupCache = groupCache;
-    this.groupDetailFactory = groupDetailFactory;
+    this.groupControlFactory = groupControlFactory;
     this.accountCache = accountCache;
     this.projectControl = projectControl;
     this.currentUser = currentUser;
   }
 
-  public Set<Account> listAccounts(final AccountGroup.UUID groupUUID, final Project.NameKey project)
+  public Set<Account> listAccounts(AccountGroup.UUID groupUUID, Project.NameKey project)
       throws NoSuchGroupException, NoSuchProjectException, OrmException, IOException {
     return listAccounts(groupUUID, project, new HashSet<AccountGroup.UUID>());
   }
@@ -71,15 +74,14 @@
     if (SystemGroupBackend.PROJECT_OWNERS.equals(groupUUID)) {
       return getProjectOwners(project, seen);
     }
-    AccountGroup group = groupCache.get(groupUUID);
-    if (group != null) {
-      return getGroupMembers(group, project, seen);
+    Optional<InternalGroup> group = groupCache.get(groupUUID);
+    if (group.isPresent()) {
+      return getGroupMembers(group.get(), project, seen);
     }
     return Collections.emptySet();
   }
 
-  private Set<Account> getProjectOwners(
-      final Project.NameKey project, final Set<AccountGroup.UUID> seen)
+  private Set<Account> getProjectOwners(final Project.NameKey project, Set<AccountGroup.UUID> seen)
       throws NoSuchProjectException, NoSuchGroupException, OrmException, IOException {
     seen.add(SystemGroupBackend.PROJECT_OWNERS);
     if (project == null) {
@@ -90,7 +92,7 @@
         projectControl.controlFor(project, currentUser).getProjectState().getAllOwners();
 
     final HashSet<Account> projectOwners = new HashSet<>();
-    for (final AccountGroup.UUID ownerGroup : ownerGroups) {
+    for (AccountGroup.UUID ownerGroup : ownerGroups) {
       if (!seen.contains(ownerGroup)) {
         projectOwners.addAll(listAccounts(ownerGroup, project, seen));
       }
@@ -99,25 +101,29 @@
   }
 
   private Set<Account> getGroupMembers(
-      final AccountGroup group, final Project.NameKey project, final Set<AccountGroup.UUID> seen)
+      InternalGroup group, Project.NameKey project, Set<AccountGroup.UUID> seen)
       throws NoSuchGroupException, OrmException, NoSuchProjectException, IOException {
     seen.add(group.getGroupUUID());
-    final GroupDetail groupDetail = groupDetailFactory.create(group.getId()).call();
+    GroupControl groupControl = groupControlFactory.controlFor(new InternalGroupDescription(group));
 
-    final Set<Account> members = new HashSet<>();
-    if (groupDetail.members != null) {
-      for (final AccountGroupMember member : groupDetail.members) {
-        members.add(accountCache.get(member.getAccountId()).getAccount());
-      }
-    }
-    if (groupDetail.includes != null) {
-      for (final AccountGroupById groupInclude : groupDetail.includes) {
-        final AccountGroup includedGroup = groupCache.get(groupInclude.getIncludeUUID());
-        if (includedGroup != null && !seen.contains(includedGroup.getGroupUUID())) {
-          members.addAll(listAccounts(includedGroup.getGroupUUID(), project, seen));
+    Set<Account> directMembers =
+        group
+            .getMembers()
+            .stream()
+            .filter(groupControl::canSeeMember)
+            .map(accountCache::get)
+            .map(AccountState::getAccount)
+            .collect(toImmutableSet());
+
+    Set<Account> indirectMembers = new HashSet<>();
+    if (groupControl.canSeeGroup()) {
+      for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
+        if (!seen.contains(subgroupUuid)) {
+          indirectMembers.addAll(listAccounts(subgroupUuid, project, seen));
         }
       }
     }
-    return members;
+
+    return Sets.union(directMembers, indirectMembers);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
index 70801c3..a077629 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/IncludingGroupMembership.java
@@ -19,11 +19,14 @@
 import com.google.common.collect.Sets;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 
@@ -40,21 +43,19 @@
     IncludingGroupMembership create(IdentifiedUser user);
   }
 
+  private final GroupCache groupCache;
   private final GroupIncludeCache includeCache;
   private final IdentifiedUser user;
   private final Map<AccountGroup.UUID, Boolean> memberOf;
   private Set<AccountGroup.UUID> knownGroups;
 
   @Inject
-  IncludingGroupMembership(GroupIncludeCache includeCache, @Assisted IdentifiedUser user) {
+  IncludingGroupMembership(
+      GroupCache groupCache, GroupIncludeCache includeCache, @Assisted IdentifiedUser user) {
+    this.groupCache = groupCache;
     this.includeCache = includeCache;
     this.user = user;
-
-    Set<AccountGroup.UUID> groups = user.state().getInternalGroups();
-    memberOf = new ConcurrentHashMap<>(groups.size());
-    for (AccountGroup.UUID g : groups) {
-      memberOf.put(g, true);
-    }
+    memberOf = new ConcurrentHashMap<>();
   }
 
   @Override
@@ -88,7 +89,15 @@
         }
 
         memberOf.put(id, false);
-        if (search(includeCache.subgroupsOf(id))) {
+        Optional<InternalGroup> group = groupCache.get(id);
+        if (!group.isPresent()) {
+          continue;
+        }
+        if (group.get().getMembers().contains(user.getAccountId())) {
+          memberOf.put(id, true);
+          return true;
+        }
+        if (search(group.get().getSubgroups())) {
           memberOf.put(id, true);
           return true;
         }
@@ -115,7 +124,8 @@
 
   private ImmutableSet<AccountGroup.UUID> computeKnownGroups() {
     GroupMembership membership = user.getEffectiveGroups();
-    Set<AccountGroup.UUID> direct = user.state().getInternalGroups();
+    Collection<AccountGroup.UUID> direct = includeCache.getGroupsWithMember(user.getAccountId());
+    direct.forEach(groupUuid -> memberOf.put(groupUuid, true));
     Set<AccountGroup.UUID> r = Sets.newHashSet(direct);
     r.remove(null);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
index 238241c..6feb287 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Index.java
@@ -19,6 +19,9 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.Index.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -29,19 +32,22 @@
   public static class Input {}
 
   private final AccountCache accountCache;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
 
   @Inject
-  Index(AccountCache accountCache, Provider<CurrentUser> self) {
+  Index(
+      AccountCache accountCache, PermissionBackend permissionBackend, Provider<CurrentUser> self) {
     this.accountCache = accountCache;
+    this.permissionBackend = permissionBackend;
     this.self = self;
   }
 
   @Override
-  public Response<?> apply(AccountResource rsrc, Input input) throws IOException, AuthException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to index account");
+  public Response<?> apply(AccountResource rsrc, Input input)
+      throws IOException, AuthException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     // evicting the account from the cache, reindexes the account
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
index 7791a2e..e7ff314 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalAccountDirectory.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
index b0ada0d..3f4fee9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/InternalGroupBackend.java
@@ -16,12 +16,17 @@
 
 import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.group.Groups;
+import com.google.gerrit.server.group.InternalGroupDescription;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -32,15 +37,21 @@
 public class InternalGroupBackend implements GroupBackend {
   private final GroupControl.Factory groupControlFactory;
   private final GroupCache groupCache;
+  private final Groups groups;
+  private final SchemaFactory<ReviewDb> schema;
   private final IncludingGroupMembership.Factory groupMembershipFactory;
 
   @Inject
   InternalGroupBackend(
       GroupControl.Factory groupControlFactory,
       GroupCache groupCache,
+      Groups groups,
+      SchemaFactory<ReviewDb> schema,
       IncludingGroupMembership.Factory groupMembershipFactory) {
     this.groupControlFactory = groupControlFactory;
     this.groupCache = groupCache;
+    this.groups = groups;
+    this.schema = schema;
     this.groupMembershipFactory = groupMembershipFactory;
   }
 
@@ -56,25 +67,29 @@
       return null;
     }
 
-    AccountGroup g = groupCache.get(uuid);
-    if (g == null) {
-      return null;
-    }
-    return GroupDescriptions.forAccountGroup(g);
+    return groupCache.get(uuid).map(InternalGroupDescription::new).orElse(null);
   }
 
   @Override
-  public Collection<GroupReference> suggest(final String name, final ProjectControl project) {
-    return groupCache
-        .all()
-        .stream()
-        .filter(
-            group ->
-                // startsWithIgnoreCase && isVisible
-                group.getName().regionMatches(true, 0, name, 0, name.length())
-                    && groupControlFactory.controlFor(group).isVisible())
-        .map(GroupReference::forGroup)
-        .collect(toList());
+  public Collection<GroupReference> suggest(String name, ProjectState project) {
+    try (ReviewDb db = schema.open()) {
+      return groups
+          .getAll(db)
+          .filter(group -> startsWithIgnoreCase(group, name))
+          .filter(this::isVisible)
+          .map(GroupReference::forGroup)
+          .collect(toList());
+    } catch (OrmException e) {
+      return ImmutableList.of();
+    }
+  }
+
+  private static boolean startsWithIgnoreCase(AccountGroup group, String name) {
+    return group.getName().regionMatches(true, 0, name, 0, name.length());
+  }
+
+  private boolean isVisible(AccountGroup group) {
+    return groupControlFactory.controlFor(group).isVisible();
   }
 
   @Override
@@ -85,6 +100,6 @@
   @Override
   public boolean isVisibleToAll(AccountGroup.UUID uuid) {
     GroupDescription.Internal g = get(uuid);
-    return g != null && g.getAccountGroup().isVisibleToAll();
+    return g != null && g.isVisibleToAll();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
index 775ce6d..44060be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Module.java
@@ -51,7 +51,7 @@
     get(ACCOUNT_KIND, "active").to(GetActive.class);
     put(ACCOUNT_KIND, "active").to(PutActive.class);
     delete(ACCOUNT_KIND, "active").to(DeleteActive.class);
-    child(ACCOUNT_KIND, "emails").to(Emails.class);
+    child(ACCOUNT_KIND, "emails").to(EmailsCollection.class);
     get(EMAIL_KIND).to(GetEmail.class);
     put(EMAIL_KIND).to(PutEmail.class);
     delete(EMAIL_KIND).to(DeleteEmail.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
index 7a4e0ec..d7f3ba9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PostWatchedProjects.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -24,6 +23,9 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
+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.project.ProjectsCollection;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,6 +43,7 @@
 public class PostWatchedProjects
     implements RestModifyView<AccountResource, List<ProjectWatchInfo>> {
   private final Provider<IdentifiedUser> self;
+  private final PermissionBackend permissionBackend;
   private final GetWatchedProjects getWatchedProjects;
   private final ProjectsCollection projectsCollection;
   private final AccountCache accountCache;
@@ -49,11 +52,13 @@
   @Inject
   public PostWatchedProjects(
       Provider<IdentifiedUser> self,
+      PermissionBackend permissionBackend,
       GetWatchedProjects getWatchedProjects,
       ProjectsCollection projectsCollection,
       AccountCache accountCache,
       WatchConfig.Accessor watchConfig) {
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.getWatchedProjects = getWatchedProjects;
     this.projectsCollection = projectsCollection;
     this.accountCache = accountCache;
@@ -62,11 +67,12 @@
 
   @Override
   public List<ProjectWatchInfo> apply(AccountResource rsrc, List<ProjectWatchInfo> input)
-      throws OrmException, RestApiException, IOException, ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to edit project watches");
+      throws OrmException, RestApiException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
+
     Account.Id accountId = rsrc.getUser().getAccountId();
     watchConfig.upsertProjectWatches(accountId, asMap(input));
     accountCache.evict(accountId);
@@ -74,7 +80,8 @@
   }
 
   private Map<ProjectWatchKey, Set<NotifyType>> asMap(List<ProjectWatchInfo> input)
-      throws BadRequestException, UnprocessableEntityException, IOException {
+      throws BadRequestException, UnprocessableEntityException, IOException,
+          PermissionBackendException {
     Map<ProjectWatchKey, Set<NotifyType>> m = new HashMap<>();
     for (ProjectWatchInfo info : input) {
       if (info.project == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
index 32c5345..7ce2ea8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutActive.java
@@ -19,58 +19,28 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.PutActive.Input;
-import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
-import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 @Singleton
 public class PutActive implements RestModifyView<AccountResource, Input> {
   public static class Input {}
 
-  private final Provider<ReviewDb> dbProvider;
-  private final AccountCache byIdCache;
+  private final SetInactiveFlag setInactiveFlag;
 
   @Inject
-  PutActive(Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
-    this.dbProvider = dbProvider;
-    this.byIdCache = byIdCache;
+  PutActive(SetInactiveFlag setInactiveFlag) {
+    this.setInactiveFlag = setInactiveFlag;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
-      throws ResourceNotFoundException, OrmException, IOException {
-    AtomicBoolean alreadyActive = new AtomicBoolean(false);
-    Account a =
-        dbProvider
-            .get()
-            .accounts()
-            .atomicUpdate(
-                rsrc.getUser().getAccountId(),
-                new AtomicUpdate<Account>() {
-                  @Override
-                  public Account update(Account a) {
-                    if (a.isActive()) {
-                      alreadyActive.set(true);
-                    } else {
-                      a.setActive(true);
-                    }
-                    return a;
-                  }
-                });
-    if (a == null) {
-      throw new ResourceNotFoundException("account not found");
-    }
-    dbProvider.get().accounts().update(Collections.singleton(a));
-    byIdCache.evict(a.getId());
-    return alreadyActive.get() ? Response.ok("") : Response.created("");
+      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException {
+    return setInactiveFlag.activate(rsrc.getUser().getAccountId());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
index e622575..b27ebf4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutAgreement.java
@@ -17,6 +17,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AgreementInput;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -43,7 +44,6 @@
 @Singleton
 public class PutAgreement implements RestModifyView<AccountResource, AgreementInput> {
   private final ProjectCache projectCache;
-  private final GroupCache groupCache;
   private final Provider<IdentifiedUser> self;
   private final AgreementSignup agreementSignup;
   private final AddMembers addMembers;
@@ -52,13 +52,11 @@
   @Inject
   PutAgreement(
       ProjectCache projectCache,
-      GroupCache groupCache,
       Provider<IdentifiedUser> self,
       AgreementSignup agreementSignup,
       AddMembers addMembers,
       @GerritServerConfig Config config) {
     this.projectCache = projectCache;
-    this.groupCache = groupCache;
     this.self = self;
     this.agreementSignup = agreementSignup;
     this.addMembers = addMembers;
@@ -92,13 +90,12 @@
       throw new ResourceConflictException("autoverify group uuid not found");
     }
 
-    AccountGroup group = groupCache.get(uuid);
-    if (group == null) {
+    Account account = self.get().getAccount();
+    try {
+      addMembers.addMembers(uuid, ImmutableList.of(account.getId()));
+    } catch (NoSuchGroupException e) {
       throw new ResourceConflictException("autoverify group not found");
     }
-
-    Account account = self.get().getAccount();
-    addMembers.addMembers(group.getId(), ImmutableList.of(account.getId()));
     agreementSignup.fire(account, agreementName);
 
     return Response.ok(agreementName);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
index 0174ff1..deb859a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutHttpPassword.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.account;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -22,10 +22,15 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutHttpPassword.Input;
+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.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;
@@ -53,23 +58,30 @@
   }
 
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+  private final ExternalIds externalIds;
   private final ExternalIdsUpdate.User externalIdsUpdate;
 
   @Inject
   PutHttpPassword(
       Provider<CurrentUser> self,
-      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
+      ExternalIds externalIds,
       ExternalIdsUpdate.User externalIdsUpdate) {
     this.self = self;
-    this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
+    this.externalIds = externalIds;
     this.externalIdsUpdate = externalIdsUpdate;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
       throws AuthException, ResourceNotFoundException, ResourceConflictException, OrmException,
-          IOException, ConfigInvalidException {
+          IOException, ConfigInvalidException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
+    }
+
     if (input == null) {
       input = new Input();
     }
@@ -77,49 +89,31 @@
 
     String newPassword;
     if (input.generate) {
-      if (!self.get().hasSameAccountId(rsrc.getUser())
-          && !self.get().getCapabilities().canAdministrateServer()) {
-        throw new AuthException("not allowed to generate HTTP password");
-      }
       newPassword = generate();
-
     } else if (input.httpPassword == null) {
-      if (!self.get().hasSameAccountId(rsrc.getUser())
-          && !self.get().getCapabilities().canAdministrateServer()) {
-        throw new AuthException("not allowed to clear HTTP password");
-      }
       newPassword = null;
     } else {
-      if (!self.get().getCapabilities().canAdministrateServer()) {
-        throw new AuthException(
-            "not allowed to set HTTP password directly, "
-                + "requires the Administrate Server permission");
-      }
+      // Only administrators can explicitly set the password.
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
       newPassword = input.httpPassword;
     }
     return apply(rsrc.getUser(), newPassword);
   }
 
   public Response<String> apply(IdentifiedUser user, String newPassword)
-      throws ResourceNotFoundException, ResourceConflictException, OrmException, IOException {
+      throws ResourceNotFoundException, ResourceConflictException, OrmException, IOException,
+          ConfigInvalidException {
     if (user.getUserName() == null) {
       throw new ResourceConflictException("username must be set");
     }
 
-    ExternalId extId =
-        ExternalId.from(
-            dbProvider
-                .get()
-                .accountExternalIds()
-                .get(
-                    ExternalId.Key.create(SCHEME_USERNAME, user.getUserName())
-                        .asAccountExternalIdKey()));
+    ExternalId extId = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, user.getUserName()));
     if (extId == null) {
       throw new ResourceNotFoundException();
     }
     ExternalId newExtId =
         ExternalId.createWithPassword(extId.key(), extId.accountId(), extId.email(), newPassword);
-    externalIdsUpdate.create().upsert(dbProvider.get(), newExtId);
+    externalIdsUpdate.create().upsert(newExtId);
 
     return Strings.isNullOrEmpty(newPassword) ? Response.<String>none() : Response.ok(newPassword);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
index a00e2ad..cf66d68 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutName.java
@@ -23,16 +23,18 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutName.Input;
-import com.google.gwtorm.server.AtomicUpdate;
+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 java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class PutName implements RestModifyView<AccountResource, Input> {
@@ -42,34 +44,34 @@
 
   private final Provider<CurrentUser> self;
   private final Realm realm;
-  private final Provider<ReviewDb> dbProvider;
-  private final AccountCache byIdCache;
+  private final PermissionBackend permissionBackend;
+  private final AccountsUpdate.Server accountsUpdate;
 
   @Inject
   PutName(
       Provider<CurrentUser> self,
       Realm realm,
-      Provider<ReviewDb> dbProvider,
-      AccountCache byIdCache) {
+      PermissionBackend permissionBackend,
+      AccountsUpdate.Server accountsUpdate) {
     this.self = self;
     this.realm = realm;
-    this.dbProvider = dbProvider;
-    this.byIdCache = byIdCache;
+    this.permissionBackend = permissionBackend;
+    this.accountsUpdate = accountsUpdate;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
       throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-          IOException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to change name");
+          IOException, PermissionBackendException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), input);
   }
 
   public Response<String> apply(IdentifiedUser user, Input input)
-      throws MethodNotAllowedException, ResourceNotFoundException, OrmException, IOException {
+      throws MethodNotAllowedException, ResourceNotFoundException, IOException,
+          ConfigInvalidException {
     if (input == null) {
       input = new Input();
     }
@@ -79,25 +81,13 @@
     }
 
     String newName = input.name;
-    Account a =
-        dbProvider
-            .get()
-            .accounts()
-            .atomicUpdate(
-                user.getAccountId(),
-                new AtomicUpdate<Account>() {
-                  @Override
-                  public Account update(Account a) {
-                    a.setFullName(newName);
-                    return a;
-                  }
-                });
-    if (a == null) {
+    Account account =
+        accountsUpdate.create().update(user.getAccountId(), a -> a.setFullName(newName));
+    if (account == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    byIdCache.evict(a.getId());
-    return Strings.isNullOrEmpty(a.getFullName())
-        ? Response.<String>none()
-        : Response.ok(a.getFullName());
+    return Strings.isNullOrEmpty(account.getFullName())
+        ? Response.none()
+        : Response.ok(account.getFullName());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
index d86a312..f4ba6d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutPreferred.java
@@ -19,69 +19,66 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutPreferred.Input;
-import com.google.gwtorm.server.AtomicUpdate;
+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 java.io.IOException;
-import java.util.Collections;
 import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class PutPreferred implements RestModifyView<AccountResource.Email, Input> {
   static class Input {}
 
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> dbProvider;
-  private final AccountCache byIdCache;
+  private final PermissionBackend permissionBackend;
+  private final AccountsUpdate.Server accountsUpdate;
 
   @Inject
-  PutPreferred(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
+  PutPreferred(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AccountsUpdate.Server accountsUpdate) {
     this.self = self;
-    this.dbProvider = dbProvider;
-    this.byIdCache = byIdCache;
+    this.permissionBackend = permissionBackend;
+    this.accountsUpdate = accountsUpdate;
   }
 
   @Override
   public Response<String> apply(AccountResource.Email rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException, IOException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to set preferred email address");
+      throws AuthException, ResourceNotFoundException, OrmException, IOException,
+          PermissionBackendException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), rsrc.getEmail());
   }
 
   public Response<String> apply(IdentifiedUser user, String email)
-      throws ResourceNotFoundException, OrmException, IOException {
+      throws ResourceNotFoundException, IOException, ConfigInvalidException {
     AtomicBoolean alreadyPreferred = new AtomicBoolean(false);
-    Account a =
-        dbProvider
-            .get()
-            .accounts()
-            .atomicUpdate(
+    Account account =
+        accountsUpdate
+            .create()
+            .update(
                 user.getAccountId(),
-                new AtomicUpdate<Account>() {
-                  @Override
-                  public Account update(Account a) {
-                    if (email.equals(a.getPreferredEmail())) {
-                      alreadyPreferred.set(true);
-                    } else {
-                      a.setPreferredEmail(email);
-                    }
-                    return a;
+                a -> {
+                  if (email.equals(a.getPreferredEmail())) {
+                    alreadyPreferred.set(true);
+                  } else {
+                    a.setPreferredEmail(email);
                   }
                 });
-    if (a == null) {
+    if (account == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    dbProvider.get().accounts().update(Collections.singleton(a));
-    byIdCache.evict(a.getId());
     return alreadyPreferred.get() ? Response.ok("") : Response.created("");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
index c16d8da..3f7c4f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutStatus.java
@@ -21,16 +21,18 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.PutStatus.Input;
-import com.google.gwtorm.server.AtomicUpdate;
+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 java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class PutStatus implements RestModifyView<AccountResource, Input> {
@@ -45,50 +47,45 @@
   }
 
   private final Provider<CurrentUser> self;
-  private final Provider<ReviewDb> dbProvider;
-  private final AccountCache byIdCache;
+  private final PermissionBackend permissionBackend;
+  private final AccountsUpdate.Server accountsUpdate;
 
   @Inject
-  PutStatus(Provider<CurrentUser> self, Provider<ReviewDb> dbProvider, AccountCache byIdCache) {
+  PutStatus(
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      AccountsUpdate.Server accountsUpdate) {
     this.self = self;
-    this.dbProvider = dbProvider;
-    this.byIdCache = byIdCache;
+    this.permissionBackend = permissionBackend;
+    this.accountsUpdate = accountsUpdate;
   }
 
   @Override
   public Response<String> apply(AccountResource rsrc, Input input)
-      throws AuthException, ResourceNotFoundException, OrmException, IOException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("not allowed to set status");
+      throws AuthException, ResourceNotFoundException, OrmException, IOException,
+          PermissionBackendException, ConfigInvalidException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
     return apply(rsrc.getUser(), input);
   }
 
   public Response<String> apply(IdentifiedUser user, Input input)
-      throws ResourceNotFoundException, OrmException, IOException {
+      throws ResourceNotFoundException, IOException, ConfigInvalidException {
     if (input == null) {
       input = new Input();
     }
 
     String newStatus = input.status;
-    Account a =
-        dbProvider
-            .get()
-            .accounts()
-            .atomicUpdate(
-                user.getAccountId(),
-                new AtomicUpdate<Account>() {
-                  @Override
-                  public Account update(Account a) {
-                    a.setStatus(Strings.nullToEmpty(newStatus));
-                    return a;
-                  }
-                });
-    if (a == null) {
+    Account account =
+        accountsUpdate
+            .create()
+            .update(user.getAccountId(), a -> a.setStatus(Strings.nullToEmpty(newStatus)));
+    if (account == null) {
       throw new ResourceNotFoundException("account not found");
     }
-    byIdCache.evict(a.getId());
-    return Strings.isNullOrEmpty(a.getStatus()) ? Response.none() : Response.ok(a.getStatus());
+    return Strings.isNullOrEmpty(account.getStatus())
+        ? Response.none()
+        : Response.ok(account.getStatus());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
index 21b1720..785aa66 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/PutUsername.java
@@ -22,9 +22,11 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.PutUsername.Input;
+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;
@@ -40,28 +42,28 @@
 
   private final Provider<CurrentUser> self;
   private final ChangeUserName.Factory changeUserNameFactory;
+  private final PermissionBackend permissionBackend;
   private final Realm realm;
-  private final Provider<ReviewDb> db;
 
   @Inject
   PutUsername(
       Provider<CurrentUser> self,
       ChangeUserName.Factory changeUserNameFactory,
-      Realm realm,
-      Provider<ReviewDb> db) {
+      PermissionBackend permissionBackend,
+      Realm realm) {
     this.self = self;
     this.changeUserNameFactory = changeUserNameFactory;
+    this.permissionBackend = permissionBackend;
     this.realm = realm;
-    this.db = db;
   }
 
   @Override
   public String apply(AccountResource rsrc, Input input)
       throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
-          ResourceConflictException, OrmException, IOException, ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not allowed to set username");
+          ResourceConflictException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
     if (!realm.allowsEdit(AccountFieldName.USER_NAME)) {
@@ -73,7 +75,7 @@
     }
 
     try {
-      changeUserNameFactory.create(db.get(), rsrc.getUser(), input.username).call();
+      changeUserNameFactory.create(rsrc.getUser(), input.username).call();
     } catch (IllegalStateException e) {
       if (ChangeUserName.USERNAME_CANNOT_BE_CHANGED.equals(e.getMessage())) {
         throw new MethodNotAllowedException(e.getMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java
index a2de481..c23e16f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/QueryAccounts.java
@@ -18,17 +18,19 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.client.ListAccountsOption;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountDirectory.FillOptions;
 import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.QueryResult;
+import com.google.gerrit.server.query.account.AccountPredicates;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
 import com.google.gerrit.server.query.account.AccountQueryProcessor;
 import com.google.gwtorm.server.OrmException;
@@ -69,7 +71,7 @@
       metaVar = "CNT",
       usage = "maximum number of users to return")
   public void setLimit(int n) {
-    queryProcessor.setLimit(n);
+    queryProcessor.setUserProvidedLimit(n);
 
     if (n < 0) {
       suggestLimit = 10;
@@ -173,10 +175,15 @@
       Predicate<AccountState> queryPred;
       if (suggest) {
         queryPred = queryBuilder.defaultQuery(query);
-        queryProcessor.setLimit(suggestLimit);
+        queryProcessor.setUserProvidedLimit(suggestLimit);
       } else {
         queryPred = queryBuilder.parse(query);
       }
+      if (!AccountPredicates.hasActive(queryPred)) {
+        // if neither 'is:active' nor 'is:inactive' appears in the query only
+        // active accounts should be queried
+        queryPred = AccountPredicates.andActive(queryPred);
+      }
       QueryResult<AccountState> result = queryProcessor.query(queryPred);
       for (AccountState accountState : result.entities()) {
         Account.Id id = accountState.getAccount().getId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
index 5d551bc..6174d94 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Realm.java
@@ -17,7 +17,12 @@
 import com.google.gerrit.extensions.client.AccountFieldName;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import java.io.IOException;
+import java.util.Collection;
 import java.util.Set;
+import javax.naming.NamingException;
+import javax.security.auth.login.LoginException;
 
 public interface Realm {
   /** Can the end-user modify this field of their own account? */
@@ -43,5 +48,22 @@
    * where there is an {@link EmailExpander} configured that knows how to convert the accountName
    * into an email address, and then locate the user by that email address.
    */
-  Account.Id lookup(String accountName);
+  Account.Id lookup(String accountName) throws IOException;
+
+  /**
+   * @return true if the account is active.
+   * @throws NamingException
+   * @throws LoginException
+   * @throws AccountException
+   */
+  default boolean isActive(@SuppressWarnings("unused") String username)
+      throws LoginException, NamingException, AccountException {
+    return true;
+  }
+
+  /** @return true if the account is backed by the realm, false otherwise. */
+  default boolean accountBelongsToRealm(
+      @SuppressWarnings("unused") Collection<ExternalId> externalIds) {
+    return false;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
index c72ff02..67f276d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetDiffPreferences.java
@@ -29,6 +29,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -41,6 +44,7 @@
   private final Provider<CurrentUser> self;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitMgr;
 
   @Inject
@@ -48,20 +52,21 @@
       Provider<CurrentUser> self,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllUsersName allUsersName,
+      PermissionBackend permissionBackend,
       GitRepositoryManager gitMgr) {
     this.self = self;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
+    this.permissionBackend = permissionBackend;
     this.gitMgr = gitMgr;
   }
 
   @Override
   public DiffPreferencesInfo apply(AccountResource rsrc, DiffPreferencesInfo in)
       throws AuthException, BadRequestException, ConfigInvalidException,
-          RepositoryNotFoundException, IOException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+          RepositoryNotFoundException, IOException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     if (in == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
index e2a2912..0142d15 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetEditPreferences.java
@@ -28,6 +28,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -40,6 +43,7 @@
 
   private final Provider<CurrentUser> self;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager gitMgr;
   private final AllUsersName allUsersName;
 
@@ -47,10 +51,12 @@
   SetEditPreferences(
       Provider<CurrentUser> self,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      PermissionBackend permissionBackend,
       GitRepositoryManager gitMgr,
       AllUsersName allUsersName) {
     this.self = self;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.permissionBackend = permissionBackend;
     this.gitMgr = gitMgr;
     this.allUsersName = allUsersName;
   }
@@ -58,10 +64,9 @@
   @Override
   public EditPreferencesInfo apply(AccountResource rsrc, EditPreferencesInfo in)
       throws AuthException, BadRequestException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+          ConfigInvalidException, PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     if (in == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetInactiveFlag.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetInactiveFlag.java
new file mode 100644
index 0000000..6e12c3e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -0,0 +1,82 @@
+// 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.account;
+
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicBoolean;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class SetInactiveFlag {
+
+  private final AccountsUpdate.Server accountsUpdate;
+
+  @Inject
+  SetInactiveFlag(AccountsUpdate.Server accountsUpdate) {
+    this.accountsUpdate = accountsUpdate;
+  }
+
+  public Response<?> deactivate(Account.Id accountId)
+      throws RestApiException, IOException, ConfigInvalidException {
+    AtomicBoolean alreadyInactive = new AtomicBoolean(false);
+    Account account =
+        accountsUpdate
+            .create()
+            .update(
+                accountId,
+                a -> {
+                  if (!a.isActive()) {
+                    alreadyInactive.set(true);
+                  } else {
+                    a.setActive(false);
+                  }
+                });
+    if (account == null) {
+      throw new ResourceNotFoundException("account not found");
+    }
+    if (alreadyInactive.get()) {
+      throw new ResourceConflictException("account not active");
+    }
+    return Response.none();
+  }
+
+  public Response<String> activate(Account.Id accountId)
+      throws ResourceNotFoundException, IOException, ConfigInvalidException {
+    AtomicBoolean alreadyActive = new AtomicBoolean(false);
+    Account account =
+        accountsUpdate
+            .create()
+            .update(
+                accountId,
+                a -> {
+                  if (a.isActive()) {
+                    alreadyActive.set(true);
+                  } else {
+                    a.setActive(true);
+                  }
+                });
+    if (account == null) {
+      throw new ResourceNotFoundException("account not found");
+    }
+    return alreadyActive.get() ? Response.ok("") : Response.created("");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
index d2164f6..9657928 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SetPreferences.java
@@ -24,6 +24,7 @@
 import static com.google.gerrit.server.git.UserConfigSections.URL_ALIAS;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.MenuItem;
 import com.google.gerrit.extensions.config.DownloadScheme;
@@ -36,6 +37,9 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.UserConfigSections;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -51,6 +55,7 @@
 public class SetPreferences implements RestModifyView<AccountResource, GeneralPreferencesInfo> {
   private final Provider<CurrentUser> self;
   private final AccountCache cache;
+  private final PermissionBackend permissionBackend;
   private final GeneralPreferencesLoader loader;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
   private final AllUsersName allUsersName;
@@ -60,6 +65,7 @@
   SetPreferences(
       Provider<CurrentUser> self,
       AccountCache cache,
+      PermissionBackend permissionBackend,
       GeneralPreferencesLoader loader,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
       AllUsersName allUsersName,
@@ -67,6 +73,7 @@
     this.self = self;
     this.loader = loader;
     this.cache = cache;
+    this.permissionBackend = permissionBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.allUsersName = allUsersName;
     this.downloadSchemes = downloadSchemes;
@@ -74,10 +81,10 @@
 
   @Override
   public GeneralPreferencesInfo apply(AccountResource rsrc, GeneralPreferencesInfo i)
-      throws AuthException, BadRequestException, IOException, ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new AuthException("requires Modify Account capability");
+      throws AuthException, BadRequestException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
     }
 
     checkDownloadScheme(i.downloadScheme);
@@ -94,7 +101,7 @@
   }
 
   private void writeToGit(Account.Id id, GeneralPreferencesInfo i)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException, BadRequestException {
     VersionedAccountPreferences prefs;
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       prefs = VersionedAccountPreferences.forUser(id);
@@ -115,11 +122,15 @@
     }
   }
 
-  public static void storeMyMenus(VersionedAccountPreferences prefs, List<MenuItem> my) {
+  public static void storeMyMenus(VersionedAccountPreferences prefs, List<MenuItem> my)
+      throws BadRequestException {
     Config cfg = prefs.getConfig();
     if (my != null) {
       unsetSection(cfg, UserConfigSections.MY);
       for (MenuItem item : my) {
+        checkRequiredMenuItemField(item.name, "name");
+        checkRequiredMenuItemField(item.url, "URL");
+
         set(cfg, item.name, KEY_URL, item.url);
         set(cfg, item.name, KEY_TARGET, item.target);
         set(cfg, item.name, KEY_ID, item.id);
@@ -136,11 +147,11 @@
     }
   }
 
-  private static void set(Config cfg, String section, String key, String val) {
-    if (Strings.isNullOrEmpty(val)) {
-      cfg.unset(UserConfigSections.MY, section, key);
+  private static void set(Config cfg, String section, String key, @Nullable String val) {
+    if (val == null || val.trim().isEmpty()) {
+      cfg.unset(UserConfigSections.MY, section.trim(), key);
     } else {
-      cfg.setString(UserConfigSections.MY, section, key, val);
+      cfg.setString(UserConfigSections.MY, section.trim(), key, val.trim());
     }
   }
 
@@ -168,6 +179,13 @@
     }
   }
 
+  private static void checkRequiredMenuItemField(String value, String name)
+      throws BadRequestException {
+    if (value == null || value.trim().isEmpty()) {
+      throw new BadRequestException(name + " for menu item is required");
+    }
+  }
+
   private void checkDownloadScheme(String downloadScheme) throws BadRequestException {
     if (Strings.isNullOrEmpty(downloadScheme)) {
       return;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
index 4f00e1a..2c8f273 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/SshKeys.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.account;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -22,6 +23,9 @@
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+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;
@@ -34,6 +38,7 @@
   private final DynamicMap<RestView<AccountResource.SshKey>> views;
   private final GetSshKeys list;
   private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
   private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
   @Inject
@@ -41,10 +46,12 @@
       DynamicMap<RestView<AccountResource.SshKey>> views,
       GetSshKeys list,
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       VersionedAuthorizedKeys.Accessor authorizedKeys) {
     this.views = views;
     this.list = list;
     this.self = self;
+    this.permissionBackend = permissionBackend;
     this.authorizedKeys = authorizedKeys;
   }
 
@@ -55,10 +62,15 @@
 
   @Override
   public AccountResource.SshKey parse(AccountResource rsrc, IdString id)
-      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException {
-    if (!self.get().hasSameAccountId(rsrc.getUser())
-        && !self.get().getCapabilities().canModifyAccount()) {
-      throw new ResourceNotFoundException();
+      throws ResourceNotFoundException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    if (!self.get().hasSameAccountId(rsrc.getUser())) {
+      try {
+        permissionBackend.user(self).check(GlobalPermission.MODIFY_ACCOUNT);
+      } catch (AuthException e) {
+        // If lacking MODIFY_ACCOUNT claim the resource does not exist.
+        throw new ResourceNotFoundException();
+      }
     }
     return parse(rsrc.getUser(), id);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
index 868d378..3976d47 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/StarredChanges.java
@@ -20,8 +20,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -30,8 +32,11 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.QueryChanges;
 import com.google.gwtorm.server.OrmDuplicateKeyException;
 import com.google.gwtorm.server.OrmException;
@@ -67,7 +72,7 @@
 
   @Override
   public AccountResource.StarredChange parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException, OrmException, PermissionBackendException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     if (starredChangesUtil
@@ -96,7 +101,6 @@
     };
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public RestModifyView<AccountResource, EmptyInput> create(AccountResource parent, IdString id)
       throws UnprocessableEntityException {
@@ -104,7 +108,7 @@
       return createProvider.get().setChange(changes.parse(TopLevelResource.INSTANCE, id));
     } catch (ResourceNotFoundException e) {
       throw new UnprocessableEntityException(String.format("change %s not found", id.get()));
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("cannot resolve change", e);
       throw new UnprocessableEntityException("internal server error");
     }
@@ -129,7 +133,7 @@
 
     @Override
     public Response<?> apply(AccountResource rsrc, EmptyInput in)
-        throws AuthException, OrmException, IOException {
+        throws RestApiException, OrmException, IOException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed to add starred change");
       }
@@ -140,6 +144,10 @@
             change.getId(),
             StarredChangesUtil.DEFAULT_LABELS,
             null);
+      } catch (MutuallyExclusiveLabelsException e) {
+        throw new ResourceConflictException(e.getMessage());
+      } catch (IllegalLabelException e) {
+        throw new BadRequestException(e.getMessage());
       } catch (OrmDuplicateKeyException e) {
         return Response.none();
       }
@@ -179,7 +187,7 @@
 
     @Override
     public Response<?> apply(AccountResource.StarredChange rsrc, EmptyInput in)
-        throws AuthException, OrmException, IOException {
+        throws AuthException, OrmException, IOException, IllegalLabelException {
       if (!self.get().hasSameAccountId(rsrc.getUser())) {
         throw new AuthException("not allowed remove starred change");
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java
index cf43a21..2aedfe1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/Stars.java
@@ -33,6 +33,7 @@
 import com.google.gerrit.server.account.AccountResource.Star;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.QueryChanges;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -65,7 +66,7 @@
 
   @Override
   public Star parse(AccountResource parent, IdString id)
-      throws ResourceNotFoundException, OrmException {
+      throws ResourceNotFoundException, OrmException, PermissionBackendException {
     IdentifiedUser user = parent.getUser();
     ChangeResource change = changes.parse(TopLevelResource.INSTANCE, id);
     Set<String> labels = starredChangesUtil.getLabels(user.getAccountId(), change.getId());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java
index a3223d9..fc9b58a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/UniversalGroupBackend.java
@@ -31,7 +31,7 @@
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Collection;
@@ -87,7 +87,7 @@
   }
 
   @Override
-  public Collection<GroupReference> suggest(String name, ProjectControl project) {
+  public Collection<GroupReference> suggest(String name, ProjectState project) {
     Set<GroupReference> groups = Sets.newTreeSet(GROUP_REF_NAME_COMPARATOR);
     for (GroupBackend g : backends) {
       groups.addAll(g.suggest(name, project));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
index 2eb0b54..612da6e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAccountPreferences.java
@@ -38,7 +38,7 @@
   private final String ref;
   private Config cfg;
 
-  private VersionedAccountPreferences(String ref) {
+  protected VersionedAccountPreferences(String ref) {
     this.ref = ref;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
new file mode 100644
index 0000000..1033641
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/DisabledExternalIdCache.java
@@ -0,0 +1,80 @@
+// 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.account.externalids;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.AbstractModule;
+import com.google.inject.Module;
+import java.io.IOException;
+import java.util.Collection;
+import org.eclipse.jgit.lib.ObjectId;
+
+public class DisabledExternalIdCache implements ExternalIdCache {
+  public static Module module() {
+    return new AbstractModule() {
+
+      @Override
+      protected void configure() {
+        bind(ExternalIdCache.class).to(DisabledExternalIdCache.class);
+      }
+    };
+  }
+
+  @Override
+  public void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd) {}
+
+  @Override
+  public void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId) {}
+
+  @Override
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+    throw new UnsupportedOperationException();
+  }
+
+  @Override
+  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
+    throw new UnsupportedOperationException();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
new file mode 100644
index 0000000..85401c5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalId.java
@@ -0,0 +1,413 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.client.AuthType;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.HashedPassword;
+import java.io.Serializable;
+import java.util.Objects;
+import java.util.Set;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+
+@AutoValue
+public abstract class ExternalId implements Serializable {
+  // If these regular expressions are modified the same modifications should be done to the
+  // corresponding regular expressions in the
+  // com.google.gerrit.client.account.UsernameField class.
+  private static final String USER_NAME_PATTERN_FIRST_REGEX = "[a-zA-Z0-9]";
+  private static final String USER_NAME_PATTERN_REST_REGEX = "[a-zA-Z0-9.!#$%&’*+=?^_`\\{|\\}~@-]";
+  private static final String USER_NAME_PATTERN_LAST_REGEX = "[a-zA-Z0-9]";
+
+  /** Regular expression that a username must match. */
+  private static final String USER_NAME_PATTERN_REGEX =
+      "^("
+          + //
+          USER_NAME_PATTERN_FIRST_REGEX
+          + //
+          USER_NAME_PATTERN_REST_REGEX
+          + "*"
+          + //
+          USER_NAME_PATTERN_LAST_REGEX
+          + //
+          "|"
+          + //
+          USER_NAME_PATTERN_FIRST_REGEX
+          + //
+          ")$";
+
+  private static final Pattern USER_NAME_PATTERN = Pattern.compile(USER_NAME_PATTERN_REGEX);
+
+  public static boolean isValidUsername(String username) {
+    return USER_NAME_PATTERN.matcher(username).matches();
+  }
+
+  private static final long serialVersionUID = 1L;
+
+  private static final String EXTERNAL_ID_SECTION = "externalId";
+  private static final String ACCOUNT_ID_KEY = "accountId";
+  private static final String EMAIL_KEY = "email";
+  private static final String PASSWORD_KEY = "password";
+
+  /**
+   * Scheme used for {@link AuthType#LDAP}, {@link AuthType#CLIENT_SSL_CERT_LDAP}, {@link
+   * AuthType#HTTP_LDAP}, and {@link AuthType#LDAP_BIND} usernames.
+   *
+   * <p>The name {@code gerrit:} was a very poor choice.
+   */
+  public static final String SCHEME_GERRIT = "gerrit";
+
+  /** Scheme used for randomly created identities constructed by a UUID. */
+  public static final String SCHEME_UUID = "uuid";
+
+  /** Scheme used to represent only an email address. */
+  public static final String SCHEME_MAILTO = "mailto";
+
+  /** Scheme for the username used to authenticate an account, e.g. over SSH. */
+  public static final String SCHEME_USERNAME = "username";
+
+  /** Scheme used for GPG public keys. */
+  public static final String SCHEME_GPGKEY = "gpgkey";
+
+  /** Scheme for external auth used during authentication, e.g. OAuth Token */
+  public static final String SCHEME_EXTERNAL = "external";
+
+  @AutoValue
+  public abstract static class Key implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    public static Key create(@Nullable String scheme, String id) {
+      return new AutoValue_ExternalId_Key(Strings.emptyToNull(scheme), id);
+    }
+
+    /**
+     * Parses an external ID key from a string in the format "scheme:id" or "id".
+     *
+     * @return the parsed external ID key
+     */
+    public static Key parse(String externalId) {
+      int c = externalId.indexOf(':');
+      if (c < 1 || c >= externalId.length() - 1) {
+        return create(null, externalId);
+      }
+      return create(externalId.substring(0, c), externalId.substring(c + 1));
+    }
+
+    public abstract @Nullable String scheme();
+
+    public abstract String id();
+
+    public boolean isScheme(String scheme) {
+      return scheme.equals(scheme());
+    }
+
+    /**
+     * Returns the SHA1 of the external ID that is used as note ID in the refs/meta/external-ids
+     * notes branch.
+     */
+    @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
+    public ObjectId sha1() {
+      return ObjectId.fromRaw(Hashing.sha1().hashString(get(), UTF_8).asBytes());
+    }
+
+    /**
+     * Exports this external ID key as string with the format "scheme:id", or "id" if scheme is
+     * null.
+     *
+     * <p>This string representation is used as subsection name in the Git config file that stores
+     * the external ID.
+     */
+    public String get() {
+      if (scheme() != null) {
+        return scheme() + ":" + id();
+      }
+      return id();
+    }
+
+    @Override
+    public String toString() {
+      return get();
+    }
+  }
+
+  public static ExternalId create(String scheme, String id, Account.Id accountId) {
+    return create(Key.create(scheme, id), accountId, null, null);
+  }
+
+  public static ExternalId create(
+      String scheme,
+      String id,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword) {
+    return create(Key.create(scheme, id), accountId, email, hashedPassword);
+  }
+
+  public static ExternalId create(Key key, Account.Id accountId) {
+    return create(key, accountId, null, null);
+  }
+
+  public static ExternalId create(
+      Key key, Account.Id accountId, @Nullable String email, @Nullable String hashedPassword) {
+    return create(
+        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), null);
+  }
+
+  public static ExternalId createWithPassword(
+      Key key, Account.Id accountId, @Nullable String email, String plainPassword) {
+    plainPassword = Strings.emptyToNull(plainPassword);
+    String hashedPassword =
+        plainPassword != null ? HashedPassword.fromPassword(plainPassword).encode() : null;
+    return create(key, accountId, email, hashedPassword);
+  }
+
+  public static ExternalId createUsername(String id, Account.Id accountId, String plainPassword) {
+    return createWithPassword(Key.create(SCHEME_USERNAME, id), accountId, null, plainPassword);
+  }
+
+  public static ExternalId createWithEmail(
+      String scheme, String id, Account.Id accountId, @Nullable String email) {
+    return createWithEmail(Key.create(scheme, id), accountId, email);
+  }
+
+  public static ExternalId createWithEmail(Key key, Account.Id accountId, @Nullable String email) {
+    return create(key, accountId, Strings.emptyToNull(email), null);
+  }
+
+  public static ExternalId createEmail(Account.Id accountId, String email) {
+    return createWithEmail(SCHEME_MAILTO, email, accountId, checkNotNull(email));
+  }
+
+  static ExternalId create(ExternalId extId, @Nullable ObjectId blobId) {
+    return new AutoValue_ExternalId(
+        extId.key(), extId.accountId(), extId.email(), extId.password(), blobId);
+  }
+
+  @VisibleForTesting
+  public static ExternalId create(
+      Key key,
+      Account.Id accountId,
+      @Nullable String email,
+      @Nullable String hashedPassword,
+      @Nullable ObjectId blobId) {
+    return new AutoValue_ExternalId(
+        key, accountId, Strings.emptyToNull(email), Strings.emptyToNull(hashedPassword), blobId);
+  }
+
+  /**
+   * Parses an external ID from a byte array that contain the external ID as an Git config file
+   * text.
+   *
+   * <p>The Git config must have exactly one externalId subsection with an accountId and optionally
+   * email and password:
+   *
+   * <pre>
+   * [externalId "username:jdoe"]
+   *   accountId = 1003407
+   *   email = jdoe@example.com
+   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+   * </pre>
+   */
+  public static ExternalId parse(String noteId, byte[] raw, ObjectId blobId)
+      throws ConfigInvalidException {
+    checkNotNull(blobId);
+
+    Config externalIdConfig = new Config();
+    try {
+      externalIdConfig.fromText(new String(raw, UTF_8));
+    } catch (ConfigInvalidException e) {
+      throw invalidConfig(noteId, e.getMessage());
+    }
+
+    Set<String> externalIdKeys = externalIdConfig.getSubsections(EXTERNAL_ID_SECTION);
+    if (externalIdKeys.size() != 1) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Expected exactly 1 '%s' section, found %d",
+              EXTERNAL_ID_SECTION, externalIdKeys.size()));
+    }
+
+    String externalIdKeyStr = Iterables.getOnlyElement(externalIdKeys);
+    Key externalIdKey = Key.parse(externalIdKeyStr);
+    if (externalIdKey == null) {
+      throw invalidConfig(noteId, String.format("External ID %s is invalid", externalIdKeyStr));
+    }
+
+    if (!externalIdKey.sha1().getName().equals(noteId)) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "SHA1 of external ID '%s' does not match note ID '%s'", externalIdKeyStr, noteId));
+    }
+
+    String email = externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, EMAIL_KEY);
+    String password =
+        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, PASSWORD_KEY);
+    int accountId = readAccountId(noteId, externalIdConfig, externalIdKeyStr);
+
+    return create(
+        externalIdKey,
+        new Account.Id(accountId),
+        Strings.emptyToNull(email),
+        Strings.emptyToNull(password),
+        blobId);
+  }
+
+  private static int readAccountId(String noteId, Config externalIdConfig, String externalIdKeyStr)
+      throws ConfigInvalidException {
+    String accountIdStr =
+        externalIdConfig.getString(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY);
+    if (accountIdStr == null) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value for '%s.%s.%s' is missing, expected account ID",
+              EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+
+    try {
+      int accountId =
+          externalIdConfig.getInt(EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY, -1);
+      if (accountId < 0) {
+        throw invalidConfig(
+            noteId,
+            String.format(
+                "Value %s for '%s.%s.%s' is invalid, expected account ID",
+                accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+      }
+      return accountId;
+    } catch (IllegalArgumentException e) {
+      throw invalidConfig(
+          noteId,
+          String.format(
+              "Value %s for '%s.%s.%s' is invalid, expected account ID",
+              accountIdStr, EXTERNAL_ID_SECTION, externalIdKeyStr, ACCOUNT_ID_KEY));
+    }
+  }
+
+  private static ConfigInvalidException invalidConfig(String noteId, String message) {
+    return new ConfigInvalidException(
+        String.format("Invalid external ID config for note '%s': %s", noteId, message));
+  }
+
+  public abstract Key key();
+
+  public abstract Account.Id accountId();
+
+  public abstract @Nullable String email();
+
+  public abstract @Nullable String password();
+
+  /**
+   * ID of the note blob in the external IDs branch that stores this external ID. {@code null} if
+   * the external ID was created in code and is not yet stored in Git.
+   */
+  public abstract @Nullable ObjectId blobId();
+
+  public void checkThatBlobIdIsSet() {
+    checkState(blobId() != null, "No blob ID set for external ID %s", key().get());
+  }
+
+  public boolean isScheme(String scheme) {
+    return key().isScheme(scheme);
+  }
+
+  public byte[] toByteArray() {
+    checkState(blobId() != null, "Missing blobId in external ID %s", key().get());
+    byte[] b = new byte[2 * Constants.OBJECT_ID_STRING_LENGTH + 1];
+    key().sha1().copyTo(b, 0);
+    b[Constants.OBJECT_ID_STRING_LENGTH] = ':';
+    blobId().copyTo(b, Constants.OBJECT_ID_STRING_LENGTH + 1);
+    return b;
+  }
+
+  /**
+   * For checking if two external IDs are equals the blobId is excluded and external IDs that have
+   * different blob IDs but identical other fields are considered equal. This way an external ID
+   * that was loaded from Git can be equal with an external ID that was created from code.
+   */
+  @Override
+  public boolean equals(Object obj) {
+    if (!(obj instanceof ExternalId)) {
+      return false;
+    }
+    ExternalId o = (ExternalId) obj;
+    return Objects.equals(key(), o.key())
+        && Objects.equals(accountId(), o.accountId())
+        && Objects.equals(email(), o.email())
+        && Objects.equals(password(), o.password());
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(key(), accountId(), email(), password());
+  }
+
+  /**
+   * Exports this external ID as Git config file text.
+   *
+   * <p>The Git config has exactly one externalId subsection with an accountId and optionally email
+   * and password:
+   *
+   * <pre>
+   * [externalId "username:jdoe"]
+   *   accountId = 1003407
+   *   email = jdoe@example.com
+   *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+   * </pre>
+   */
+  @Override
+  public String toString() {
+    Config c = new Config();
+    writeToConfig(c);
+    return c.toText();
+  }
+
+  public void writeToConfig(Config c) {
+    String externalIdKey = key().get();
+    // Do not use c.setInt(...) to write the account ID because c.setInt(...) persists integers
+    // that can be expressed in KiB as a unit strings, e.g. "1024000" is stored as "100k". Using
+    // c.setString(...) ensures that account IDs are human readable.
+    c.setString(
+        EXTERNAL_ID_SECTION, externalIdKey, ACCOUNT_ID_KEY, Integer.toString(accountId().get()));
+
+    if (email() != null) {
+      c.setString(EXTERNAL_ID_SECTION, externalIdKey, EMAIL_KEY, email());
+    } else {
+      c.unset(EXTERNAL_ID_SECTION, externalIdKey, EMAIL_KEY);
+    }
+
+    if (password() != null) {
+      c.setString(EXTERNAL_ID_SECTION, externalIdKey, PASSWORD_KEY, password());
+    } else {
+      c.unset(EXTERNAL_ID_SECTION, externalIdKey, PASSWORD_KEY);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
new file mode 100644
index 0000000..d928e15
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCache.java
@@ -0,0 +1,82 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.gerrit.reviewdb.client.Account;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Caches external IDs of all accounts.
+ *
+ * <p>On each cache access the SHA1 of the refs/meta/external-ids branch is read to verify that the
+ * cache is up to date.
+ */
+interface ExternalIdCache {
+  void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException;
+
+  void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extId)
+      throws IOException;
+
+  ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException;
+
+  ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException;
+
+  ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException;
+
+  ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException;
+
+  default ImmutableSet<ExternalId> byEmail(String email) throws IOException {
+    return byEmails(email).get(email);
+  }
+
+  default void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId extId)
+      throws IOException {
+    onCreate(oldNotesRev, newNotesRev, Collections.singleton(extId));
+  }
+
+  default void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId extId)
+      throws IOException {
+    onRemove(oldNotesRev, newNotesRev, Collections.singleton(extId));
+  }
+
+  default void onUpdate(ObjectId oldNotesRev, ObjectId newNotesRev, ExternalId updatedExtId)
+      throws IOException {
+    onUpdate(oldNotesRev, newNotesRev, Collections.singleton(updatedExtId));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
new file mode 100644
index 0000000..311e70f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdCacheImpl.java
@@ -0,0 +1,264 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.common.collect.ImmutableSetMultimap.toImmutableSetMultimap;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Strings;
+import com.google.common.cache.CacheBuilder;
+import com.google.common.cache.CacheLoader;
+import com.google.common.cache.LoadingCache;
+import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Caches external IDs of all accounts. The external IDs are always loaded from NoteDb. */
+@Singleton
+class ExternalIdCacheImpl implements ExternalIdCache {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdCacheImpl.class);
+
+  private final LoadingCache<ObjectId, AllExternalIds> extIdsByAccount;
+  private final ExternalIdReader externalIdReader;
+  private final Lock lock;
+
+  @Inject
+  ExternalIdCacheImpl(ExternalIdReader externalIdReader) {
+    this.extIdsByAccount =
+        CacheBuilder.newBuilder()
+            // The cached data is potentially pretty large and we are always only interested
+            // in the latest value, hence the maximum cache size is set to 1.
+            // This can lead to extra cache loads in case of the following race:
+            // 1. thread 1 reads the notes ref at revision A
+            // 2. thread 2 updates the notes ref to revision B and stores the derived value
+            //    for B in the cache
+            // 3. thread 1 attempts to read the data for revision A from the cache, and misses
+            // 4. later threads attempt to read at B
+            // In this race unneeded reloads are done in step 3 (reload from revision A) and
+            // step 4 (reload from revision B, because the value for revision B was lost when the
+            // reload from revision A was done, since the cache can hold only one entry).
+            // These reloads could be avoided by increasing the cache size to 2. However the race
+            // window between reading the ref and looking it up in the cache is small so that
+            // it's rare that this race happens. Therefore it's not worth to double the memory
+            // usage of this cache, just to avoid this.
+            .maximumSize(1)
+            .build(new Loader(externalIdReader));
+    this.externalIdReader = externalIdReader;
+    this.lock = new ReentrantLock(true /* fair */);
+  }
+
+  @Override
+  public void onCreate(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : extIds) {
+            extId.checkThatBlobIdIsSet();
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onRemove(ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> extIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : extIds) {
+            m.remove(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onUpdate(
+      ObjectId oldNotesRev, ObjectId newNotesRev, Collection<ExternalId> updatedExtIds)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          removeKeys(m.values(), updatedExtIds.stream().map(e -> e.key()).collect(toSet()));
+          for (ExternalId updatedExtId : updatedExtIds) {
+            updatedExtId.checkThatBlobIdIsSet();
+            m.put(updatedExtId.accountId(), updatedExtId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Account.Id accountId,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    ExternalIdsUpdate.checkSameAccount(Iterables.concat(toRemove, toAdd), accountId);
+
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : toRemove) {
+            m.remove(extId.accountId(), extId);
+          }
+          for (ExternalId extId : toAdd) {
+            extId.checkThatBlobIdIsSet();
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public void onReplace(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Collection<ExternalId> toRemove,
+      Collection<ExternalId> toAdd)
+      throws IOException {
+    updateCache(
+        oldNotesRev,
+        newNotesRev,
+        m -> {
+          for (ExternalId extId : toRemove) {
+            m.remove(extId.accountId(), extId);
+          }
+          for (ExternalId extId : toAdd) {
+            extId.checkThatBlobIdIsSet();
+            m.put(extId.accountId(), extId);
+          }
+        });
+  }
+
+  @Override
+  public ImmutableSet<ExternalId> byAccount(Account.Id accountId) throws IOException {
+    return get().byAccount().get(accountId);
+  }
+
+  @Override
+  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+    return get().byAccount();
+  }
+
+  @Override
+  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+    AllExternalIds allExternalIds = get();
+    ImmutableSetMultimap.Builder<String, ExternalId> byEmails = ImmutableSetMultimap.builder();
+    for (String email : emails) {
+      byEmails.putAll(email, allExternalIds.byEmail().get(email));
+    }
+    return byEmails.build();
+  }
+
+  @Override
+  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
+    return get().byEmail();
+  }
+
+  private AllExternalIds get() throws IOException {
+    try {
+      return extIdsByAccount.get(externalIdReader.readRevision());
+    } catch (ExecutionException e) {
+      throw new IOException("Cannot load external ids", e);
+    }
+  }
+
+  private void updateCache(
+      ObjectId oldNotesRev,
+      ObjectId newNotesRev,
+      Consumer<Multimap<Account.Id, ExternalId>> update) {
+    lock.lock();
+    try {
+      ListMultimap<Account.Id, ExternalId> m;
+      if (!ObjectId.zeroId().equals(oldNotesRev)) {
+        m =
+            MultimapBuilder.hashKeys()
+                .arrayListValues()
+                .build(extIdsByAccount.get(oldNotesRev).byAccount());
+      } else {
+        m = MultimapBuilder.hashKeys().arrayListValues().build();
+      }
+      update.accept(m);
+      extIdsByAccount.put(newNotesRev, AllExternalIds.create(m));
+    } catch (ExecutionException e) {
+      log.warn("Cannot update external IDs", e);
+    } finally {
+      lock.unlock();
+    }
+  }
+
+  private static void removeKeys(Collection<ExternalId> ids, Collection<ExternalId.Key> toRemove) {
+    Collections2.transform(ids, e -> e.key()).removeAll(toRemove);
+  }
+
+  private static class Loader extends CacheLoader<ObjectId, AllExternalIds> {
+    private final ExternalIdReader externalIdReader;
+
+    Loader(ExternalIdReader externalIdReader) {
+      this.externalIdReader = externalIdReader;
+    }
+
+    @Override
+    public AllExternalIds load(ObjectId notesRev) throws Exception {
+      Multimap<Account.Id, ExternalId> extIdsByAccount =
+          MultimapBuilder.hashKeys().arrayListValues().build();
+      for (ExternalId extId : externalIdReader.all(notesRev)) {
+        extId.checkThatBlobIdIsSet();
+        extIdsByAccount.put(extId.accountId(), extId);
+      }
+      return AllExternalIds.create(extIdsByAccount);
+    }
+  }
+
+  @AutoValue
+  abstract static class AllExternalIds {
+    static AllExternalIds create(Multimap<Account.Id, ExternalId> byAccount) {
+      ImmutableSetMultimap<String, ExternalId> byEmail =
+          byAccount
+              .values()
+              .stream()
+              .filter(e -> !Strings.isNullOrEmpty(e.email()))
+              .collect(toImmutableSetMultimap(ExternalId::email, e -> e));
+      return new AutoValue_ExternalIdCacheImpl_AllExternalIds(
+          ImmutableSetMultimap.copyOf(byAccount), byEmail);
+    }
+
+    public abstract ImmutableSetMultimap<Account.Id, ExternalId> byAccount();
+
+    public abstract ImmutableSetMultimap<String, ExternalId> byEmail();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.java
new file mode 100644
index 0000000..8c97144
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdModule.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.server.account.externalids;
+
+import com.google.inject.AbstractModule;
+
+public class ExternalIdModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    bind(ExternalIdCacheImpl.class);
+    bind(ExternalIdCache.class).to(ExternalIdCacheImpl.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
new file mode 100644
index 0000000..7871607
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdReader.java
@@ -0,0 +1,199 @@
+// 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.account.externalids;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.metrics.Timer0;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Class to read external IDs from NoteDb.
+ *
+ * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
+ * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
+ * is a git config file that contains an external ID. It has exactly one externalId subsection with
+ * an accountId and optionally email and password:
+ *
+ * <pre>
+ * [externalId "username:jdoe"]
+ *   accountId = 1003407
+ *   email = jdoe@example.com
+ *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+ * </pre>
+ */
+@Singleton
+public class ExternalIdReader {
+  private static final Logger log = LoggerFactory.getLogger(ExternalIdReader.class);
+
+  public static final int MAX_NOTE_SZ = 1 << 19;
+
+  public static ObjectId readRevision(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(RefNames.REFS_EXTERNAL_IDS);
+    return ref != null ? ref.getObjectId() : ObjectId.zeroId();
+  }
+
+  public static NoteMap readNoteMap(RevWalk rw, ObjectId rev) throws IOException {
+    if (!rev.equals(ObjectId.zeroId())) {
+      return NoteMap.read(rw.getObjectReader(), rw.parseCommit(rev));
+    }
+    return NoteMap.newEmptyMap();
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private boolean failOnLoad = false;
+  private final Timer0 readAllLatency;
+
+  @Inject
+  ExternalIdReader(
+      GitRepositoryManager repoManager, AllUsersName allUsersName, MetricMaker metricMaker) {
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.readAllLatency =
+        metricMaker.newTimer(
+            "notedb/read_all_external_ids_latency",
+            new Description("Latency for reading all external IDs from NoteDb.")
+                .setCumulative()
+                .setUnit(Units.MILLISECONDS));
+  }
+
+  @VisibleForTesting
+  public void setFailOnLoad(boolean failOnLoad) {
+    this.failOnLoad = failOnLoad;
+  }
+
+  ObjectId readRevision() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return readRevision(repo);
+    }
+  }
+
+  /** Reads and returns all external IDs. */
+  Set<ExternalId> all() throws IOException {
+    checkReadEnabled();
+
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return all(repo, readRevision(repo));
+    }
+  }
+
+  /**
+   * Reads and returns all external IDs from the specified revision of the refs/meta/external-ids
+   * branch.
+   */
+  Set<ExternalId> all(ObjectId rev) throws IOException {
+    checkReadEnabled();
+
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return all(repo, rev);
+    }
+  }
+
+  /** Reads and returns all external IDs. */
+  private Set<ExternalId> all(Repository repo, ObjectId rev) throws IOException {
+    if (rev.equals(ObjectId.zeroId())) {
+      return ImmutableSet.of();
+    }
+
+    try (Timer0.Context ctx = readAllLatency.start();
+        RevWalk rw = new RevWalk(repo)) {
+      NoteMap noteMap = readNoteMap(rw, rev);
+      Set<ExternalId> extIds = new HashSet<>();
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader().open(note.getData(), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+        try {
+          extIds.add(ExternalId.parse(note.getName(), raw, note.getData()));
+        } catch (Exception e) {
+          log.error("Ignoring invalid external ID note {}", note.getName(), e);
+        }
+      }
+      return extIds;
+    }
+  }
+
+  /** Reads and returns the specified external ID. */
+  @Nullable
+  ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+    checkReadEnabled();
+
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      ObjectId rev = readRevision(repo);
+      if (rev.equals(ObjectId.zeroId())) {
+        return null;
+      }
+
+      return parse(key, rw, rev);
+    }
+  }
+
+  /** Reads and returns the specified external ID from the given revision. */
+  @Nullable
+  ExternalId get(ExternalId.Key key, ObjectId rev) throws IOException, ConfigInvalidException {
+    checkReadEnabled();
+
+    if (rev.equals(ObjectId.zeroId())) {
+      return null;
+    }
+
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo)) {
+      return parse(key, rw, rev);
+    }
+  }
+
+  private static ExternalId parse(ExternalId.Key key, RevWalk rw, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    NoteMap noteMap = readNoteMap(rw, rev);
+    ObjectId noteId = key.sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    ObjectId noteData = noteMap.get(noteId);
+    byte[] raw = rw.getObjectReader().open(noteData, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    return ExternalId.parse(noteId.name(), raw, noteData);
+  }
+
+  private void checkReadEnabled() throws IOException {
+    if (failOnLoad) {
+      throw new IOException("Reading from external IDs is disabled");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java
new file mode 100644
index 0000000..35eb6d4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIds.java
@@ -0,0 +1,122 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+
+/**
+ * Class to access external IDs.
+ *
+ * <p>The external IDs are either read from NoteDb or retrieved from the cache.
+ */
+@Singleton
+public class ExternalIds {
+  private final ExternalIdReader externalIdReader;
+  private final ExternalIdCache externalIdCache;
+
+  @Inject
+  public ExternalIds(ExternalIdReader externalIdReader, ExternalIdCache externalIdCache) {
+    this.externalIdReader = externalIdReader;
+    this.externalIdCache = externalIdCache;
+  }
+
+  /** Returns all external IDs. */
+  public Set<ExternalId> all() throws IOException {
+    return externalIdReader.all();
+  }
+
+  /** Returns all external IDs from the specified revision of the refs/meta/external-ids branch. */
+  public Set<ExternalId> all(ObjectId rev) throws IOException {
+    return externalIdReader.all(rev);
+  }
+
+  /** Returns the specified external ID. */
+  @Nullable
+  public ExternalId get(ExternalId.Key key) throws IOException, ConfigInvalidException {
+    return externalIdReader.get(key);
+  }
+
+  /** Returns the specified external ID from the given revision. */
+  @Nullable
+  public ExternalId get(ExternalId.Key key, ObjectId rev)
+      throws IOException, ConfigInvalidException {
+    return externalIdReader.get(key, rev);
+  }
+
+  /** Returns the external IDs of the specified account. */
+  public Set<ExternalId> byAccount(Account.Id accountId) throws IOException {
+    return externalIdCache.byAccount(accountId);
+  }
+
+  /** Returns the external IDs of the specified account that have the given scheme. */
+  public Set<ExternalId> byAccount(Account.Id accountId, String scheme) throws IOException {
+    return byAccount(accountId).stream().filter(e -> e.key().isScheme(scheme)).collect(toSet());
+  }
+
+  /** Returns all external IDs by account. */
+  public ImmutableSetMultimap<Account.Id, ExternalId> allByAccount() throws IOException {
+    return externalIdCache.allByAccount();
+  }
+
+  /**
+   * Returns the external ID with the given email.
+   *
+   * <p>Each email should belong to a single external ID only. This means if more than one external
+   * ID is returned there is an inconsistency in the external IDs.
+   *
+   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
+   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
+   * multiple emails are needed it is more efficient to use {@link #byEmails(String...)} as this
+   * method reads the SHA1 of the refs/meta/external-ids branch only once (and not once per email).
+   *
+   * @see #byEmails(String...)
+   */
+  public Set<ExternalId> byEmail(String email) throws IOException {
+    return externalIdCache.byEmail(email);
+  }
+
+  /**
+   * Returns the external IDs for the given emails.
+   *
+   * <p>Each email should belong to a single external ID only. This means if more than one external
+   * ID for an email is returned there is an inconsistency in the external IDs.
+   *
+   * <p>The external IDs are retrieved from the external ID cache. Each access to the external ID
+   * cache requires reading the SHA1 of the refs/meta/external-ids branch. If external IDs for
+   * multiple emails are needed it is more efficient to use this method instead of {@link
+   * #byEmail(String)} as this method reads the SHA1 of the refs/meta/external-ids branch only once
+   * (and not once per email).
+   *
+   * @see #byEmail(String)
+   */
+  public ImmutableSetMultimap<String, ExternalId> byEmails(String... emails) throws IOException {
+    return externalIdCache.byEmails(emails);
+  }
+
+  /** Returns all external IDs by email. */
+  public ImmutableSetMultimap<String, ExternalId> allByEmail() throws IOException {
+    return externalIdCache.allByEmail();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
new file mode 100644
index 0000000..8e5582c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsBatchUpdate.java
@@ -0,0 +1,129 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * This class allows to do batch updates to external IDs.
+ *
+ * <p>For NoteDb all updates will result in a single commit to the refs/meta/external-ids branch.
+ * This means callers can prepare many updates by invoking {@link #replace(ExternalId, ExternalId)}
+ * multiple times and when {@link ExternalIdsBatchUpdate#commit(String)} is invoked a single NoteDb
+ * commit is created that contains all the prepared updates.
+ */
+public class ExternalIdsBatchUpdate {
+  private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+  private final ExternalIdCache externalIdCache;
+  private final Set<ExternalId> toAdd = new HashSet<>();
+  private final Set<ExternalId> toDelete = new HashSet<>();
+
+  @Inject
+  public ExternalIdsBatchUpdate(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ExternalIdCache externalIdCache) {
+    this.repoManager = repoManager;
+    this.gitRefUpdated = gitRefUpdated;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+    this.externalIdCache = externalIdCache;
+  }
+
+  /**
+   * Adds an external ID replacement to the batch.
+   *
+   * <p>The actual replacement is only done when {@link #commit(String)} is invoked.
+   */
+  public void replace(ExternalId extIdToDelete, ExternalId extIdToAdd) {
+    ExternalIdsUpdate.checkSameAccount(ImmutableSet.of(extIdToDelete, extIdToAdd));
+    toAdd.add(extIdToAdd);
+    toDelete.add(extIdToDelete);
+  }
+
+  /**
+   * Commits this batch.
+   *
+   * <p>This means external ID replacements which were prepared by invoking {@link
+   * #replace(ExternalId, ExternalId)} are now executed. Deletion of external IDs is done before
+   * adding the new external IDs. This means if an external ID is specified for deletion and an
+   * external ID with the same key is specified to be added, the old external ID with that key is
+   * deleted first and then the new external ID is added (so the external ID for that key is
+   * replaced).
+   *
+   * <p>For NoteDb a single commit is created that contains all the external ID updates.
+   */
+  public void commit(String commitMessage)
+      throws IOException, OrmException, ConfigInvalidException {
+    if (toDelete.isEmpty() && toAdd.isEmpty()) {
+      return;
+    }
+
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId rev = ExternalIdReader.readRevision(repo);
+
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+      for (ExternalId extId : toDelete) {
+        ExternalIdsUpdate.remove(rw, noteMap, extId);
+      }
+
+      for (ExternalId extId : toAdd) {
+        ExternalIdsUpdate.insert(rw, ins, noteMap, extId);
+      }
+
+      ObjectId newRev =
+          ExternalIdsUpdate.commit(
+              allUsersName,
+              repo,
+              rw,
+              ins,
+              rev,
+              noteMap,
+              commitMessage,
+              serverIdent,
+              serverIdent,
+              null,
+              gitRefUpdated);
+      externalIdCache.onReplace(rev, newRev, toDelete, toAdd);
+    }
+
+    toAdd.clear();
+    toDelete.clear();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.java
new file mode 100644
index 0000000..5dbde8e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsConsistencyChecker.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.server.account.externalids;
+
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+import static java.util.stream.Collectors.joining;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.commons.codec.DecoderException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class ExternalIdsConsistencyChecker {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsers;
+  private final AccountCache accountCache;
+  private final OutgoingEmailValidator validator;
+
+  @Inject
+  ExternalIdsConsistencyChecker(
+      GitRepositoryManager repoManager,
+      AllUsersName allUsers,
+      AccountCache accountCache,
+      OutgoingEmailValidator validator) {
+    this.repoManager = repoManager;
+    this.allUsers = allUsers;
+    this.accountCache = accountCache;
+    this.validator = validator;
+  }
+
+  public List<ConsistencyProblemInfo> check() throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(repo, ExternalIdReader.readRevision(repo));
+    }
+  }
+
+  public List<ConsistencyProblemInfo> check(ObjectId rev) throws IOException {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return check(repo, rev);
+    }
+  }
+
+  private List<ConsistencyProblemInfo> check(Repository repo, ObjectId commit) throws IOException {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    ListMultimap<String, ExternalId.Key> emails =
+        MultimapBuilder.hashKeys().arrayListValues().build();
+
+    try (RevWalk rw = new RevWalk(repo)) {
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, commit);
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader()
+                .open(note.getData(), OBJ_BLOB)
+                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
+        try {
+          ExternalId extId = ExternalId.parse(note.getName(), raw, note.getData());
+          problems.addAll(validateExternalId(extId));
+
+          if (extId.email() != null) {
+            emails.put(extId.email(), extId.key());
+          }
+        } catch (ConfigInvalidException e) {
+          addError(String.format(e.getMessage()), problems);
+        }
+      }
+    }
+
+    emails
+        .asMap()
+        .entrySet()
+        .stream()
+        .filter(e -> e.getValue().size() > 1)
+        .forEach(
+            e ->
+                addError(
+                    String.format(
+                        "Email '%s' is not unique, it's used by the following external IDs: %s",
+                        e.getKey(),
+                        e.getValue()
+                            .stream()
+                            .map(k -> "'" + k.get() + "'")
+                            .sorted()
+                            .collect(joining(", "))),
+                    problems));
+
+    return problems;
+  }
+
+  private List<ConsistencyProblemInfo> validateExternalId(ExternalId extId) {
+    List<ConsistencyProblemInfo> problems = new ArrayList<>();
+
+    if (accountCache.getOrNull(extId.accountId()) == null) {
+      addError(
+          String.format(
+              "External ID '%s' belongs to account that doesn't exist: %s",
+              extId.key().get(), extId.accountId().get()),
+          problems);
+    }
+
+    if (extId.email() != null && !validator.isValid(extId.email())) {
+      addError(
+          String.format(
+              "External ID '%s' has an invalid email: %s", extId.key().get(), extId.email()),
+          problems);
+    }
+
+    if (extId.password() != null && extId.isScheme(SCHEME_USERNAME)) {
+      try {
+        HashedPassword.decode(extId.password());
+      } catch (DecoderException e) {
+        addError(
+            String.format(
+                "External ID '%s' has an invalid password: %s", extId.key().get(), e.getMessage()),
+            problems);
+      }
+    }
+
+    return problems;
+  }
+
+  private static void addError(String error, List<ConsistencyProblemInfo> problems) {
+    problems.add(new ConsistencyProblemInfo(ConsistencyProblemInfo.Status.ERROR, error));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
new file mode 100644
index 0000000..db37147
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/externalids/ExternalIdsUpdate.java
@@ -0,0 +1,940 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.account.externalids;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.MAX_NOTE_SZ;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.readNoteMap;
+import static com.google.gerrit.server.account.externalids.ExternalIdReader.readRevision;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.eclipse.jgit.lib.Constants.OBJ_TREE;
+
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.Retryer;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+import com.google.common.util.concurrent.Runnables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Updates externalIds in ReviewDb and NoteDb.
+ *
+ * <p>In NoteDb external IDs are stored in the All-Users repository in a Git Notes branch called
+ * refs/meta/external-ids where the sha1 of the external ID is used as note name. Each note content
+ * is a git config file that contains an external ID. It has exactly one externalId subsection with
+ * an accountId and optionally email and password:
+ *
+ * <pre>
+ * [externalId "username:jdoe"]
+ *   accountId = 1003407
+ *   email = jdoe@example.com
+ *   password = bcrypt:4:LCbmSBDivK/hhGVQMfkDpA==:XcWn0pKYSVU/UJgOvhidkEtmqCp6oKB7
+ * </pre>
+ *
+ * For NoteDb each method call results in one commit on refs/meta/external-ids branch.
+ *
+ * <p>On updating external IDs this class takes care to evict affected accounts from the account
+ * cache and thus triggers reindex for them.
+ */
+public class ExternalIdsUpdate {
+  private static final String COMMIT_MSG = "Update external IDs";
+
+  /**
+   * Factory to create an ExternalIdsUpdate instance for updating external IDs by the Gerrit server.
+   *
+   * <p>The Gerrit server identity will be used as author and committer for all commits that update
+   * the external IDs.
+   */
+  @Singleton
+  public static class Server {
+    private final GitRepositoryManager repoManager;
+    private final AccountCache accountCache;
+    private final AllUsersName allUsersName;
+    private final MetricMaker metricMaker;
+    private final ExternalIds externalIds;
+    private final ExternalIdCache externalIdCache;
+    private final Provider<PersonIdent> serverIdent;
+    private final GitReferenceUpdated gitRefUpdated;
+
+    @Inject
+    public Server(
+        GitRepositoryManager repoManager,
+        AccountCache accountCache,
+        AllUsersName allUsersName,
+        MetricMaker metricMaker,
+        ExternalIds externalIds,
+        ExternalIdCache externalIdCache,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        GitReferenceUpdated gitRefUpdated) {
+      this.repoManager = repoManager;
+      this.accountCache = accountCache;
+      this.allUsersName = allUsersName;
+      this.metricMaker = metricMaker;
+      this.externalIds = externalIds;
+      this.externalIdCache = externalIdCache;
+      this.serverIdent = serverIdent;
+      this.gitRefUpdated = gitRefUpdated;
+    }
+
+    public ExternalIdsUpdate create() {
+      PersonIdent i = serverIdent.get();
+      return new ExternalIdsUpdate(
+          repoManager,
+          accountCache,
+          allUsersName,
+          metricMaker,
+          externalIds,
+          externalIdCache,
+          i,
+          i,
+          null,
+          gitRefUpdated);
+    }
+  }
+
+  /**
+   * Factory to create an ExternalIdsUpdate instance for updating external IDs by the Gerrit server.
+   *
+   * <p>Using this class no reindex will be performed for the affected accounts and they will also
+   * not be evicted from the account cache.
+   *
+   * <p>The Gerrit server identity will be used as author and committer for all commits that update
+   * the external IDs.
+   */
+  @Singleton
+  public static class ServerNoReindex {
+    private final GitRepositoryManager repoManager;
+    private final AllUsersName allUsersName;
+    private final MetricMaker metricMaker;
+    private final ExternalIds externalIds;
+    private final ExternalIdCache externalIdCache;
+    private final Provider<PersonIdent> serverIdent;
+    private final GitReferenceUpdated gitRefUpdated;
+
+    @Inject
+    public ServerNoReindex(
+        GitRepositoryManager repoManager,
+        AllUsersName allUsersName,
+        MetricMaker metricMaker,
+        ExternalIds externalIds,
+        ExternalIdCache externalIdCache,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        GitReferenceUpdated gitRefUpdated) {
+      this.repoManager = repoManager;
+      this.allUsersName = allUsersName;
+      this.metricMaker = metricMaker;
+      this.externalIds = externalIds;
+      this.externalIdCache = externalIdCache;
+      this.serverIdent = serverIdent;
+      this.gitRefUpdated = gitRefUpdated;
+    }
+
+    public ExternalIdsUpdate create() {
+      PersonIdent i = serverIdent.get();
+      return new ExternalIdsUpdate(
+          repoManager,
+          null,
+          allUsersName,
+          metricMaker,
+          externalIds,
+          externalIdCache,
+          i,
+          i,
+          null,
+          gitRefUpdated);
+    }
+  }
+
+  /**
+   * Factory to create an ExternalIdsUpdate instance for updating external IDs by the current user.
+   *
+   * <p>The identity of the current user will be used as author for all commits that update the
+   * external IDs. The Gerrit server identity will be used as committer.
+   */
+  @Singleton
+  public static class User {
+    private final GitRepositoryManager repoManager;
+    private final AccountCache accountCache;
+    private final AllUsersName allUsersName;
+    private final MetricMaker metricMaker;
+    private final ExternalIds externalIds;
+    private final ExternalIdCache externalIdCache;
+    private final Provider<PersonIdent> serverIdent;
+    private final Provider<IdentifiedUser> identifiedUser;
+    private final GitReferenceUpdated gitRefUpdated;
+
+    @Inject
+    public User(
+        GitRepositoryManager repoManager,
+        AccountCache accountCache,
+        AllUsersName allUsersName,
+        MetricMaker metricMaker,
+        ExternalIds externalIds,
+        ExternalIdCache externalIdCache,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        Provider<IdentifiedUser> identifiedUser,
+        GitReferenceUpdated gitRefUpdated) {
+      this.repoManager = repoManager;
+      this.accountCache = accountCache;
+      this.allUsersName = allUsersName;
+      this.metricMaker = metricMaker;
+      this.externalIds = externalIds;
+      this.externalIdCache = externalIdCache;
+      this.serverIdent = serverIdent;
+      this.identifiedUser = identifiedUser;
+      this.gitRefUpdated = gitRefUpdated;
+    }
+
+    public ExternalIdsUpdate create() {
+      IdentifiedUser user = identifiedUser.get();
+      PersonIdent i = serverIdent.get();
+      return new ExternalIdsUpdate(
+          repoManager,
+          accountCache,
+          allUsersName,
+          metricMaker,
+          externalIds,
+          externalIdCache,
+          createPersonIdent(i, user),
+          i,
+          user,
+          gitRefUpdated);
+    }
+
+    private PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
+      return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+    }
+  }
+
+  @VisibleForTesting
+  public static RetryerBuilder<RefsMetaExternalIdsUpdate> retryerBuilder() {
+    return RetryerBuilder.<RefsMetaExternalIdsUpdate>newBuilder()
+        .retryIfException(e -> e instanceof LockFailureException)
+        .withWaitStrategy(
+            WaitStrategies.join(
+                WaitStrategies.exponentialWait(2, TimeUnit.SECONDS),
+                WaitStrategies.randomWait(50, TimeUnit.MILLISECONDS)))
+        .withStopStrategy(StopStrategies.stopAfterDelay(10, TimeUnit.SECONDS));
+  }
+
+  private static final Retryer<RefsMetaExternalIdsUpdate> RETRYER = retryerBuilder().build();
+
+  private final GitRepositoryManager repoManager;
+  @Nullable private final AccountCache accountCache;
+  private final AllUsersName allUsersName;
+  private final ExternalIds externalIds;
+  private final ExternalIdCache externalIdCache;
+  private final PersonIdent committerIdent;
+  private final PersonIdent authorIdent;
+  @Nullable private final IdentifiedUser currentUser;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final Runnable afterReadRevision;
+  private final Retryer<RefsMetaExternalIdsUpdate> retryer;
+  private final Counter0 updateCount;
+
+  private ExternalIdsUpdate(
+      GitRepositoryManager repoManager,
+      @Nullable AccountCache accountCache,
+      AllUsersName allUsersName,
+      MetricMaker metricMaker,
+      ExternalIds externalIds,
+      ExternalIdCache externalIdCache,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      @Nullable IdentifiedUser currentUser,
+      GitReferenceUpdated gitRefUpdated) {
+    this(
+        repoManager,
+        accountCache,
+        allUsersName,
+        metricMaker,
+        externalIds,
+        externalIdCache,
+        committerIdent,
+        authorIdent,
+        currentUser,
+        gitRefUpdated,
+        Runnables.doNothing(),
+        RETRYER);
+  }
+
+  @VisibleForTesting
+  public ExternalIdsUpdate(
+      GitRepositoryManager repoManager,
+      @Nullable AccountCache accountCache,
+      AllUsersName allUsersName,
+      MetricMaker metricMaker,
+      ExternalIds externalIds,
+      ExternalIdCache externalIdCache,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      @Nullable IdentifiedUser currentUser,
+      GitReferenceUpdated gitRefUpdated,
+      Runnable afterReadRevision,
+      Retryer<RefsMetaExternalIdsUpdate> retryer) {
+    this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.accountCache = accountCache;
+    this.allUsersName = checkNotNull(allUsersName, "allUsersName");
+    this.committerIdent = checkNotNull(committerIdent, "committerIdent");
+    this.externalIds = checkNotNull(externalIds, "externalIds");
+    this.externalIdCache = checkNotNull(externalIdCache, "externalIdCache");
+    this.authorIdent = checkNotNull(authorIdent, "authorIdent");
+    this.currentUser = currentUser;
+    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
+    this.afterReadRevision = checkNotNull(afterReadRevision, "afterReadRevision");
+    this.retryer = checkNotNull(retryer, "retryer");
+    this.updateCount =
+        metricMaker.newCounter(
+            "notedb/external_id_update_count",
+            new Description("Total number of external ID updates.").setRate().setUnit("updates"));
+  }
+
+  /**
+   * Inserts a new external ID.
+   *
+   * <p>If the external ID already exists, the insert fails with {@link OrmDuplicateKeyException}.
+   */
+  public void insert(ExternalId extId) throws IOException, ConfigInvalidException, OrmException {
+    insert(Collections.singleton(extId));
+  }
+
+  /**
+   * Inserts new external IDs.
+   *
+   * <p>If any of the external ID already exists, the insert fails with {@link
+   * OrmDuplicateKeyException}.
+   */
+  public void insert(Collection<ExternalId> extIds)
+      throws IOException, ConfigInvalidException, OrmException {
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
+              for (ExternalId extId : extIds) {
+                ExternalId insertedExtId = insert(o.rw(), o.ins(), o.noteMap(), extId);
+                updatedExtIds.onUpdate(insertedExtId);
+              }
+              return updatedExtIds;
+            });
+    externalIdCache.onCreate(u.oldRev(), u.newRev(), u.updatedExtIds().getUpdated());
+    evictAccounts(u);
+  }
+
+  /**
+   * Inserts or updates an external ID.
+   *
+   * <p>If the external ID already exists, it is overwritten, otherwise it is inserted.
+   */
+  public void upsert(ExternalId extId) throws IOException, ConfigInvalidException, OrmException {
+    upsert(Collections.singleton(extId));
+  }
+
+  /**
+   * Inserts or updates external IDs.
+   *
+   * <p>If any of the external IDs already exists, it is overwritten. New external IDs are inserted.
+   */
+  public void upsert(Collection<ExternalId> extIds)
+      throws IOException, ConfigInvalidException, OrmException {
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
+              for (ExternalId extId : extIds) {
+                ExternalId updatedExtId = upsert(o.rw(), o.ins(), o.noteMap(), extId);
+                updatedExtIds.onUpdate(updatedExtId);
+              }
+              return updatedExtIds;
+            });
+    externalIdCache.onUpdate(u.oldRev(), u.newRev(), u.updatedExtIds().getUpdated());
+    evictAccounts(u);
+  }
+
+  /**
+   * Deletes an external ID.
+   *
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
+   */
+  public void delete(ExternalId extId) throws IOException, ConfigInvalidException, OrmException {
+    delete(Collections.singleton(extId));
+  }
+
+  /**
+   * Deletes external IDs.
+   *
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key as any of the external IDs that should be deleted, but otherwise doesn't match the that
+   *     external ID.
+   */
+  public void delete(Collection<ExternalId> extIds)
+      throws IOException, ConfigInvalidException, OrmException {
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
+              for (ExternalId extId : extIds) {
+                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extId);
+                updatedExtIds.onRemove(removedExtId);
+              }
+              return updatedExtIds;
+            });
+    externalIdCache.onRemove(u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved());
+    evictAccounts(u);
+  }
+
+  /**
+   * Delete an external ID by key.
+   *
+   * @throws IllegalStateException is thrown if the external ID does not belong to the specified
+   *     account.
+   */
+  public void delete(Account.Id accountId, ExternalId.Key extIdKey)
+      throws IOException, ConfigInvalidException, OrmException {
+    delete(accountId, Collections.singleton(extIdKey));
+  }
+
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * @throws IllegalStateException is thrown if any of the external IDs does not belong to the
+   *     specified account.
+   */
+  public void delete(Account.Id accountId, Collection<ExternalId.Key> extIdKeys)
+      throws IOException, ConfigInvalidException, OrmException {
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
+              for (ExternalId.Key extIdKey : extIdKeys) {
+                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extIdKey, accountId);
+                updatedExtIds.onRemove(removedExtId);
+              }
+              return updatedExtIds;
+            });
+    externalIdCache.onRemove(u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved());
+    evictAccount(accountId);
+  }
+
+  /**
+   * Delete external IDs by external ID key.
+   *
+   * <p>The external IDs are deleted regardless of which account they belong to.
+   */
+  public void deleteByKeys(Collection<ExternalId.Key> extIdKeys)
+      throws IOException, ConfigInvalidException, OrmException {
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
+              for (ExternalId.Key extIdKey : extIdKeys) {
+                ExternalId extId = remove(o.rw(), o.noteMap(), extIdKey, null);
+                updatedExtIds.onRemove(extId);
+              }
+              return updatedExtIds;
+            });
+    externalIdCache.onRemove(u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved());
+    evictAccounts(u);
+  }
+
+  /** Deletes all external IDs of the specified account. */
+  public void deleteAll(Account.Id accountId)
+      throws IOException, ConfigInvalidException, OrmException {
+    delete(externalIds.byAccount(accountId));
+  }
+
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * @throws IllegalStateException is thrown if any of the specified external IDs does not belong to
+   *     the specified account.
+   */
+  public void replace(
+      Account.Id accountId, Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, ConfigInvalidException, OrmException {
+    checkSameAccount(toAdd, accountId);
+
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
+              for (ExternalId.Key extIdKey : toDelete) {
+                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extIdKey, accountId);
+                updatedExtIds.onRemove(removedExtId);
+              }
+
+              for (ExternalId extId : toAdd) {
+                ExternalId insertedExtId = insert(o.rw(), o.ins(), o.noteMap(), extId);
+                updatedExtIds.onUpdate(insertedExtId);
+              }
+              return updatedExtIds;
+            });
+    externalIdCache.onReplace(
+        u.oldRev(),
+        u.newRev(),
+        accountId,
+        u.updatedExtIds().getRemoved(),
+        u.updatedExtIds().getUpdated());
+    evictAccount(accountId);
+  }
+
+  /**
+   * Replaces external IDs for an account by external ID keys.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID key is specified for deletion and an external ID with the same key is specified to
+   * be added, the old external ID with that key is deleted first and then the new external ID is
+   * added (so the external ID for that key is replaced).
+   *
+   * <p>The external IDs are replaced regardless of which account they belong to.
+   */
+  public void replaceByKeys(Collection<ExternalId.Key> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, ConfigInvalidException, OrmException {
+    RefsMetaExternalIdsUpdate u =
+        updateNoteMap(
+            o -> {
+              UpdatedExternalIds updatedExtIds = new UpdatedExternalIds();
+              for (ExternalId.Key extIdKey : toDelete) {
+                ExternalId removedExtId = remove(o.rw(), o.noteMap(), extIdKey, null);
+                updatedExtIds.onRemove(removedExtId);
+              }
+
+              for (ExternalId extId : toAdd) {
+                ExternalId insertedExtId = insert(o.rw(), o.ins(), o.noteMap(), extId);
+                updatedExtIds.onUpdate(insertedExtId);
+              }
+              return updatedExtIds;
+            });
+    externalIdCache.onReplace(
+        u.oldRev(), u.newRev(), u.updatedExtIds().getRemoved(), u.updatedExtIds().getUpdated());
+    evictAccounts(u);
+  }
+
+  /**
+   * Replaces an external ID.
+   *
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
+   */
+  public void replace(ExternalId toDelete, ExternalId toAdd)
+      throws IOException, ConfigInvalidException, OrmException {
+    replace(Collections.singleton(toDelete), Collections.singleton(toAdd));
+  }
+
+  /**
+   * Replaces external IDs.
+   *
+   * <p>Deletion of external IDs is done before adding the new external IDs. This means if an
+   * external ID is specified for deletion and an external ID with the same key is specified to be
+   * added, the old external ID with that key is deleted first and then the new external ID is added
+   * (so the external ID for that key is replaced).
+   *
+   * @throws IllegalStateException is thrown if the specified external IDs belong to different
+   *     accounts.
+   */
+  public void replace(Collection<ExternalId> toDelete, Collection<ExternalId> toAdd)
+      throws IOException, ConfigInvalidException, OrmException {
+    Account.Id accountId = checkSameAccount(Iterables.concat(toDelete, toAdd));
+    if (accountId == null) {
+      // toDelete and toAdd are empty -> nothing to do
+      return;
+    }
+
+    replace(accountId, toDelete.stream().map(e -> e.key()).collect(toSet()), toAdd);
+  }
+
+  /**
+   * Checks that all specified external IDs belong to the same account.
+   *
+   * @return the ID of the account to which all specified external IDs belong.
+   */
+  public static Account.Id checkSameAccount(Iterable<ExternalId> extIds) {
+    return checkSameAccount(extIds, null);
+  }
+
+  /**
+   * Checks that all specified external IDs belong to specified account. If no account is specified
+   * it is checked that all specified external IDs belong to the same account.
+   *
+   * @return the ID of the account to which all specified external IDs belong.
+   */
+  public static Account.Id checkSameAccount(
+      Iterable<ExternalId> extIds, @Nullable Account.Id accountId) {
+    for (ExternalId extId : extIds) {
+      if (accountId == null) {
+        accountId = extId.accountId();
+        continue;
+      }
+      checkState(
+          accountId.equals(extId.accountId()),
+          "external id %s belongs to account %s, expected account %s",
+          extId.key().get(),
+          extId.accountId().get(),
+          accountId.get());
+    }
+    return accountId;
+  }
+
+  /**
+   * Inserts a new external ID and sets it in the note map.
+   *
+   * <p>If the external ID already exists, the insert fails with {@link OrmDuplicateKeyException}.
+   */
+  public static ExternalId insert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
+      throws OrmDuplicateKeyException, ConfigInvalidException, IOException {
+    if (noteMap.contains(extId.key().sha1())) {
+      throw new OrmDuplicateKeyException(
+          String.format("external id %s already exists", extId.key().get()));
+    }
+    return upsert(rw, ins, noteMap, extId);
+  }
+
+  /**
+   * Insert or updates an new external ID and sets it in the note map.
+   *
+   * <p>If the external ID already exists it is overwritten.
+   */
+  public static ExternalId upsert(RevWalk rw, ObjectInserter ins, NoteMap noteMap, ExternalId extId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extId.key().sha1();
+    Config c = new Config();
+    if (noteMap.contains(extId.key().sha1())) {
+      byte[] raw =
+          rw.getObjectReader().open(noteMap.get(noteId), OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+      try {
+        c.fromText(new String(raw, UTF_8));
+      } catch (ConfigInvalidException e) {
+        throw new ConfigInvalidException(
+            String.format("Invalid external id config for note %s: %s", noteId, e.getMessage()));
+      }
+    }
+    extId.writeToConfig(c);
+    byte[] raw = c.toText().getBytes(UTF_8);
+    ObjectId noteData = ins.insert(OBJ_BLOB, raw);
+    noteMap.set(noteId, noteData);
+    return ExternalId.create(extId, noteData);
+  }
+
+  /**
+   * Removes an external ID from the note map.
+   *
+   * @throws IllegalStateException is thrown if there is an existing external ID that has the same
+   *     key, but otherwise doesn't match the specified external ID.
+   */
+  public static ExternalId remove(RevWalk rw, NoteMap noteMap, ExternalId extId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extId.key().sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    ObjectId noteData = noteMap.get(noteId);
+    byte[] raw = rw.getObjectReader().open(noteData, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    ExternalId actualExtId = ExternalId.parse(noteId.name(), raw, noteData);
+    checkState(
+        extId.equals(actualExtId),
+        "external id %s should be removed, but it's not matching the actual external id %s",
+        extId.toString(),
+        actualExtId.toString());
+    noteMap.remove(noteId);
+    return actualExtId;
+  }
+
+  /**
+   * Removes an external ID from the note map by external ID key.
+   *
+   * @throws IllegalStateException is thrown if an expected account ID is provided and an external
+   *     ID with the specified key exists, but belongs to another account.
+   * @return the external ID that was removed, {@code null} if no external ID with the specified key
+   *     exists
+   */
+  private static ExternalId remove(
+      RevWalk rw, NoteMap noteMap, ExternalId.Key extIdKey, Account.Id expectedAccountId)
+      throws IOException, ConfigInvalidException {
+    ObjectId noteId = extIdKey.sha1();
+    if (!noteMap.contains(noteId)) {
+      return null;
+    }
+
+    ObjectId noteData = noteMap.get(noteId);
+    byte[] raw = rw.getObjectReader().open(noteData, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
+    ExternalId extId = ExternalId.parse(noteId.name(), raw, noteData);
+    if (expectedAccountId != null) {
+      checkState(
+          expectedAccountId.equals(extId.accountId()),
+          "external id %s should be removed for account %s,"
+              + " but external id belongs to account %s",
+          extIdKey.get(),
+          expectedAccountId.get(),
+          extId.accountId().get());
+    }
+    noteMap.remove(noteId);
+    return extId;
+  }
+
+  private RefsMetaExternalIdsUpdate updateNoteMap(ExternalIdUpdater updater)
+      throws IOException, ConfigInvalidException, OrmException {
+    try {
+      return retryer.call(
+          () -> {
+            try (Repository repo = repoManager.openRepository(allUsersName);
+                ObjectInserter ins = repo.newObjectInserter()) {
+              ObjectId rev = readRevision(repo);
+
+              afterReadRevision.run();
+
+              try (RevWalk rw = new RevWalk(repo)) {
+                NoteMap noteMap = readNoteMap(rw, rev);
+                UpdatedExternalIds updatedExtIds =
+                    updater.update(OpenRepo.create(repo, rw, ins, noteMap));
+
+                return commit(repo, rw, ins, rev, noteMap, updatedExtIds);
+              }
+            }
+          });
+    } catch (ExecutionException | RetryException e) {
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), ConfigInvalidException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), OrmException.class);
+      }
+      throw new OrmException(e);
+    }
+  }
+
+  private RefsMetaExternalIdsUpdate commit(
+      Repository repo,
+      RevWalk rw,
+      ObjectInserter ins,
+      ObjectId rev,
+      NoteMap noteMap,
+      UpdatedExternalIds updatedExtIds)
+      throws IOException {
+    ObjectId newRev =
+        commit(
+            allUsersName,
+            repo,
+            rw,
+            ins,
+            rev,
+            noteMap,
+            COMMIT_MSG,
+            committerIdent,
+            authorIdent,
+            currentUser,
+            gitRefUpdated);
+    updateCount.increment();
+    return RefsMetaExternalIdsUpdate.create(rev, newRev, updatedExtIds);
+  }
+
+  /** Commits updates to the external IDs. */
+  public static ObjectId commit(
+      Project.NameKey project,
+      Repository repo,
+      RevWalk rw,
+      ObjectInserter ins,
+      ObjectId rev,
+      NoteMap noteMap,
+      String commitMessage,
+      PersonIdent committerIdent,
+      PersonIdent authorIdent,
+      @Nullable IdentifiedUser user,
+      GitReferenceUpdated gitRefUpdated)
+      throws IOException {
+    CommitBuilder cb = new CommitBuilder();
+    cb.setMessage(commitMessage);
+    cb.setTreeId(noteMap.writeTree(ins));
+    cb.setAuthor(authorIdent);
+    cb.setCommitter(committerIdent);
+    if (!rev.equals(ObjectId.zeroId())) {
+      cb.setParentId(rev);
+    } else {
+      cb.setParentIds(); // Ref is currently nonexistent, commit has no parents.
+    }
+    if (cb.getTreeId() == null) {
+      if (rev.equals(ObjectId.zeroId())) {
+        cb.setTreeId(emptyTree(ins)); // No parent, assume empty tree.
+      } else {
+        RevCommit p = rw.parseCommit(rev);
+        cb.setTreeId(p.getTree()); // Copy tree from parent.
+      }
+    }
+    ObjectId commitId = ins.insert(cb);
+    ins.flush();
+
+    RefUpdate u = repo.updateRef(RefNames.REFS_EXTERNAL_IDS);
+    u.setRefLogIdent(committerIdent);
+    u.setRefLogMessage("Update external IDs", false);
+    u.setExpectedOldObjectId(rev);
+    u.setNewObjectId(commitId);
+    RefUpdate.Result res = u.update();
+    switch (res) {
+      case NEW:
+      case FAST_FORWARD:
+      case NO_CHANGE:
+      case RENAMED:
+      case FORCED:
+        break;
+      case LOCK_FAILURE:
+        throw new LockFailureException("Updating external IDs failed with " + res, u);
+      case IO_FAILURE:
+      case NOT_ATTEMPTED:
+      case REJECTED:
+      case REJECTED_CURRENT_BRANCH:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
+      default:
+        throw new IOException("Updating external IDs failed with " + res);
+    }
+    gitRefUpdated.fire(project, u, user != null ? user.getAccount() : null);
+    return rw.parseCommit(commitId);
+  }
+
+  private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
+    return ins.insert(OBJ_TREE, new byte[] {});
+  }
+
+  private void evictAccount(Account.Id accountId) throws IOException {
+    if (accountCache != null) {
+      accountCache.evict(accountId);
+    }
+  }
+
+  private void evictAccounts(RefsMetaExternalIdsUpdate u) throws IOException {
+    if (accountCache != null) {
+      for (Account.Id id : u.updatedExtIds().all().map(ExternalId::accountId).collect(toSet())) {
+        accountCache.evict(id);
+      }
+    }
+  }
+
+  @FunctionalInterface
+  private static interface ExternalIdUpdater {
+    UpdatedExternalIds update(OpenRepo openRepo)
+        throws IOException, ConfigInvalidException, OrmException;
+  }
+
+  @AutoValue
+  abstract static class OpenRepo {
+    static OpenRepo create(Repository repo, RevWalk rw, ObjectInserter ins, NoteMap noteMap) {
+      return new AutoValue_ExternalIdsUpdate_OpenRepo(repo, rw, ins, noteMap);
+    }
+
+    abstract Repository repo();
+
+    abstract RevWalk rw();
+
+    abstract ObjectInserter ins();
+
+    abstract NoteMap noteMap();
+  }
+
+  @VisibleForTesting
+  @AutoValue
+  public abstract static class RefsMetaExternalIdsUpdate {
+    static RefsMetaExternalIdsUpdate create(
+        ObjectId oldRev, ObjectId newRev, UpdatedExternalIds updatedExtIds) {
+      return new AutoValue_ExternalIdsUpdate_RefsMetaExternalIdsUpdate(
+          oldRev, newRev, updatedExtIds);
+    }
+
+    abstract ObjectId oldRev();
+
+    abstract ObjectId newRev();
+
+    abstract UpdatedExternalIds updatedExtIds();
+  }
+
+  public static class UpdatedExternalIds {
+    private Set<ExternalId> updated = new HashSet<>();
+    private Set<ExternalId> removed = new HashSet<>();
+
+    public void onUpdate(ExternalId extId) {
+      if (extId != null) {
+        updated.add(extId);
+      }
+    }
+
+    public void onRemove(ExternalId extId) {
+      if (extId != null) {
+        removed.add(extId);
+      }
+    }
+
+    public Set<ExternalId> getUpdated() {
+      return ImmutableSet.copyOf(updated);
+    }
+
+    public Set<ExternalId> getRemoved() {
+      return ImmutableSet.copyOf(removed);
+    }
+
+    public Stream<ExternalId> all() {
+      return Streams.concat(removed.stream(), updated.stream());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/ApiUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/ApiUtil.java
new file mode 100644
index 0000000..c5b8b12
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/ApiUtil.java
@@ -0,0 +1,39 @@
+// 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.api;
+
+import com.google.common.base.Throwables;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+/** Static utilities for API implementations. */
+public class ApiUtil {
+  /**
+   * Convert an exception encountered during API execution to a {@link RestApiException}.
+   *
+   * @param msg message to be used in the case where a new {@code RestApiException} is wrapped
+   *     around {@code e}.
+   * @param e exception being handled.
+   * @return {@code e} if it is already a {@code RestApiException}, otherwise a new {@code
+   *     RestApiException} wrapped around {@code e}.
+   * @throws RuntimeException if {@code e} is a runtime exception, it is rethrown as-is.
+   */
+  public static RestApiException asRestApiException(String msg, Exception e)
+      throws RuntimeException {
+    Throwables.throwIfUnchecked(e);
+    return e instanceof RestApiException ? (RestApiException) e : new RestApiException(msg, e);
+  }
+
+  private ApiUtil() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/GerritApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/GerritApiImpl.java
index 6241276..6a6415e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/GerritApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/GerritApiImpl.java
@@ -19,6 +19,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.inject.Inject;
 import com.google.inject.Singleton;
@@ -30,15 +31,22 @@
   private final Config config;
   private final Groups groups;
   private final Projects projects;
+  private final Plugins plugins;
 
   @Inject
   GerritApiImpl(
-      Accounts accounts, Changes changes, Config config, Groups groups, Projects projects) {
+      Accounts accounts,
+      Changes changes,
+      Config config,
+      Groups groups,
+      Projects projects,
+      Plugins plugins) {
     this.accounts = accounts;
     this.changes = changes;
     this.config = config;
     this.groups = groups;
     this.projects = projects;
+    this.plugins = plugins;
   }
 
   @Override
@@ -65,4 +73,9 @@
   public Projects projects() {
     return projects;
   }
+
+  @Override
+  public Plugins plugins() {
+    return plugins;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
index 430b6b7..3b09e79 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountApiImpl.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.api.accounts;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 import static javax.servlet.http.HttpServletResponse.SC_OK;
 
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.api.accounts.GpgKeyApi;
@@ -33,12 +33,12 @@
 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.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AddSshKey;
@@ -55,6 +55,7 @@
 import com.google.gerrit.server.account.GetEditPreferences;
 import com.google.gerrit.server.account.GetEmails;
 import com.google.gerrit.server.account.GetExternalIds;
+import com.google.gerrit.server.account.GetGroups;
 import com.google.gerrit.server.account.GetPreferences;
 import com.google.gerrit.server.account.GetSshKeys;
 import com.google.gerrit.server.account.GetWatchedProjects;
@@ -62,6 +63,7 @@
 import com.google.gerrit.server.account.PostWatchedProjects;
 import com.google.gerrit.server.account.PutActive;
 import com.google.gerrit.server.account.PutAgreement;
+import com.google.gerrit.server.account.PutName;
 import com.google.gerrit.server.account.PutStatus;
 import com.google.gerrit.server.account.SetDiffPreferences;
 import com.google.gerrit.server.account.SetEditPreferences;
@@ -74,11 +76,9 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.SortedSet;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class AccountApiImpl implements AccountApi {
   interface Factory {
@@ -120,6 +120,8 @@
   private final GetExternalIds getExternalIds;
   private final DeleteExternalIds deleteExternalIds;
   private final PutStatus putStatus;
+  private final GetGroups getGroups;
+  private final PutName putName;
 
   @Inject
   AccountApiImpl(
@@ -157,6 +159,8 @@
       GetExternalIds getExternalIds,
       DeleteExternalIds deleteExternalIds,
       PutStatus putStatus,
+      GetGroups getGroups,
+      PutName putName,
       @Assisted AccountResource account) {
     this.account = account;
     this.accountLoaderFactory = ailf;
@@ -193,6 +197,8 @@
     this.getExternalIds = getExternalIds;
     this.deleteExternalIds = deleteExternalIds;
     this.putStatus = putStatus;
+    this.getGroups = getGroups;
+    this.putName = putName;
   }
 
   @Override
@@ -202,8 +208,8 @@
       AccountInfo ai = accountLoader.get(account.getUser().getAccountId());
       accountLoader.fill();
       return ai;
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot parse change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse change", e);
     }
   }
 
@@ -221,8 +227,8 @@
       } else {
         deleteActive.apply(account, new DeleteActive.Input());
       }
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot set active", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set active", e);
     }
   }
 
@@ -234,15 +240,19 @@
 
   @Override
   public GeneralPreferencesInfo getPreferences() throws RestApiException {
-    return getPreferences.apply(account);
+    try {
+      return getPreferences.apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get preferences", e);
+    }
   }
 
   @Override
   public GeneralPreferencesInfo setPreferences(GeneralPreferencesInfo in) throws RestApiException {
     try {
       return setPreferences.apply(account, in);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot set preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set preferences", e);
     }
   }
 
@@ -250,8 +260,8 @@
   public DiffPreferencesInfo getDiffPreferences() throws RestApiException {
     try {
       return getDiffPreferences.apply(account);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot query diff preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query diff preferences", e);
     }
   }
 
@@ -259,8 +269,8 @@
   public DiffPreferencesInfo setDiffPreferences(DiffPreferencesInfo in) throws RestApiException {
     try {
       return setDiffPreferences.apply(account, in);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot set diff preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set diff preferences", e);
     }
   }
 
@@ -268,8 +278,8 @@
   public EditPreferencesInfo getEditPreferences() throws RestApiException {
     try {
       return getEditPreferences.apply(account);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot query edit preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query edit preferences", e);
     }
   }
 
@@ -277,8 +287,8 @@
   public EditPreferencesInfo setEditPreferences(EditPreferencesInfo in) throws RestApiException {
     try {
       return setEditPreferences.apply(account, in);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot set edit preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set edit preferences", e);
     }
   }
 
@@ -286,8 +296,8 @@
   public List<ProjectWatchInfo> getWatchedProjects() throws RestApiException {
     try {
       return getWatchedProjects.apply(account);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot get watched projects", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get watched projects", e);
     }
   }
 
@@ -296,8 +306,8 @@
       throws RestApiException {
     try {
       return postWatchedProjects.apply(account, in);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot update watched projects", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update watched projects", e);
     }
   }
 
@@ -305,8 +315,8 @@
   public void deleteWatchedProjects(List<ProjectWatchInfo> in) throws RestApiException {
     try {
       deleteWatchedProjects.apply(account, in);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot delete watched projects", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete watched projects", e);
     }
   }
 
@@ -316,8 +326,8 @@
       ChangeResource rsrc = changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(changeId));
       starredChangesCreate.setChange(rsrc);
       starredChangesCreate.apply(account, new StarredChanges.EmptyInput());
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot star change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot star change", e);
     }
   }
 
@@ -328,8 +338,8 @@
       AccountResource.StarredChange starredChange =
           new AccountResource.StarredChange(account.getUser(), rsrc);
       starredChangesDelete.apply(starredChange, new StarredChanges.EmptyInput());
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot unstar change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot unstar change", e);
     }
   }
 
@@ -338,8 +348,8 @@
     try {
       AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
       starsPost.apply(rsrc, input);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot post stars", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post stars", e);
     }
   }
 
@@ -348,8 +358,8 @@
     try {
       AccountResource.Star rsrc = stars.parse(account, IdString.fromUrl(changeId));
       return starsGet.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get stars", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get stars", e);
     }
   }
 
@@ -357,8 +367,17 @@
   public List<ChangeInfo> getStarredChanges() throws RestApiException {
     try {
       return stars.list().apply(account);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get starred changes", e);
+    }
+  }
+
+  @Override
+  public List<GroupInfo> getGroups() throws RestApiException {
+    try {
+      return getGroups.apply(account);
     } catch (OrmException e) {
-      throw new RestApiException("Cannot get starred changes", e);
+      throw asRestApiException("Cannot get groups", e);
     }
   }
 
@@ -372,8 +391,8 @@
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), input.email);
     try {
       createEmailFactory.create(input.email).apply(rsrc, input);
-    } catch (EmailException | OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot add email", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add email", e);
     }
   }
 
@@ -382,8 +401,8 @@
     AccountResource.Email rsrc = new AccountResource.Email(account.getUser(), email);
     try {
       deleteEmail.apply(rsrc, null);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot delete email", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete email", e);
     }
   }
 
@@ -392,8 +411,8 @@
     PutStatus.Input in = new PutStatus.Input(status);
     try {
       putStatus.apply(account, in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot set status", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set status", e);
     }
   }
 
@@ -401,8 +420,8 @@
   public List<SshKeyInfo> listSshKeys() throws RestApiException {
     try {
       return getSshKeys.apply(account);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot list SSH keys", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list SSH keys", e);
     }
   }
 
@@ -412,8 +431,8 @@
     in.raw = RawInputUtil.create(key);
     try {
       return addSshKey.apply(account, in).value();
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot add SSH key", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add SSH key", e);
     }
   }
 
@@ -423,8 +442,8 @@
       AccountResource.SshKey sshKeyRes =
           sshKeys.parse(account, IdString.fromDecoded(Integer.toString(seq)));
       deleteSshKey.apply(sshKeyRes, null);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot delete SSH key", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete SSH key", e);
     }
   }
 
@@ -432,8 +451,8 @@
   public Map<String, GpgKeyInfo> listGpgKeys() throws RestApiException {
     try {
       return gpgApiAdapter.listGpgKeys(account);
-    } catch (GpgException e) {
-      throw new RestApiException("Cannot list GPG keys", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list GPG keys", e);
     }
   }
 
@@ -442,8 +461,8 @@
       throws RestApiException {
     try {
       return gpgApiAdapter.putGpgKeys(account, add, delete);
-    } catch (GpgException e) {
-      throw new RestApiException("Cannot add GPG key", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add GPG key", e);
     }
   }
 
@@ -451,8 +470,8 @@
   public GpgKeyApi gpgKey(String id) throws RestApiException {
     try {
       return gpgApiAdapter.gpgKey(account, IdString.fromDecoded(id));
-    } catch (GpgException e) {
-      throw new RestApiException("Cannot get PGP key", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get PGP key", e);
     }
   }
 
@@ -467,8 +486,8 @@
       AgreementInput input = new AgreementInput();
       input.name = agreementName;
       putAgreement.apply(account, input);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot sign agreement", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot sign agreement", e);
     }
   }
 
@@ -476,8 +495,8 @@
   public void index() throws RestApiException {
     try {
       index.apply(account, new Index.Input());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot index account", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index account", e);
     }
   }
 
@@ -485,8 +504,8 @@
   public List<AccountExternalIdInfo> getExternalIds() throws RestApiException {
     try {
       return getExternalIds.apply(account);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get external IDs", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get external IDs", e);
     }
   }
 
@@ -494,8 +513,19 @@
   public void deleteExternalIds(List<String> externalIds) throws RestApiException {
     try {
       deleteExternalIds.apply(account, externalIds);
-    } catch (IOException | OrmException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot delete external IDs", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete external IDs", e);
+    }
+  }
+
+  @Override
+  public void setName(String name) throws RestApiException {
+    PutName.Input input = new PutName.Input();
+    input.name = name;
+    try {
+      putName.apply(account, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set account name", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
index 2d90853..2f8dee6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountExternalIdCreator.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.api.accounts;
 
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import java.util.List;
 
 public interface AccountExternalIdCreator {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
index 498b720..5257aec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/accounts/AccountsImpl.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.api.accounts;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.accounts.AccountApi;
 import com.google.gerrit.extensions.api.accounts.AccountInput;
@@ -32,18 +32,18 @@
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.account.CreateAccount;
 import com.google.gerrit.server.account.QueryAccounts;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class AccountsImpl implements Accounts {
   private final AccountsCollection accounts;
   private final AccountApiImpl.Factory api;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
   private final CreateAccount.Factory createAccount;
   private final Provider<QueryAccounts> queryAccountsProvider;
@@ -52,11 +52,13 @@
   AccountsImpl(
       AccountsCollection accounts,
       AccountApiImpl.Factory api,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       CreateAccount.Factory createAccount,
       Provider<QueryAccounts> queryAccountsProvider) {
     this.accounts = accounts;
     this.api = api;
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.createAccount = createAccount;
     this.queryAccountsProvider = queryAccountsProvider;
@@ -66,8 +68,8 @@
   public AccountApi id(String id) throws RestApiException {
     try {
       return api.create(accounts.parse(TopLevelResource.INSTANCE, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot parse change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse change", e);
     }
   }
 
@@ -96,13 +98,13 @@
     if (checkNotNull(in, "AccountInput").username == null) {
       throw new BadRequestException("AccountInput must specify username");
     }
-    checkRequiresCapability(self, null, CreateAccount.class);
     try {
-      AccountInfo info =
-          createAccount.create(in.username).apply(TopLevelResource.INSTANCE, in).value();
+      CreateAccount impl = createAccount.create(in.username);
+      permissionBackend.user(self).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      AccountInfo info = impl.apply(TopLevelResource.INSTANCE, in).value();
       return id(info._accountId);
-    } catch (OrmException | IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot create account " + in.username, e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create account " + in.username, e);
     }
   }
 
@@ -128,8 +130,8 @@
       myQueryAccounts.setQuery(r.getQuery());
       myQueryAccounts.setLimit(r.getLimit());
       return myQueryAccounts.apply(TopLevelResource.INSTANCE);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve suggested accounts", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve suggested accounts", e);
     }
   }
 
@@ -158,8 +160,8 @@
         myQueryAccounts.addOption(option);
       }
       return myQueryAccounts.apply(TopLevelResource.INSTANCE);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve suggested accounts", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve suggested accounts", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
index a0babe1..447fdf4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeApiImpl.java
@@ -14,9 +14,12 @@
 
 package com.google.gerrit.server.api.changes;
 
-import com.google.gerrit.common.errors.EmailException;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.AbandonInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
+import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
@@ -29,6 +32,7 @@
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
+import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherInfo;
 import com.google.gerrit.extensions.api.changes.SubmittedTogetherOption;
@@ -36,13 +40,17 @@
 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.IdString;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.change.Abandon;
 import com.google.gerrit.server.change.ChangeIncludedIn;
 import com.google.gerrit.server.change.ChangeJson;
@@ -51,34 +59,43 @@
 import com.google.gerrit.server.change.CreateMergePatchSet;
 import com.google.gerrit.server.change.DeleteAssignee;
 import com.google.gerrit.server.change.DeleteChange;
+import com.google.gerrit.server.change.DeletePrivate;
 import com.google.gerrit.server.change.GetAssignee;
 import com.google.gerrit.server.change.GetHashtags;
 import com.google.gerrit.server.change.GetPastAssignees;
+import com.google.gerrit.server.change.GetPureRevert;
 import com.google.gerrit.server.change.GetTopic;
+import com.google.gerrit.server.change.Ignore;
 import com.google.gerrit.server.change.Index;
 import com.google.gerrit.server.change.ListChangeComments;
 import com.google.gerrit.server.change.ListChangeDrafts;
 import com.google.gerrit.server.change.ListChangeRobotComments;
+import com.google.gerrit.server.change.ListReviewers;
+import com.google.gerrit.server.change.MarkAsReviewed;
+import com.google.gerrit.server.change.MarkAsUnreviewed;
 import com.google.gerrit.server.change.Move;
 import com.google.gerrit.server.change.PostHashtags;
+import com.google.gerrit.server.change.PostPrivate;
 import com.google.gerrit.server.change.PostReviewers;
-import com.google.gerrit.server.change.PublishDraftPatchSet;
 import com.google.gerrit.server.change.PutAssignee;
+import com.google.gerrit.server.change.PutMessage;
 import com.google.gerrit.server.change.PutTopic;
 import com.google.gerrit.server.change.Rebase;
 import com.google.gerrit.server.change.Restore;
 import com.google.gerrit.server.change.Revert;
 import com.google.gerrit.server.change.Reviewers;
 import com.google.gerrit.server.change.Revisions;
+import com.google.gerrit.server.change.SetPrivateOp;
+import com.google.gerrit.server.change.SetReadyForReview;
+import com.google.gerrit.server.change.SetWorkInProgress;
 import com.google.gerrit.server.change.SubmittedTogether;
 import com.google.gerrit.server.change.SuggestChangeReviewers;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.change.Unignore;
+import com.google.gerrit.server.change.WorkInProgressOp;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
@@ -95,13 +112,13 @@
   private final ReviewerApiImpl.Factory reviewerApi;
   private final RevisionApiImpl.Factory revisionApi;
   private final SuggestChangeReviewers suggestReviewers;
+  private final ListReviewers listReviewers;
   private final ChangeResource change;
   private final Abandon abandon;
   private final Revert revert;
   private final Restore restore;
   private final CreateMergePatchSet updateByMerge;
   private final Provider<SubmittedTogether> submittedTogether;
-  private final PublishDraftPatchSet.CurrentRevision publishDraftChange;
   private final Rebase.CurrentRevision rebase;
   private final DeleteChange deleteChange;
   private final GetTopic getTopic;
@@ -122,6 +139,17 @@
   private final Check check;
   private final Index index;
   private final Move move;
+  private final PostPrivate postPrivate;
+  private final DeletePrivate deletePrivate;
+  private final Ignore ignore;
+  private final Unignore unignore;
+  private final MarkAsReviewed markAsReviewed;
+  private final MarkAsUnreviewed markAsUnreviewed;
+  private final SetWorkInProgress setWip;
+  private final SetReadyForReview setReady;
+  private final PutMessage putMessage;
+  private final GetPureRevert getPureRevert;
+  private final StarredChangesUtil stars;
 
   @Inject
   ChangeApiImpl(
@@ -131,12 +159,12 @@
       ReviewerApiImpl.Factory reviewerApi,
       RevisionApiImpl.Factory revisionApi,
       SuggestChangeReviewers suggestReviewers,
+      ListReviewers listReviewers,
       Abandon abandon,
       Revert revert,
       Restore restore,
       CreateMergePatchSet updateByMerge,
       Provider<SubmittedTogether> submittedTogether,
-      PublishDraftPatchSet.CurrentRevision publishDraftChange,
       Rebase.CurrentRevision rebase,
       DeleteChange deleteChange,
       GetTopic getTopic,
@@ -157,6 +185,17 @@
       Check check,
       Index index,
       Move move,
+      PostPrivate postPrivate,
+      DeletePrivate deletePrivate,
+      Ignore ignore,
+      Unignore unignore,
+      MarkAsReviewed markAsReviewed,
+      MarkAsUnreviewed markAsUnreviewed,
+      SetWorkInProgress setWip,
+      SetReadyForReview setReady,
+      PutMessage putMessage,
+      GetPureRevert getPureRevert,
+      StarredChangesUtil stars,
       @Assisted ChangeResource change) {
     this.changeApi = changeApi;
     this.revert = revert;
@@ -165,11 +204,11 @@
     this.reviewerApi = reviewerApi;
     this.revisionApi = revisionApi;
     this.suggestReviewers = suggestReviewers;
+    this.listReviewers = listReviewers;
     this.abandon = abandon;
     this.restore = restore;
     this.updateByMerge = updateByMerge;
     this.submittedTogether = submittedTogether;
-    this.publishDraftChange = publishDraftChange;
     this.rebase = rebase;
     this.deleteChange = deleteChange;
     this.getTopic = getTopic;
@@ -190,6 +229,17 @@
     this.check = check;
     this.index = index;
     this.move = move;
+    this.postPrivate = postPrivate;
+    this.deletePrivate = deletePrivate;
+    this.ignore = ignore;
+    this.unignore = unignore;
+    this.markAsReviewed = markAsReviewed;
+    this.markAsUnreviewed = markAsUnreviewed;
+    this.setWip = setWip;
+    this.setReady = setReady;
+    this.putMessage = putMessage;
+    this.getPureRevert = getPureRevert;
+    this.stars = stars;
     this.change = change;
   }
 
@@ -212,8 +262,8 @@
   public RevisionApi revision(String id) throws RestApiException {
     try {
       return revisionApi.create(revisions.parse(change, IdString.fromDecoded(id)));
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot parse revision", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse revision", e);
     }
   }
 
@@ -221,8 +271,8 @@
   public ReviewerApi reviewer(String id) throws RestApiException {
     try {
       return reviewerApi.create(reviewers.parse(change, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot parse reviewer", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse reviewer", e);
     }
   }
 
@@ -235,8 +285,8 @@
   public void abandon(AbandonInput in) throws RestApiException {
     try {
       abandon.apply(change, in);
-    } catch (OrmException | UpdateException e) {
-      throw new RestApiException("Cannot abandon change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot abandon change", e);
     }
   }
 
@@ -249,8 +299,8 @@
   public void restore(RestoreInput in) throws RestApiException {
     try {
       restore.apply(change, in);
-    } catch (OrmException | UpdateException e) {
-      throw new RestApiException("Cannot restore change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot restore change", e);
     }
   }
 
@@ -265,8 +315,40 @@
   public void move(MoveInput in) throws RestApiException {
     try {
       move.apply(change, in);
-    } catch (OrmException | UpdateException e) {
-      throw new RestApiException("Cannot move change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot move change", e);
+    }
+  }
+
+  @Override
+  public void setPrivate(boolean value, @Nullable String message) throws RestApiException {
+    try {
+      SetPrivateOp.Input input = new SetPrivateOp.Input(message);
+      if (value) {
+        postPrivate.apply(change, input);
+      } else {
+        deletePrivate.apply(change, input);
+      }
+    } catch (Exception e) {
+      throw asRestApiException("Cannot change private status", e);
+    }
+  }
+
+  @Override
+  public void setWorkInProgress(@Nullable String message) throws RestApiException {
+    try {
+      setWip.apply(change, new WorkInProgressOp.Input(message));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set work in progress state", e);
+    }
+  }
+
+  @Override
+  public void setReadyForReview(@Nullable String message) throws RestApiException {
+    try {
+      setReady.apply(change, new WorkInProgressOp.Input(message));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set ready for review state", e);
     }
   }
 
@@ -279,8 +361,8 @@
   public ChangeApi revert(RevertInput in) throws RestApiException {
     try {
       return changeApi.id(revert.apply(change, in)._number);
-    } catch (OrmException | IOException | UpdateException e) {
-      throw new RestApiException("Cannot revert change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot revert change", e);
     }
   }
 
@@ -288,8 +370,8 @@
   public ChangeInfo createMergePatchSet(MergePatchSetInput in) throws RestApiException {
     try {
       return updateByMerge.apply(change, in).value();
-    } catch (IOException | UpdateException | InvalidChangeOperationException | OrmException e) {
-      throw new RestApiException("Cannot update change by merge", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update change by merge", e);
     }
   }
 
@@ -317,18 +399,15 @@
           .addListChangesOption(listOptions)
           .addSubmittedTogetherOption(submitOptions)
           .applyInfo(change);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot query submittedTogether", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query submittedTogether", e);
     }
   }
 
+  @Deprecated
   @Override
   public void publish() throws RestApiException {
-    try {
-      publishDraftChange.apply(change, null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot publish change", e);
-    }
+    throw new UnsupportedOperationException("draft workflow is discontinued");
   }
 
   @Override
@@ -340,8 +419,8 @@
   public void rebase(RebaseInput in) throws RestApiException {
     try {
       rebase.apply(change, in);
-    } catch (EmailException | OrmException | UpdateException | IOException e) {
-      throw new RestApiException("Cannot rebase change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase change", e);
     }
   }
 
@@ -349,8 +428,8 @@
   public void delete() throws RestApiException {
     try {
       deleteChange.apply(change, null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete change", e);
     }
   }
 
@@ -365,8 +444,8 @@
     in.topic = topic;
     try {
       putTopic.apply(change, in);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot set topic", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set topic", e);
     }
   }
 
@@ -374,24 +453,24 @@
   public IncludedInInfo includedIn() throws RestApiException {
     try {
       return includedIn.apply(change);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Could not extract IncludedIn data", e);
+    } catch (Exception e) {
+      throw asRestApiException("Could not extract IncludedIn data", e);
     }
   }
 
   @Override
-  public void addReviewer(String reviewer) throws RestApiException {
+  public AddReviewerResult addReviewer(String reviewer) throws RestApiException {
     AddReviewerInput in = new AddReviewerInput();
     in.reviewer = reviewer;
-    addReviewer(in);
+    return addReviewer(in);
   }
 
   @Override
-  public void addReviewer(AddReviewerInput in) throws RestApiException {
+  public AddReviewerResult addReviewer(AddReviewerInput in) throws RestApiException {
     try {
-      postReviewers.apply(change, in);
-    } catch (OrmException | IOException | UpdateException e) {
-      throw new RestApiException("Cannot add change reviewer", e);
+      return postReviewers.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add change reviewer", e);
     }
   }
 
@@ -416,8 +495,17 @@
       suggestReviewers.setQuery(r.getQuery());
       suggestReviewers.setLimit(r.getLimit());
       return suggestReviewers.apply(change);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot retrieve suggested reviewers", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve suggested reviewers", e);
+    }
+  }
+
+  @Override
+  public List<ReviewerInfo> reviewers() throws RestApiException {
+    try {
+      return listReviewers.apply(change);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve reviewers", e);
     }
   }
 
@@ -425,8 +513,8 @@
   public ChangeInfo get(EnumSet<ListChangesOption> s) throws RestApiException {
     try {
       return changeJson.create(s).format(change);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve change", e);
     }
   }
 
@@ -446,6 +534,22 @@
   }
 
   @Override
+  public void setMessage(String msg) throws RestApiException {
+    CommitMessageInput in = new CommitMessageInput();
+    in.message = msg;
+    setMessage(in);
+  }
+
+  @Override
+  public void setMessage(CommitMessageInput in) throws RestApiException {
+    try {
+      putMessage.apply(change, in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot edit commit message", e);
+    }
+  }
+
+  @Override
   public ChangeInfo info() throws RestApiException {
     return get(EnumSet.noneOf(ListChangesOption.class));
   }
@@ -454,8 +558,8 @@
   public void setHashtags(HashtagsInput input) throws RestApiException {
     try {
       postHashtags.apply(change, input);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot post hashtags", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post hashtags", e);
     }
   }
 
@@ -463,17 +567,17 @@
   public Set<String> getHashtags() throws RestApiException {
     try {
       return getHashtags.apply(change).value();
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot get hashtags", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get hashtags", e);
     }
   }
 
   @Override
   public AccountInfo setAssignee(AssigneeInput input) throws RestApiException {
     try {
-      return putAssignee.apply(change, input).value();
-    } catch (UpdateException | IOException | OrmException e) {
-      throw new RestApiException("Cannot set assignee", e);
+      return putAssignee.apply(change, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set assignee", e);
     }
   }
 
@@ -482,8 +586,8 @@
     try {
       Response<AccountInfo> r = getAssignee.apply(change);
       return r.isNone() ? null : r.value();
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get assignee", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get assignee", e);
     }
   }
 
@@ -491,8 +595,8 @@
   public List<AccountInfo> getPastAssignees() throws RestApiException {
     try {
       return getPastAssignees.apply(change).value();
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get past assignees", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get past assignees", e);
     }
   }
 
@@ -501,8 +605,8 @@
     try {
       Response<AccountInfo> r = deleteAssignee.apply(change, null);
       return r.isNone() ? null : r.value();
-    } catch (UpdateException | OrmException e) {
-      throw new RestApiException("Cannot delete assignee", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete assignee", e);
     }
   }
 
@@ -510,8 +614,8 @@
   public Map<String, List<CommentInfo>> comments() throws RestApiException {
     try {
       return listComments.apply(change);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get comments", e);
     }
   }
 
@@ -519,8 +623,8 @@
   public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
     try {
       return listChangeRobotComments.apply(change);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get robot comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get robot comments", e);
     }
   }
 
@@ -528,8 +632,8 @@
   public Map<String, List<CommentInfo>> drafts() throws RestApiException {
     try {
       return listDrafts.apply(change);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get drafts", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get drafts", e);
     }
   }
 
@@ -537,17 +641,19 @@
   public ChangeInfo check() throws RestApiException {
     try {
       return check.apply(change).value();
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot check change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check change", e);
     }
   }
 
   @Override
   public ChangeInfo check(FixInput fix) throws RestApiException {
     try {
+      // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
+      // ConsistencyChecker.
       return check.apply(change, fix).value();
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot check change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check change", e);
     }
   }
 
@@ -555,8 +661,62 @@
   public void index() throws RestApiException {
     try {
       index.apply(change, new Index.Input());
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot index change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index change", e);
+    }
+  }
+
+  @Override
+  public void ignore(boolean ignore) throws RestApiException {
+    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
+    // StarredChangesUtil.
+    try {
+      if (ignore) {
+        this.ignore.apply(change, new Ignore.Input());
+      } else {
+        unignore.apply(change, new Unignore.Input());
+      }
+    } catch (OrmException | IllegalLabelException e) {
+      throw asRestApiException("Cannot ignore change", e);
+    }
+  }
+
+  @Override
+  public boolean ignored() throws RestApiException {
+    try {
+      return stars.isIgnored(change);
+    } catch (OrmException e) {
+      throw asRestApiException("Cannot check if ignored", e);
+    }
+  }
+
+  @Override
+  public void markAsReviewed(boolean reviewed) throws RestApiException {
+    // TODO(dborowitz): Convert to RetryingRestModifyView. Needs to plumb BatchUpdate.Factory into
+    // StarredChangesUtil.
+    try {
+      if (reviewed) {
+        markAsReviewed.apply(change, new MarkAsReviewed.Input());
+      } else {
+        markAsUnreviewed.apply(change, new MarkAsUnreviewed.Input());
+      }
+    } catch (OrmException | IllegalLabelException e) {
+      throw asRestApiException(
+          "Cannot mark change as " + (reviewed ? "reviewed" : "unreviewed"), e);
+    }
+  }
+
+  @Override
+  public PureRevertInfo pureRevert() throws RestApiException {
+    return pureRevert(null);
+  }
+
+  @Override
+  public PureRevertInfo pureRevert(@Nullable String claimedOriginal) throws RestApiException {
+    try {
+      return getPureRevert.setClaimedOriginal(claimedOriginal).apply(change);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot compute pure revert", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
index 80d5071..d1b57e6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangeEditApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.ChangeEditApi;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.common.EditInfo;
@@ -30,7 +32,6 @@
 import com.google.gerrit.server.change.DeleteChangeEdit;
 import com.google.gerrit.server.change.PublishChangeEdit;
 import com.google.gerrit.server.change.RebaseChangeEdit;
-import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -88,8 +89,8 @@
     try {
       Response<EditInfo> edit = editDetail.apply(changeResource);
       return edit.isNone() ? Optional.empty() : Optional.of(edit.value());
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot retrieve change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve change edit", e);
     }
   }
 
@@ -97,8 +98,8 @@
   public void create() throws RestApiException {
     try {
       changeEditsPost.apply(changeResource, null);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot create change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change edit", e);
     }
   }
 
@@ -106,8 +107,8 @@
   public void delete() throws RestApiException {
     try {
       deleteChangeEdit.apply(changeResource, new DeleteChangeEdit.Input());
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot delete change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete change edit", e);
     }
   }
 
@@ -115,8 +116,8 @@
   public void rebase() throws RestApiException {
     try {
       rebaseChangeEdit.apply(changeResource, null);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot rebase change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase change edit", e);
     }
   }
 
@@ -129,8 +130,8 @@
   public void publish(PublishChangeEditInput publishChangeEditInput) throws RestApiException {
     try {
       publishChangeEdit.apply(changeResource, publishChangeEditInput);
-    } catch (IOException | OrmException | UpdateException e) {
-      throw new RestApiException("Cannot publish change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot publish change edit", e);
     }
   }
 
@@ -140,8 +141,8 @@
       ChangeEditResource changeEditResource = getChangeEditResource(filePath);
       Response<BinaryResult> fileResponse = changeEditsGet.apply(changeEditResource);
       return fileResponse.isNone() ? Optional.empty() : Optional.of(fileResponse.value());
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot retrieve file of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve file of change edit", e);
     }
   }
 
@@ -152,8 +153,8 @@
       renameInput.oldPath = oldFilePath;
       renameInput.newPath = newFilePath;
       changeEditsPost.apply(changeResource, renameInput);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot rename file of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rename file of change edit", e);
     }
   }
 
@@ -163,26 +164,26 @@
       ChangeEdits.Post.Input restoreInput = new ChangeEdits.Post.Input();
       restoreInput.restorePath = filePath;
       changeEditsPost.apply(changeResource, restoreInput);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot restore file of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot restore file of change edit", e);
     }
   }
 
   @Override
   public void modifyFile(String filePath, RawInput newContent) throws RestApiException {
     try {
-      changeEditsPut.apply(changeResource.getControl(), filePath, newContent);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot modify file of change edit", e);
+      changeEditsPut.apply(changeResource, filePath, newContent);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot modify file of change edit", e);
     }
   }
 
   @Override
   public void deleteFile(String filePath) throws RestApiException {
     try {
-      changeEditDeleteContent.apply(changeResource.getControl(), filePath);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot delete file of change edit", e);
+      changeEditDeleteContent.apply(changeResource, filePath);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete file of change edit", e);
     }
   }
 
@@ -192,8 +193,8 @@
       try (BinaryResult binaryResult = getChangeEditCommitMessage.apply(changeResource)) {
         return binaryResult.asString();
       }
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot get commit message of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get commit message of change edit", e);
     }
   }
 
@@ -203,8 +204,8 @@
     input.message = newCommitMessage;
     try {
       modifyChangeEditCommitMessage.apply(changeResource, input);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot modify commit message of change edit", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot modify commit message of change edit", e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
index c77f86f..cc39883 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ChangesImpl.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -24,7 +25,6 @@
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -32,14 +32,10 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.change.CreateChange;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.query.change.QueryChanges;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.List;
 
 @Singleton
@@ -77,18 +73,24 @@
   public ChangeApi id(String id) throws RestApiException {
     try {
       return api.create(changes.parse(TopLevelResource.INSTANCE, IdString.fromUrl(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot parse change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse change", e);
     }
   }
 
   @Override
+  public ChangeApi id(String project, int id) throws RestApiException {
+    return id(
+        Joiner.on('~').join(ImmutableList.of(Url.encode(project), Url.encode(String.valueOf(id)))));
+  }
+
+  @Override
   public ChangeApi create(ChangeInput in) throws RestApiException {
     try {
       ChangeInfo out = createChange.apply(TopLevelResource.INSTANCE, in).value();
       return api.create(changes.parse(new Change.Id(out._number)));
-    } catch (OrmException | IOException | InvalidChangeOperationException | UpdateException e) {
-      throw new RestApiException("Cannot create change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create change", e);
     }
   }
 
@@ -107,7 +109,7 @@
     return query().withQuery(query);
   }
 
-  private List<ChangeInfo> get(final QueryRequest q) throws RestApiException {
+  private List<ChangeInfo> get(QueryRequest q) throws RestApiException {
     QueryChanges qc = queryProvider.get();
     if (q.getQuery() != null) {
       qc.addQuery(q.getQuery());
@@ -132,8 +134,8 @@
       List<ChangeInfo> infos = (List<ChangeInfo>) result;
 
       return ImmutableList.copyOf(infos);
-    } catch (AuthException | OrmException e) {
-      throw new RestApiException("Cannot query changes", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query changes", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
index 5c61e23..6a2501e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/CommentApiImpl.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.CommentApi;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.CommentResource;
+import com.google.gerrit.server.change.DeleteComment;
 import com.google.gerrit.server.change.GetComment;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -29,11 +32,14 @@
   }
 
   private final GetComment getComment;
+  private final DeleteComment deleteComment;
   private final CommentResource comment;
 
   @Inject
-  CommentApiImpl(GetComment getComment, @Assisted CommentResource comment) {
+  CommentApiImpl(
+      GetComment getComment, DeleteComment deleteComment, @Assisted CommentResource comment) {
     this.getComment = getComment;
+    this.deleteComment = deleteComment;
     this.comment = comment;
   }
 
@@ -41,8 +47,17 @@
   public CommentInfo get() throws RestApiException {
     try {
       return getComment.apply(comment);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve comment", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comment", e);
+    }
+  }
+
+  @Override
+  public CommentInfo delete(DeleteCommentInput input) throws RestApiException {
+    try {
+      return deleteComment.apply(comment, input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete comment", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
index 1bd9216..eada51b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/DraftApiImpl.java
@@ -14,16 +14,18 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.DeleteDraftComment;
 import com.google.gerrit.server.change.DraftCommentResource;
 import com.google.gerrit.server.change.GetDraftComment;
 import com.google.gerrit.server.change.PutDraftComment;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -53,8 +55,8 @@
   public CommentInfo get() throws RestApiException {
     try {
       return getDraft.apply(draft);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve draft", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve draft", e);
     }
   }
 
@@ -62,8 +64,8 @@
   public CommentInfo update(DraftInput in) throws RestApiException {
     try {
       return putDraft.apply(draft, in).value();
-    } catch (UpdateException | OrmException e) {
-      throw new RestApiException("Cannot update draft", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot update draft", e);
     }
   }
 
@@ -71,8 +73,13 @@
   public void delete() throws RestApiException {
     try {
       deleteDraft.apply(draft, null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete draft", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete draft", e);
     }
   }
+
+  @Override
+  public CommentInfo delete(DeleteCommentInput input) {
+    throw new NotImplementedException();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
index aa66e7b..f51cdac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/FileApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.common.DiffInfo;
 import com.google.gerrit.extensions.restapi.BinaryResult;
@@ -21,11 +23,8 @@
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.GetContent;
 import com.google.gerrit.server.change.GetDiff;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 
 class FileApiImpl implements FileApi {
   interface Factory {
@@ -47,8 +46,8 @@
   public BinaryResult content() throws RestApiException {
     try {
       return getContent.apply(file);
-    } catch (IOException | OrmException e) {
-      throw new RestApiException("Cannot retrieve file content", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve file content", e);
     }
   }
 
@@ -56,8 +55,8 @@
   public DiffInfo diff() throws RestApiException {
     try {
       return getDiff.apply(file).value();
-    } catch (IOException | InvalidChangeOperationException | OrmException e) {
-      throw new RestApiException("Cannot retrieve diff", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
     }
   }
 
@@ -65,8 +64,8 @@
   public DiffInfo diff(String base) throws RestApiException {
     try {
       return getDiff.setBase(base).apply(file).value();
-    } catch (IOException | InvalidChangeOperationException | OrmException e) {
-      throw new RestApiException("Cannot retrieve diff", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
     }
   }
 
@@ -74,8 +73,8 @@
   public DiffInfo diff(int parent) throws RestApiException {
     try {
       return getDiff.setParent(parent).apply(file).value();
-    } catch (OrmException | InvalidChangeOperationException | IOException e) {
-      throw new RestApiException("Cannot retrieve diff", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
     }
   }
 
@@ -102,10 +101,11 @@
     if (r.getWhitespace() != null) {
       getDiff.setWhitespace(r.getWhitespace());
     }
+    r.getParent().ifPresent(getDiff::setParent);
     try {
       return getDiff.apply(file).value();
-    } catch (IOException | InvalidChangeOperationException | OrmException e) {
-      throw new RestApiException("Cannot retrieve diff", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve diff", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
index 8ac874a..2f8b7d8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/ReviewerApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.ReviewerApi;
@@ -23,8 +25,6 @@
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.change.Votes;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Map;
@@ -55,8 +55,8 @@
   public Map<String, Short> votes() throws RestApiException {
     try {
       return listVotes.apply(reviewer);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot list votes", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list votes", e);
     }
   }
 
@@ -64,8 +64,8 @@
   public void deleteVote(String label) throws RestApiException {
     try {
       deleteVote.apply(new VoteResource(reviewer, label), null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete vote", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
     }
   }
 
@@ -73,8 +73,8 @@
   public void deleteVote(DeleteVoteInput input) throws RestApiException {
     try {
       deleteVote.apply(new VoteResource(reviewer, input.label), input);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete vote", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
     }
   }
 
@@ -87,8 +87,8 @@
   public void remove(DeleteReviewerInput input) throws RestApiException {
     try {
       deleteReviewer.apply(reviewer, input);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot remove reviewer", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove reviewer", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index 43be8df..65bbc47 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.Changes;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
@@ -25,6 +27,7 @@
 import com.google.gerrit.extensions.api.changes.FileApi;
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
 import com.google.gerrit.extensions.api.changes.RevisionReviewerApi;
 import com.google.gerrit.extensions.api.changes.RobotCommentApi;
@@ -33,6 +36,7 @@
 import com.google.gerrit.extensions.common.ActionInfo;
 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;
@@ -41,13 +45,15 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.change.ApplyFix;
 import com.google.gerrit.server.change.CherryPick;
 import com.google.gerrit.server.change.Comments;
 import com.google.gerrit.server.change.CreateDraftComment;
-import com.google.gerrit.server.change.DeleteDraftPatchSet;
 import com.google.gerrit.server.change.DraftComments;
 import com.google.gerrit.server.change.FileResource;
 import com.google.gerrit.server.change.Files;
+import com.google.gerrit.server.change.Fixes;
+import com.google.gerrit.server.change.GetCommit;
 import com.google.gerrit.server.change.GetDescription;
 import com.google.gerrit.server.change.GetMergeList;
 import com.google.gerrit.server.change.GetPatch;
@@ -58,7 +64,6 @@
 import com.google.gerrit.server.change.Mergeable;
 import com.google.gerrit.server.change.PostReview;
 import com.google.gerrit.server.change.PreviewSubmit;
-import com.google.gerrit.server.change.PublishDraftPatchSet;
 import com.google.gerrit.server.change.PutDescription;
 import com.google.gerrit.server.change.Rebase;
 import com.google.gerrit.server.change.RebaseUtil;
@@ -69,13 +74,9 @@
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.change.TestSubmitType;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -92,23 +93,24 @@
   private final RevisionReviewers revisionReviewers;
   private final RevisionReviewerApiImpl.Factory revisionReviewerApi;
   private final CherryPick cherryPick;
-  private final DeleteDraftPatchSet deleteDraft;
   private final Rebase rebase;
   private final RebaseUtil rebaseUtil;
   private final Submit submit;
   private final PreviewSubmit submitPreview;
-  private final PublishDraftPatchSet publish;
   private final Reviewed.PutReviewed putReviewed;
   private final Reviewed.DeleteReviewed deleteReviewed;
   private final RevisionResource revision;
   private final Files files;
   private final Files.ListFiles listFiles;
+  private final GetCommit getCommit;
   private final GetPatch getPatch;
   private final PostReview review;
   private final Mergeable mergeable;
   private final FileApiImpl.Factory fileApi;
   private final ListRevisionComments listComments;
   private final ListRobotComments listRobotComments;
+  private final ApplyFix applyFix;
+  private final Fixes fixes;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
   private final DraftComments drafts;
@@ -131,22 +133,23 @@
       RevisionReviewers revisionReviewers,
       RevisionReviewerApiImpl.Factory revisionReviewerApi,
       CherryPick cherryPick,
-      DeleteDraftPatchSet deleteDraft,
       Rebase rebase,
       RebaseUtil rebaseUtil,
       Submit submit,
       PreviewSubmit submitPreview,
-      PublishDraftPatchSet publish,
       Reviewed.PutReviewed putReviewed,
       Reviewed.DeleteReviewed deleteReviewed,
       Files files,
       Files.ListFiles listFiles,
+      GetCommit getCommit,
       GetPatch getPatch,
       PostReview review,
       Mergeable mergeable,
       FileApiImpl.Factory fileApi,
       ListRevisionComments listComments,
       ListRobotComments listRobotComments,
+      ApplyFix applyFix,
+      Fixes fixes,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
       DraftComments drafts,
@@ -167,23 +170,24 @@
     this.revisionReviewers = revisionReviewers;
     this.revisionReviewerApi = revisionReviewerApi;
     this.cherryPick = cherryPick;
-    this.deleteDraft = deleteDraft;
     this.rebase = rebase;
     this.rebaseUtil = rebaseUtil;
     this.review = review;
     this.submit = submit;
     this.submitPreview = submitPreview;
-    this.publish = publish;
     this.files = files;
     this.putReviewed = putReviewed;
     this.deleteReviewed = deleteReviewed;
     this.listFiles = listFiles;
+    this.getCommit = getCommit;
     this.getPatch = getPatch;
     this.mergeable = mergeable;
     this.fileApi = fileApi;
     this.listComments = listComments;
     this.robotComments = robotComments;
     this.listRobotComments = listRobotComments;
+    this.applyFix = applyFix;
+    this.fixes = fixes;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
     this.drafts = drafts;
@@ -201,11 +205,11 @@
   }
 
   @Override
-  public void review(ReviewInput in) throws RestApiException {
+  public ReviewResult review(ReviewInput in) throws RestApiException {
     try {
-      review.apply(revision, in);
-    } catch (OrmException | UpdateException | IOException e) {
-      throw new RestApiException("Cannot post review", e);
+      return review.apply(revision, in).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot post review", e);
     }
   }
 
@@ -219,8 +223,8 @@
   public void submit(SubmitInput in) throws RestApiException {
     try {
       submit.apply(revision, in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot submit change", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot submit change", e);
     }
   }
 
@@ -234,27 +238,19 @@
     try {
       submitPreview.setFormat(format);
       return submitPreview.apply(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get submit preview", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit preview", e);
     }
   }
 
   @Override
   public void publish() throws RestApiException {
-    try {
-      publish.apply(revision, new PublishDraftPatchSet.Input());
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot publish draft patch set", e);
-    }
+    throw new UnsupportedOperationException("draft workflow is discontinued");
   }
 
   @Override
   public void delete() throws RestApiException {
-    try {
-      deleteDraft.apply(revision, null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete draft ps", e);
-    }
+    throw new UnsupportedOperationException("draft workflow is discontinued");
   }
 
   @Override
@@ -267,8 +263,8 @@
   public ChangeApi rebase(RebaseInput in) throws RestApiException {
     try {
       return changes.id(rebase.apply(revision, in)._number);
-    } catch (OrmException | EmailException | UpdateException | IOException e) {
-      throw new RestApiException("Cannot rebase ps", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot rebase ps", e);
     }
   }
 
@@ -277,8 +273,8 @@
     try (Repository repo = repoManager.openRepository(revision.getProject());
         RevWalk rw = new RevWalk(repo)) {
       return rebaseUtil.canRebase(revision.getPatchSet(), revision.getChange().getDest(), repo, rw);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot check if rebase is possible", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check if rebase is possible", e);
     }
   }
 
@@ -286,8 +282,8 @@
   public ChangeApi cherryPick(CherryPickInput in) throws RestApiException {
     try {
       return changes.id(cherryPick.apply(revision, in)._number);
-    } catch (OrmException | IOException | UpdateException e) {
-      throw new RestApiException("Cannot cherry pick", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot cherry pick", e);
     }
   }
 
@@ -296,8 +292,8 @@
     try {
       return revisionReviewerApi.create(
           revisionReviewers.parse(revision, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot parse reviewer", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse reviewer", e);
     }
   }
 
@@ -312,7 +308,7 @@
       }
       view.apply(files.parse(revision, IdString.fromDecoded(path)), new Reviewed.Input());
     } catch (Exception e) {
-      throw new RestApiException("Cannot update reviewed flag", e);
+      throw asRestApiException("Cannot update reviewed flag", e);
     }
   }
 
@@ -322,8 +318,8 @@
     try {
       return ImmutableSet.copyOf(
           (Iterable<String>) listFiles.setReviewed(true).apply(revision).value());
-    } catch (OrmException | IOException | PatchListNotAvailableException e) {
-      throw new RestApiException("Cannot list reviewed files", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list reviewed files", e);
     }
   }
 
@@ -331,8 +327,8 @@
   public MergeableInfo mergeable() throws RestApiException {
     try {
       return mergeable.apply(revision);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot check mergeability", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check mergeability", e);
     }
   }
 
@@ -341,8 +337,8 @@
     try {
       mergeable.setOtherBranches(true);
       return mergeable.apply(revision);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot check mergeability", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check mergeability", e);
     }
   }
 
@@ -351,8 +347,8 @@
   public Map<String, FileInfo> files() throws RestApiException {
     try {
       return (Map<String, FileInfo>) listFiles.apply(revision).value();
-    } catch (OrmException | IOException | PatchListNotAvailableException e) {
-      throw new RestApiException("Cannot retrieve files", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
     }
   }
 
@@ -361,8 +357,8 @@
   public Map<String, FileInfo> files(String base) throws RestApiException {
     try {
       return (Map<String, FileInfo>) listFiles.setBase(base).apply(revision).value();
-    } catch (OrmException | IOException | PatchListNotAvailableException e) {
-      throw new RestApiException("Cannot retrieve files", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
     }
   }
 
@@ -371,8 +367,19 @@
   public Map<String, FileInfo> files(int parentNum) throws RestApiException {
     try {
       return (Map<String, FileInfo>) listFiles.setParent(parentNum).apply(revision).value();
-    } catch (OrmException | IOException | PatchListNotAvailableException e) {
-      throw new RestApiException("Cannot retrieve files", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
+    }
+  }
+
+  @SuppressWarnings("unchecked")
+  @Override
+  public List<String> queryFiles(String query) throws RestApiException {
+    try {
+      checkArgument(query != null, "no query provided");
+      return (List<String>) listFiles.setQuery(query).apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve files", e);
     }
   }
 
@@ -382,11 +389,20 @@
   }
 
   @Override
+  public CommitInfo commit(boolean addLinks) throws RestApiException {
+    try {
+      return getCommit.setAddLinks(addLinks).apply(revision).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve commit", e);
+    }
+  }
+
+  @Override
   public Map<String, List<CommentInfo>> comments() throws RestApiException {
     try {
       return listComments.apply(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comments", e);
     }
   }
 
@@ -394,8 +410,8 @@
   public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
     try {
       return listRobotComments.apply(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve robot comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comments", e);
     }
   }
 
@@ -403,8 +419,8 @@
   public List<CommentInfo> commentsAsList() throws RestApiException {
     try {
       return listComments.getComments(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comments", e);
     }
   }
 
@@ -412,8 +428,8 @@
   public Map<String, List<CommentInfo>> drafts() throws RestApiException {
     try {
       return listDrafts.apply(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve drafts", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve drafts", e);
     }
   }
 
@@ -421,8 +437,17 @@
   public List<RobotCommentInfo> robotCommentsAsList() throws RestApiException {
     try {
       return listRobotComments.getComments(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve robot comments", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comments", e);
+    }
+  }
+
+  @Override
+  public EditInfo applyFix(String fixId) throws RestApiException {
+    try {
+      return applyFix.apply(fixes.parse(revision, IdString.fromDecoded(fixId)), null).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot apply fix", e);
     }
   }
 
@@ -430,8 +455,8 @@
   public List<CommentInfo> draftsAsList() throws RestApiException {
     try {
       return listDrafts.getComments(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve drafts", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve drafts", e);
     }
   }
 
@@ -439,8 +464,8 @@
   public DraftApi draft(String id) throws RestApiException {
     try {
       return draftFactory.create(drafts.parse(revision, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve draft", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve draft", e);
     }
   }
 
@@ -453,8 +478,8 @@
           .id(revision.getChange().getId().get())
           .revision(revision.getPatchSet().getId().get())
           .draft(id);
-    } catch (UpdateException | OrmException e) {
-      throw new RestApiException("Cannot create draft", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create draft", e);
     }
   }
 
@@ -462,8 +487,8 @@
   public CommentApi comment(String id) throws RestApiException {
     try {
       return commentFactory.create(comments.parse(revision, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve comment", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve comment", e);
     }
   }
 
@@ -471,8 +496,8 @@
   public RobotCommentApi robotComment(String id) throws RestApiException {
     try {
       return robotCommentFactory.create(robotComments.parse(revision, IdString.fromDecoded(id)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve robot comment", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comment", e);
     }
   }
 
@@ -480,8 +505,8 @@
   public BinaryResult patch() throws RestApiException {
     try {
       return getPatch.apply(revision);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get patch", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get patch", e);
     }
   }
 
@@ -489,8 +514,8 @@
   public BinaryResult patch(String path) throws RestApiException {
     try {
       return getPatch.setPath(path).apply(revision);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get patch", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get patch", e);
     }
   }
 
@@ -498,8 +523,8 @@
   public Map<String, ActionInfo> actions() throws RestApiException {
     try {
       return revisionActions.apply(revision).value();
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get actions", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get actions", e);
     }
   }
 
@@ -507,8 +532,8 @@
   public SubmitType submitType() throws RestApiException {
     try {
       return getSubmitType.apply(revision);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get submit type", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get submit type", e);
     }
   }
 
@@ -516,8 +541,8 @@
   public SubmitType testSubmitType(TestSubmitRuleInput in) throws RestApiException {
     try {
       return testSubmitType.apply(revision, in);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot test submit type", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot test submit type", e);
     }
   }
 
@@ -531,8 +556,8 @@
           gml.setUninterestingParent(getUninterestingParent());
           gml.setAddLinks(getAddLinks());
           return gml.apply(revision).value();
-        } catch (IOException e) {
-          throw new RestApiException("Cannot get merge list", e);
+        } catch (Exception e) {
+          throw asRestApiException("Cannot get merge list", e);
         }
       }
     };
@@ -544,8 +569,8 @@
     in.description = description;
     try {
       putDescription.apply(revision, in);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot set description", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set description", e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
index 5c56321..60dc1d2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionReviewerApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.DeleteVoteInput;
 import com.google.gerrit.extensions.api.changes.RevisionReviewerApi;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -21,8 +23,6 @@
 import com.google.gerrit.server.change.ReviewerResource;
 import com.google.gerrit.server.change.VoteResource;
 import com.google.gerrit.server.change.Votes;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.util.Map;
@@ -48,8 +48,8 @@
   public Map<String, Short> votes() throws RestApiException {
     try {
       return listVotes.apply(reviewer);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot list votes", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list votes", e);
     }
   }
 
@@ -57,8 +57,8 @@
   public void deleteVote(String label) throws RestApiException {
     try {
       deleteVote.apply(new VoteResource(reviewer, label), null);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete vote", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
     }
   }
 
@@ -66,8 +66,8 @@
   public void deleteVote(DeleteVoteInput input) throws RestApiException {
     try {
       deleteVote.apply(new VoteResource(reviewer, input.label), input);
-    } catch (UpdateException e) {
-      throw new RestApiException("Cannot delete vote", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete vote", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
index ded98cb..b19939b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
@@ -14,12 +14,13 @@
 
 package com.google.gerrit.server.api.changes;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.changes.RobotCommentApi;
 import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.change.GetRobotComment;
 import com.google.gerrit.server.change.RobotCommentResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 
@@ -41,8 +42,8 @@
   public RobotCommentInfo get() throws RestApiException {
     try {
       return getComment.apply(comment);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve robot comment", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve robot comment", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
index 9b6ead0..2148d97 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/config/ServerImpl.java
@@ -14,12 +14,17 @@
 
 package com.google.gerrit.server.api.config;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.common.Version;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
 import com.google.gerrit.extensions.api.config.Server;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.common.ServerInfo;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.config.CheckConsistency;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.GetDiffPreferences;
 import com.google.gerrit.server.config.GetPreferences;
@@ -27,9 +32,8 @@
 import com.google.gerrit.server.config.SetDiffPreferences;
 import com.google.gerrit.server.config.SetPreferences;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class ServerImpl implements Server {
@@ -38,6 +42,7 @@
   private final GetDiffPreferences getDiffPreferences;
   private final SetDiffPreferences setDiffPreferences;
   private final GetServerInfo getServerInfo;
+  private final Provider<CheckConsistency> checkConsistency;
 
   @Inject
   ServerImpl(
@@ -45,12 +50,14 @@
       SetPreferences setPreferences,
       GetDiffPreferences getDiffPreferences,
       SetDiffPreferences setDiffPreferences,
-      GetServerInfo getServerInfo) {
+      GetServerInfo getServerInfo,
+      Provider<CheckConsistency> checkConsistency) {
     this.getPreferences = getPreferences;
     this.setPreferences = setPreferences;
     this.getDiffPreferences = getDiffPreferences;
     this.setDiffPreferences = setDiffPreferences;
     this.getServerInfo = getServerInfo;
+    this.checkConsistency = checkConsistency;
   }
 
   @Override
@@ -62,8 +69,8 @@
   public ServerInfo getInfo() throws RestApiException {
     try {
       return getServerInfo.apply(new ConfigResource());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get server info", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get server info", e);
     }
   }
 
@@ -71,8 +78,8 @@
   public GeneralPreferencesInfo getDefaultPreferences() throws RestApiException {
     try {
       return getPreferences.apply(new ConfigResource());
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot get default general preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get default general preferences", e);
     }
   }
 
@@ -81,8 +88,8 @@
       throws RestApiException {
     try {
       return setPreferences.apply(new ConfigResource(), in);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot set default general preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set default general preferences", e);
     }
   }
 
@@ -90,8 +97,8 @@
   public DiffPreferencesInfo getDefaultDiffPreferences() throws RestApiException {
     try {
       return getDiffPreferences.apply(new ConfigResource());
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot get default diff preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get default diff preferences", e);
     }
   }
 
@@ -100,8 +107,17 @@
       throws RestApiException {
     try {
       return setDiffPreferences.apply(new ConfigResource(), in);
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot set default diff preferences", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set default diff preferences", e);
+    }
+  }
+
+  @Override
+  public ConsistencyCheckInfo checkConsistency(ConsistencyCheckInput in) throws RestApiException {
+    try {
+      return checkConsistency.get().apply(new ConfigResource(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check consistency", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
index 15120d2..42213f7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupApiImpl.java
@@ -14,18 +14,18 @@
 
 package com.google.gerrit.server.api.groups;
 
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.server.group.AddIncludedGroups;
 import com.google.gerrit.server.group.AddMembers;
-import com.google.gerrit.server.group.DeleteIncludedGroups;
+import com.google.gerrit.server.group.AddSubgroups;
 import com.google.gerrit.server.group.DeleteMembers;
+import com.google.gerrit.server.group.DeleteSubgroups;
 import com.google.gerrit.server.group.GetAuditLog;
 import com.google.gerrit.server.group.GetDescription;
 import com.google.gerrit.server.group.GetDetail;
@@ -35,16 +35,14 @@
 import com.google.gerrit.server.group.GetOwner;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.Index;
-import com.google.gerrit.server.group.ListIncludedGroups;
 import com.google.gerrit.server.group.ListMembers;
+import com.google.gerrit.server.group.ListSubgroups;
 import com.google.gerrit.server.group.PutDescription;
 import com.google.gerrit.server.group.PutName;
 import com.google.gerrit.server.group.PutOptions;
 import com.google.gerrit.server.group.PutOwner;
-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 java.util.Arrays;
 import java.util.List;
 
@@ -66,14 +64,14 @@
   private final ListMembers listMembers;
   private final AddMembers addMembers;
   private final DeleteMembers deleteMembers;
-  private final ListIncludedGroups listGroups;
-  private final AddIncludedGroups addGroups;
-  private final DeleteIncludedGroups deleteGroups;
+  private final ListSubgroups listSubgroups;
+  private final AddSubgroups addSubgroups;
+  private final DeleteSubgroups deleteSubgroups;
   private final GetAuditLog getAuditLog;
   private final GroupResource rsrc;
   private final Index index;
 
-  @AssistedInject
+  @Inject
   GroupApiImpl(
       GetGroup getGroup,
       GetDetail getDetail,
@@ -88,9 +86,9 @@
       ListMembers listMembers,
       AddMembers addMembers,
       DeleteMembers deleteMembers,
-      ListIncludedGroups listGroups,
-      AddIncludedGroups addGroups,
-      DeleteIncludedGroups deleteGroups,
+      ListSubgroups listSubgroups,
+      AddSubgroups addSubgroups,
+      DeleteSubgroups deleteSubgroups,
       GetAuditLog getAuditLog,
       Index index,
       @Assisted GroupResource rsrc) {
@@ -107,9 +105,9 @@
     this.listMembers = listMembers;
     this.addMembers = addMembers;
     this.deleteMembers = deleteMembers;
-    this.listGroups = listGroups;
-    this.addGroups = addGroups;
-    this.deleteGroups = deleteGroups;
+    this.listSubgroups = listSubgroups;
+    this.addSubgroups = addSubgroups;
+    this.deleteSubgroups = deleteSubgroups;
     this.getAuditLog = getAuditLog;
     this.index = index;
     this.rsrc = rsrc;
@@ -119,8 +117,8 @@
   public GroupInfo get() throws RestApiException {
     try {
       return getGroup.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve group", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve group", e);
     }
   }
 
@@ -128,8 +126,8 @@
   public GroupInfo detail() throws RestApiException {
     try {
       return getDetail.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot retrieve group", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve group", e);
     }
   }
 
@@ -144,10 +142,8 @@
     in.name = name;
     try {
       putName.apply(rsrc, in);
-    } catch (NoSuchGroupException e) {
-      throw new ResourceNotFoundException(name, e);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot put group name", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group name", e);
     }
   }
 
@@ -155,8 +151,8 @@
   public GroupInfo owner() throws RestApiException {
     try {
       return getOwner.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get group owner", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get group owner", e);
     }
   }
 
@@ -166,8 +162,8 @@
     in.owner = owner;
     try {
       putOwner.apply(rsrc, in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot put group owner", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group owner", e);
     }
   }
 
@@ -182,8 +178,8 @@
     in.description = description;
     try {
       putDescription.apply(rsrc, in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot put group description", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group description", e);
     }
   }
 
@@ -196,8 +192,8 @@
   public void options(GroupOptionsInfo options) throws RestApiException {
     try {
       putOptions.apply(rsrc, options);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot put group options", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put group options", e);
     }
   }
 
@@ -211,8 +207,8 @@
     listMembers.setRecursive(recursive);
     try {
       return listMembers.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot list group members", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list group members", e);
     }
   }
 
@@ -220,8 +216,8 @@
   public void addMembers(String... members) throws RestApiException {
     try {
       addMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot add group members", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add group members", e);
     }
   }
 
@@ -229,35 +225,35 @@
   public void removeMembers(String... members) throws RestApiException {
     try {
       deleteMembers.apply(rsrc, AddMembers.Input.fromMembers(Arrays.asList(members)));
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot remove group members", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove group members", e);
     }
   }
 
   @Override
   public List<GroupInfo> includedGroups() throws RestApiException {
     try {
-      return listGroups.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot list included groups", e);
+      return listSubgroups.apply(rsrc);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list subgroups", e);
     }
   }
 
   @Override
   public void addGroups(String... groups) throws RestApiException {
     try {
-      addGroups.apply(rsrc, AddIncludedGroups.Input.fromGroups(Arrays.asList(groups)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot add group members", e);
+      addSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(Arrays.asList(groups)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot add subgroups", e);
     }
   }
 
   @Override
   public void removeGroups(String... groups) throws RestApiException {
     try {
-      deleteGroups.apply(rsrc, AddIncludedGroups.Input.fromGroups(Arrays.asList(groups)));
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot remove group members", e);
+      deleteSubgroups.apply(rsrc, AddSubgroups.Input.fromGroups(Arrays.asList(groups)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove subgroups", e);
     }
   }
 
@@ -265,8 +261,8 @@
   public List<? extends GroupAuditEventInfo> auditLog() throws RestApiException {
     try {
       return getAuditLog.apply(rsrc);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot get audit log", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get audit log", e);
     }
   }
 
@@ -274,8 +270,8 @@
   public void index() throws RestApiException {
     try {
       index.apply(rsrc, new Index.Input());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot index group", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot index group", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
index 1d725a8..e1e72ba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/groups/GroupsImpl.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.api.groups;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
 
 import com.google.gerrit.extensions.api.groups.GroupApi;
 import com.google.gerrit.extensions.api.groups.GroupInput;
@@ -32,12 +32,12 @@
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.ListGroups;
 import com.google.gerrit.server.group.QueryGroups;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.List;
 import java.util.SortedMap;
 
@@ -49,6 +49,7 @@
   private final Provider<ListGroups> listGroups;
   private final Provider<QueryGroups> queryGroups;
   private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
   private final CreateGroup.Factory createGroup;
   private final GroupApiImpl.Factory api;
 
@@ -60,6 +61,7 @@
       Provider<ListGroups> listGroups,
       Provider<QueryGroups> queryGroups,
       Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
       CreateGroup.Factory createGroup,
       GroupApiImpl.Factory api) {
     this.accounts = accounts;
@@ -68,6 +70,7 @@
     this.listGroups = listGroups;
     this.queryGroups = queryGroups;
     this.user = user;
+    this.permissionBackend = permissionBackend;
     this.createGroup = createGroup;
     this.api = api;
   }
@@ -89,12 +92,13 @@
     if (checkNotNull(in, "GroupInput").name == null) {
       throw new BadRequestException("GroupInput must specify name");
     }
-    checkRequiresCapability(user, null, CreateGroup.class);
     try {
-      GroupInfo info = createGroup.create(in.name).apply(TopLevelResource.INSTANCE, in);
+      CreateGroup impl = createGroup.create(in.name);
+      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      GroupInfo info = impl.apply(TopLevelResource.INSTANCE, in);
       return id(info.id);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot create group " + in.name, e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create group " + in.name, e);
     }
   }
 
@@ -116,8 +120,8 @@
     for (String project : req.getProjects()) {
       try {
         list.addProject(projects.parse(tlr, IdString.fromDecoded(project)).getControl());
-      } catch (IOException e) {
-        throw new RestApiException("Error looking up project " + project, e);
+      } catch (Exception e) {
+        throw asRestApiException("Error looking up project " + project, e);
       }
     }
 
@@ -130,8 +134,8 @@
     if (req.getUser() != null) {
       try {
         list.setUser(accounts.parse(req.getUser()).getAccountId());
-      } catch (OrmException e) {
-        throw new RestApiException("Error looking up user " + req.getUser(), e);
+      } catch (Exception e) {
+        throw asRestApiException("Error looking up user " + req.getUser(), e);
       }
     }
 
@@ -139,11 +143,12 @@
     list.setLimit(req.getLimit());
     list.setStart(req.getStart());
     list.setMatchSubstring(req.getSubstring());
+    list.setMatchRegex(req.getRegex());
     list.setSuggest(req.getSuggest());
     try {
       return list.apply(tlr);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot list groups", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list groups", e);
     }
   }
 
@@ -172,8 +177,8 @@
         myQueryGroups.addOption(option);
       }
       return myQueryGroups.apply(TopLevelResource.INSTANCE);
-    } catch (OrmException e) {
-      throw new RestApiException("Cannot query groups", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot query groups", e);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
new file mode 100644
index 0000000..2fc2e50
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginApiImpl.java
@@ -0,0 +1,72 @@
+// 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.api.plugins;
+
+import com.google.gerrit.extensions.api.plugins.PluginApi;
+import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.plugins.DisablePlugin;
+import com.google.gerrit.server.plugins.EnablePlugin;
+import com.google.gerrit.server.plugins.GetStatus;
+import com.google.gerrit.server.plugins.PluginResource;
+import com.google.gerrit.server.plugins.ReloadPlugin;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class PluginApiImpl implements PluginApi {
+  public interface Factory {
+    PluginApiImpl create(PluginResource resource);
+  }
+
+  private final GetStatus getStatus;
+  private final EnablePlugin enable;
+  private final DisablePlugin disable;
+  private final ReloadPlugin reload;
+  private final PluginResource resource;
+
+  @Inject
+  PluginApiImpl(
+      GetStatus getStatus,
+      EnablePlugin enable,
+      DisablePlugin disable,
+      ReloadPlugin reload,
+      @Assisted PluginResource resource) {
+    this.getStatus = getStatus;
+    this.enable = enable;
+    this.disable = disable;
+    this.reload = reload;
+    this.resource = resource;
+  }
+
+  @Override
+  public PluginInfo get() throws RestApiException {
+    return getStatus.apply(resource);
+  }
+
+  @Override
+  public void enable() throws RestApiException {
+    enable.apply(resource, new EnablePlugin.Input());
+  }
+
+  @Override
+  public void disable() throws RestApiException {
+    disable.apply(resource, new DisablePlugin.Input());
+  }
+
+  @Override
+  public void reload() throws RestApiException {
+    reload.apply(resource, new ReloadPlugin.Input());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginsImpl.java
new file mode 100644
index 0000000..fb2fb27
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/plugins/PluginsImpl.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.server.api.plugins;
+
+import com.google.gerrit.extensions.api.plugins.PluginApi;
+import com.google.gerrit.extensions.api.plugins.Plugins;
+import com.google.gerrit.extensions.common.InstallPluginInput;
+import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.plugins.InstallPlugin;
+import com.google.gerrit.server.plugins.ListPlugins;
+import com.google.gerrit.server.plugins.PluginsCollection;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.SortedMap;
+
+@Singleton
+public class PluginsImpl implements Plugins {
+  private final PluginsCollection plugins;
+  private final Provider<ListPlugins> listProvider;
+  private final Provider<InstallPlugin> installProvider;
+  private final PluginApiImpl.Factory pluginApi;
+
+  @Inject
+  PluginsImpl(
+      PluginsCollection plugins,
+      Provider<ListPlugins> listProvider,
+      Provider<InstallPlugin> installProvider,
+      PluginApiImpl.Factory pluginApi) {
+    this.plugins = plugins;
+    this.listProvider = listProvider;
+    this.installProvider = installProvider;
+    this.pluginApi = pluginApi;
+  }
+
+  @Override
+  public PluginApi name(String name) throws RestApiException {
+    return pluginApi.create(plugins.parse(name));
+  }
+
+  @Override
+  public ListRequest list() {
+    return new ListRequest() {
+      @Override
+      public SortedMap<String, PluginInfo> getAsMap() throws RestApiException {
+        return listProvider.get().request(this).apply(TopLevelResource.INSTANCE);
+      }
+    };
+  }
+
+  @Override
+  public PluginApi install(String name, InstallPluginInput input) throws RestApiException {
+    try {
+      Response<PluginInfo> created =
+          installProvider.get().setName(name).apply(TopLevelResource.INSTANCE, input);
+      return pluginApi.create(plugins.parse(created.value().id));
+    } catch (IOException e) {
+      throw new RestApiException("could not install plugin", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
index 348b4e8..642791a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/BranchApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.projects;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.BranchInput;
@@ -21,16 +23,17 @@
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.BranchResource;
 import com.google.gerrit.server.project.BranchesCollection;
 import com.google.gerrit.server.project.CreateBranch;
 import com.google.gerrit.server.project.DeleteBranch;
 import com.google.gerrit.server.project.FileResource;
 import com.google.gerrit.server.project.FilesCollection;
+import com.google.gerrit.server.project.GetBranch;
 import com.google.gerrit.server.project.GetContent;
 import com.google.gerrit.server.project.GetReflog;
 import com.google.gerrit.server.project.ProjectResource;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -45,6 +48,7 @@
   private final CreateBranch.Factory createBranchFactory;
   private final DeleteBranch deleteBranch;
   private final FilesCollection filesCollection;
+  private final GetBranch getBranch;
   private final GetContent getContent;
   private final GetReflog getReflog;
   private final String ref;
@@ -56,6 +60,7 @@
       CreateBranch.Factory createBranchFactory,
       DeleteBranch deleteBranch,
       FilesCollection filesCollection,
+      GetBranch getBranch,
       GetContent getContent,
       GetReflog getReflog,
       @Assisted ProjectResource project,
@@ -64,6 +69,7 @@
     this.createBranchFactory = createBranchFactory;
     this.deleteBranch = deleteBranch;
     this.filesCollection = filesCollection;
+    this.getBranch = getBranch;
     this.getContent = getContent;
     this.getReflog = getReflog;
     this.project = project;
@@ -75,17 +81,17 @@
     try {
       createBranchFactory.create(ref).apply(project, input);
       return this;
-    } catch (IOException e) {
-      throw new RestApiException("Cannot create branch", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create branch", e);
     }
   }
 
   @Override
   public BranchInfo get() throws RestApiException {
     try {
-      return resource().getBranchInfo();
-    } catch (IOException e) {
-      throw new RestApiException("Cannot read branch", e);
+      return getBranch.apply(resource());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot read branch", e);
     }
   }
 
@@ -93,8 +99,8 @@
   public void delete() throws RestApiException {
     try {
       deleteBranch.apply(resource(), new DeleteBranch.Input());
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot delete branch", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete branch", e);
     }
   }
 
@@ -103,8 +109,8 @@
     try {
       FileResource resource = filesCollection.parse(resource(), IdString.fromDecoded(path));
       return getContent.apply(resource);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot retrieve file", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve file", e);
     }
   }
 
@@ -112,12 +118,13 @@
   public List<ReflogEntryInfo> reflog() throws RestApiException {
     try {
       return getReflog.apply(resource());
-    } catch (IOException e) {
+    } catch (IOException | PermissionBackendException e) {
       throw new RestApiException("Cannot retrieve reflog", e);
     }
   }
 
-  private BranchResource resource() throws RestApiException, IOException {
+  private BranchResource resource()
+      throws RestApiException, IOException, PermissionBackendException {
     return branches.parse(project, IdString.fromDecoded(ref));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
index 925b647..1595682 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ChildProjectApiImpl.java
@@ -19,8 +19,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.ChildProjectResource;
 import com.google.gerrit.server.project.GetChildProject;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 
 public class ChildProjectApiImpl implements ChildProjectApi {
   interface Factory {
@@ -30,7 +30,7 @@
   private final GetChildProject getChildProject;
   private final ChildProjectResource rsrc;
 
-  @AssistedInject
+  @Inject
   ChildProjectApiImpl(GetChildProject getChildProject, @Assisted ChildProjectResource rsrc) {
     this.getChildProject = getChildProject;
     this.rsrc = rsrc;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
new file mode 100644
index 0000000..c0a5a2c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/CommitApiImpl.java
@@ -0,0 +1,70 @@
+// 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.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.extensions.api.changes.ChangeApi;
+import com.google.gerrit.extensions.api.changes.Changes;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.api.changes.IncludedInInfo;
+import com.google.gerrit.extensions.api.projects.CommitApi;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.CherryPickCommit;
+import com.google.gerrit.server.project.CommitIncludedIn;
+import com.google.gerrit.server.project.CommitResource;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class CommitApiImpl implements CommitApi {
+  public interface Factory {
+    CommitApiImpl create(CommitResource r);
+  }
+
+  private final Changes changes;
+  private final CherryPickCommit cherryPickCommit;
+  private final CommitIncludedIn includedIn;
+  private final CommitResource commitResource;
+
+  @Inject
+  CommitApiImpl(
+      Changes changes,
+      CherryPickCommit cherryPickCommit,
+      CommitIncludedIn includedIn,
+      @Assisted CommitResource commitResource) {
+    this.changes = changes;
+    this.cherryPickCommit = cherryPickCommit;
+    this.includedIn = includedIn;
+    this.commitResource = commitResource;
+  }
+
+  @Override
+  public ChangeApi cherryPick(CherryPickInput input) throws RestApiException {
+    try {
+      return changes.id(cherryPickCommit.apply(commitResource, input)._number);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot cherry pick", e);
+    }
+  }
+
+  @Override
+  public IncludedInInfo includedIn() throws RestApiException {
+    try {
+      return includedIn.apply(commitResource);
+    } catch (Exception e) {
+      throw asRestApiException("Could not extract IncludedIn data", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
new file mode 100644
index 0000000..0d4afd6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/DashboardApiImpl.java
@@ -0,0 +1,94 @@
+// 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.api.projects;
+
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.projects.DashboardApi;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.common.SetDashboardInput;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.DashboardResource;
+import com.google.gerrit.server.project.DashboardsCollection;
+import com.google.gerrit.server.project.GetDashboard;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.SetDashboard;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+public class DashboardApiImpl implements DashboardApi {
+  interface Factory {
+    DashboardApiImpl create(ProjectResource project, String id);
+  }
+
+  private final DashboardsCollection dashboards;
+  private final Provider<GetDashboard> get;
+  private final SetDashboard set;
+  private final ProjectResource project;
+  private final String id;
+
+  @Inject
+  DashboardApiImpl(
+      DashboardsCollection dashboards,
+      Provider<GetDashboard> get,
+      SetDashboard set,
+      @Assisted ProjectResource project,
+      @Assisted @Nullable String id) {
+    this.dashboards = dashboards;
+    this.get = get;
+    this.set = set;
+    this.project = project;
+    this.id = id;
+  }
+
+  @Override
+  public DashboardInfo get() throws RestApiException {
+    return get(false);
+  }
+
+  @Override
+  public DashboardInfo get(boolean inherited) throws RestApiException {
+    try {
+      return get.get().setInherited(inherited).apply(resource());
+    } catch (IOException | PermissionBackendException | ConfigInvalidException e) {
+      throw asRestApiException("Cannot read dashboard", e);
+    }
+  }
+
+  @Override
+  public void setDefault() throws RestApiException {
+    SetDashboardInput input = new SetDashboardInput();
+    input.id = id;
+    try {
+      set.apply(DashboardResource.projectDefault(project.getControl()), input);
+    } catch (Exception e) {
+      String msg = String.format("Cannot %s default dashboard", id != null ? "set" : "remove");
+      throw asRestApiException(msg, e);
+    }
+  }
+
+  private DashboardResource resource()
+      throws ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    return dashboards.parse(project, IdString.fromDecoded(id));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
index 975e6c1..f1e21d28 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/Module.java
@@ -26,5 +26,7 @@
     factory(TagApiImpl.Factory.class);
     factory(ProjectApiImpl.Factory.class);
     factory(ChildProjectApiImpl.Factory.class);
+    factory(CommitApiImpl.Factory.class);
+    factory(DashboardApiImpl.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
index e29d633..e6d5ff3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectApiImpl.java
@@ -14,15 +14,22 @@
 
 package com.google.gerrit.server.api.projects;
 
-import static com.google.gerrit.server.account.CapabilityUtils.checkRequiresCapability;
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+import static com.google.gerrit.server.project.DashboardsCollection.DEFAULT_DASHBOARD_NAME;
+import static java.util.stream.Collectors.toList;
 
 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.api.projects.BranchApi;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.api.projects.ChildProjectApi;
+import com.google.gerrit.extensions.api.projects.CommitApi;
 import com.google.gerrit.extensions.api.projects.ConfigInfo;
 import com.google.gerrit.extensions.api.projects.ConfigInput;
+import com.google.gerrit.extensions.api.projects.DashboardApi;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.api.projects.DeleteBranchesInput;
 import com.google.gerrit.extensions.api.projects.DeleteTagsInput;
 import com.google.gerrit.extensions.api.projects.DescriptionInput;
@@ -30,23 +37,32 @@
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
 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.TopLevelResource;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.CheckAccess;
 import com.google.gerrit.server.project.ChildProjectsCollection;
+import com.google.gerrit.server.project.CommitsCollection;
+import com.google.gerrit.server.project.CreateAccessChange;
 import com.google.gerrit.server.project.CreateProject;
 import com.google.gerrit.server.project.DeleteBranches;
 import com.google.gerrit.server.project.DeleteTags;
 import com.google.gerrit.server.project.GetAccess;
 import com.google.gerrit.server.project.GetConfig;
 import com.google.gerrit.server.project.GetDescription;
+import com.google.gerrit.server.project.GetHead;
 import com.google.gerrit.server.project.ListBranches;
 import com.google.gerrit.server.project.ListChildProjects;
+import com.google.gerrit.server.project.ListDashboards;
 import com.google.gerrit.server.project.ListTags;
 import com.google.gerrit.server.project.ProjectJson;
 import com.google.gerrit.server.project.ProjectResource;
@@ -54,12 +70,12 @@
 import com.google.gerrit.server.project.PutConfig;
 import com.google.gerrit.server.project.PutDescription;
 import com.google.gerrit.server.project.SetAccess;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.project.SetHead;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
-import java.io.IOException;
+import java.util.Collections;
 import java.util.List;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 
 public class ProjectApiImpl implements ProjectApi {
   interface Factory {
@@ -69,6 +85,7 @@
   }
 
   private final CurrentUser user;
+  private final PermissionBackend permissionBackend;
   private final CreateProject.Factory createProjectFactory;
   private final ProjectApiImpl.Factory projectApi;
   private final ProjectsCollection projects;
@@ -83,16 +100,25 @@
   private final TagApiImpl.Factory tagApi;
   private final GetAccess getAccess;
   private final SetAccess setAccess;
+  private final CreateAccessChange createAccessChange;
   private final GetConfig getConfig;
   private final PutConfig putConfig;
-  private final ListBranches listBranches;
-  private final ListTags listTags;
+  private final Provider<ListBranches> listBranches;
+  private final Provider<ListTags> listTags;
   private final DeleteBranches deleteBranches;
   private final DeleteTags deleteTags;
+  private final CommitsCollection commitsCollection;
+  private final CommitApiImpl.Factory commitApi;
+  private final DashboardApiImpl.Factory dashboardApi;
+  private final CheckAccess checkAccess;
+  private final Provider<ListDashboards> listDashboards;
+  private final GetHead getHead;
+  private final SetHead setHead;
 
   @AssistedInject
   ProjectApiImpl(
       CurrentUser user,
+      PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -105,15 +131,24 @@
       TagApiImpl.Factory tagApiFactory,
       GetAccess getAccess,
       SetAccess setAccess,
+      CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
-      ListBranches listBranches,
-      ListTags listTags,
+      Provider<ListBranches> listBranches,
+      Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
+      DashboardApiImpl.Factory dashboardApi,
+      CheckAccess checkAccess,
+      Provider<ListDashboards> listDashboards,
+      GetHead getHead,
+      SetHead setHead,
       @Assisted ProjectResource project) {
     this(
         user,
+        permissionBackend,
         createProjectFactory,
         projectApi,
         projects,
@@ -126,6 +161,7 @@
         tagApiFactory,
         getAccess,
         setAccess,
+        createAccessChange,
         getConfig,
         putConfig,
         listBranches,
@@ -133,12 +169,20 @@
         deleteBranches,
         deleteTags,
         project,
+        commitsCollection,
+        commitApi,
+        dashboardApi,
+        checkAccess,
+        listDashboards,
+        getHead,
+        setHead,
         null);
   }
 
   @AssistedInject
   ProjectApiImpl(
       CurrentUser user,
+      PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -151,15 +195,24 @@
       TagApiImpl.Factory tagApiFactory,
       GetAccess getAccess,
       SetAccess setAccess,
+      CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
-      ListBranches listBranches,
-      ListTags listTags,
+      Provider<ListBranches> listBranches,
+      Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
+      DashboardApiImpl.Factory dashboardApi,
+      CheckAccess checkAccess,
+      Provider<ListDashboards> listDashboards,
+      GetHead getHead,
+      SetHead setHead,
       @Assisted String name) {
     this(
         user,
+        permissionBackend,
         createProjectFactory,
         projectApi,
         projects,
@@ -172,6 +225,7 @@
         tagApiFactory,
         getAccess,
         setAccess,
+        createAccessChange,
         getConfig,
         putConfig,
         listBranches,
@@ -179,11 +233,19 @@
         deleteBranches,
         deleteTags,
         null,
+        commitsCollection,
+        commitApi,
+        dashboardApi,
+        checkAccess,
+        listDashboards,
+        getHead,
+        setHead,
         name);
   }
 
   private ProjectApiImpl(
       CurrentUser user,
+      PermissionBackend permissionBackend,
       CreateProject.Factory createProjectFactory,
       ProjectApiImpl.Factory projectApi,
       ProjectsCollection projects,
@@ -196,15 +258,24 @@
       TagApiImpl.Factory tagApiFactory,
       GetAccess getAccess,
       SetAccess setAccess,
+      CreateAccessChange createAccessChange,
       GetConfig getConfig,
       PutConfig putConfig,
-      ListBranches listBranches,
-      ListTags listTags,
+      Provider<ListBranches> listBranches,
+      Provider<ListTags> listTags,
       DeleteBranches deleteBranches,
       DeleteTags deleteTags,
       ProjectResource project,
+      CommitsCollection commitsCollection,
+      CommitApiImpl.Factory commitApi,
+      DashboardApiImpl.Factory dashboardApi,
+      CheckAccess checkAccess,
+      Provider<ListDashboards> listDashboards,
+      GetHead getHead,
+      SetHead setHead,
       String name) {
     this.user = user;
+    this.permissionBackend = permissionBackend;
     this.createProjectFactory = createProjectFactory;
     this.projectApi = projectApi;
     this.projects = projects;
@@ -214,7 +285,6 @@
     this.children = children;
     this.projectJson = projectJson;
     this.project = project;
-    this.name = name;
     this.branchApi = branchApiFactory;
     this.tagApi = tagApiFactory;
     this.getAccess = getAccess;
@@ -225,6 +295,15 @@
     this.listTags = listTags;
     this.deleteBranches = deleteBranches;
     this.deleteTags = deleteTags;
+    this.commitsCollection = commitsCollection;
+    this.commitApi = commitApi;
+    this.createAccessChange = createAccessChange;
+    this.dashboardApi = dashboardApi;
+    this.checkAccess = checkAccess;
+    this.listDashboards = listDashboards;
+    this.getHead = getHead;
+    this.setHead = setHead;
+    this.name = name;
   }
 
   @Override
@@ -241,11 +320,12 @@
       if (in.name != null && !name.equals(in.name)) {
         throw new BadRequestException("name must match input.name");
       }
-      checkRequiresCapability(user, null, CreateProject.class);
-      createProjectFactory.create(name).apply(TopLevelResource.INSTANCE, in);
+      CreateProject impl = createProjectFactory.create(name);
+      permissionBackend.user(user).checkAny(GlobalPermission.fromAnnotation(impl.getClass()));
+      impl.apply(TopLevelResource.INSTANCE, in);
       return projectApi.create(projects.parse(name));
-    } catch (IOException | ConfigInvalidException e) {
-      throw new RestApiException("Cannot create project: " + e.getMessage(), e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create project: " + e.getMessage(), e);
     }
   }
 
@@ -254,7 +334,7 @@
     if (project == null) {
       throw new ResourceNotFoundException(name);
     }
-    return projectJson.format(project);
+    return projectJson.format(project.getProjectState());
   }
 
   @Override
@@ -266,8 +346,17 @@
   public ProjectAccessInfo access() throws RestApiException {
     try {
       return getAccess.apply(checkExists());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot get access rights", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get access rights", e);
+    }
+  }
+
+  @Override
+  public AccessCheckInfo checkAccess(AccessCheckInput in) throws RestApiException {
+    try {
+      return checkAccess.apply(checkExists(), in);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot check access rights", e);
     }
   }
 
@@ -275,8 +364,17 @@
   public ProjectAccessInfo access(ProjectAccessInput p) throws RestApiException {
     try {
       return setAccess.apply(checkExists(), p);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot put access rights", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put access rights", e);
+    }
+  }
+
+  @Override
+  public ChangeInfo accessChange(ProjectAccessInput p) throws RestApiException {
+    try {
+      return createAccessChange.apply(checkExists(), p).value();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put access right change", e);
     }
   }
 
@@ -284,8 +382,8 @@
   public void description(DescriptionInput in) throws RestApiException {
     try {
       putDescription.apply(checkExists(), in);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot put project description", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot put project description", e);
     }
   }
 
@@ -304,46 +402,29 @@
     return new ListRefsRequest<BranchInfo>() {
       @Override
       public List<BranchInfo> get() throws RestApiException {
-        return listBranches(this);
+        try {
+          return listBranches.get().request(this).apply(checkExists());
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list branches", e);
+        }
       }
     };
   }
 
-  private List<BranchInfo> listBranches(ListRefsRequest<BranchInfo> request)
-      throws RestApiException {
-    listBranches.setLimit(request.getLimit());
-    listBranches.setStart(request.getStart());
-    listBranches.setMatchSubstring(request.getSubstring());
-    listBranches.setMatchRegex(request.getRegex());
-    try {
-      return listBranches.apply(checkExists());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot list branches", e);
-    }
-  }
-
   @Override
   public ListRefsRequest<TagInfo> tags() {
     return new ListRefsRequest<TagInfo>() {
       @Override
       public List<TagInfo> get() throws RestApiException {
-        return listTags(this);
+        try {
+          return listTags.get().request(this).apply(checkExists());
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list tags", e);
+        }
       }
     };
   }
 
-  private List<TagInfo> listTags(ListRefsRequest<TagInfo> request) throws RestApiException {
-    listTags.setLimit(request.getLimit());
-    listTags.setStart(request.getStart());
-    listTags.setMatchSubstring(request.getSubstring());
-    listTags.setMatchRegex(request.getRegex());
-    try {
-      return listTags.apply(checkExists());
-    } catch (IOException e) {
-      throw new RestApiException("Cannot list tags", e);
-    }
-  }
-
   @Override
   public List<ProjectInfo> children() throws RestApiException {
     return children(false);
@@ -353,15 +434,19 @@
   public List<ProjectInfo> children(boolean recursive) throws RestApiException {
     ListChildProjects list = children.list();
     list.setRecursive(recursive);
-    return list.apply(checkExists());
+    try {
+      return list.apply(checkExists());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot list children", e);
+    }
   }
 
   @Override
   public ChildProjectApi child(String name) throws RestApiException {
     try {
       return childApi.create(children.parse(checkExists(), IdString.fromDecoded(name)));
-    } catch (IOException e) {
-      throw new RestApiException("Cannot parse child project", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse child project", e);
     }
   }
 
@@ -379,8 +464,8 @@
   public void deleteBranches(DeleteBranchesInput in) throws RestApiException {
     try {
       deleteBranches.apply(checkExists(), in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot delete branches", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete branches", e);
     }
   }
 
@@ -388,8 +473,90 @@
   public void deleteTags(DeleteTagsInput in) throws RestApiException {
     try {
       deleteTags.apply(checkExists(), in);
-    } catch (OrmException | IOException e) {
-      throw new RestApiException("Cannot delete tags", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete tags", e);
+    }
+  }
+
+  @Override
+  public CommitApi commit(String commit) throws RestApiException {
+    try {
+      return commitApi.create(commitsCollection.parse(checkExists(), IdString.fromDecoded(commit)));
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse commit", e);
+    }
+  }
+
+  @Override
+  public DashboardApi dashboard(String name) throws RestApiException {
+    try {
+      return dashboardApi.create(checkExists(), name);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot parse dashboard", e);
+    }
+  }
+
+  @Override
+  public DashboardApi defaultDashboard() throws RestApiException {
+    return dashboard(DEFAULT_DASHBOARD_NAME);
+  }
+
+  @Override
+  public void defaultDashboard(String name) throws RestApiException {
+    try {
+      dashboardApi.create(checkExists(), name).setDefault();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set default dashboard", e);
+    }
+  }
+
+  @Override
+  public void removeDefaultDashboard() throws RestApiException {
+    try {
+      dashboardApi.create(checkExists(), null).setDefault();
+    } catch (Exception e) {
+      throw asRestApiException("Cannot remove default dashboard", e);
+    }
+  }
+
+  @Override
+  public ListDashboardsRequest dashboards() throws RestApiException {
+    return new ListDashboardsRequest() {
+      @Override
+      public List<DashboardInfo> get() throws RestApiException {
+        try {
+          List<?> r = listDashboards.get().apply(checkExists());
+          if (r.isEmpty()) {
+            return Collections.emptyList();
+          }
+          if (r.get(0) instanceof DashboardInfo) {
+            return r.stream().map(i -> (DashboardInfo) i).collect(toList());
+          }
+          throw new NotImplementedException("list with inheritance");
+        } catch (Exception e) {
+          throw asRestApiException("Cannot list dashboards", e);
+        }
+      }
+    };
+  }
+
+  @Override
+  public String head() throws RestApiException {
+    try {
+      return getHead.apply(checkExists());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get HEAD", e);
+    }
+  }
+
+  @Override
+  public void head(String head) throws RestApiException {
+    SetHead.Input input = new SetHead.Input();
+    input.ref = head;
+    try {
+      setHead.apply(checkExists(), input);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot set HEAD", e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
index 9483508..702a7e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/ProjectsImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.projects;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.projects.ProjectApi;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
 import com.google.gerrit.extensions.api.projects.Projects;
@@ -21,13 +23,13 @@
 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.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ListProjects;
 import com.google.gerrit.server.project.ListProjects.FilterType;
 import com.google.gerrit.server.project.ProjectsCollection;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.SortedMap;
 
 @Singleton
@@ -52,8 +54,8 @@
       return api.create(projects.parse(name));
     } catch (UnprocessableEntityException e) {
       return api.create(name);
-    } catch (IOException e) {
-      throw new RestApiException("Cannot retrieve project");
+    } catch (Exception e) {
+      throw asRestApiException("Cannot retrieve project", e);
     }
   }
 
@@ -77,12 +79,17 @@
     return new ListRequest() {
       @Override
       public SortedMap<String, ProjectInfo> getAsMap() throws RestApiException {
-        return list(this);
+        try {
+          return list(this);
+        } catch (Exception e) {
+          throw asRestApiException("project list unavailable", e);
+        }
       }
     };
   }
 
-  private SortedMap<String, ProjectInfo> list(ListRequest request) throws RestApiException {
+  private SortedMap<String, ProjectInfo> list(ListRequest request)
+      throws RestApiException, PermissionBackendException {
     ListProjects lp = listProvider.get();
     lp.setShowDescription(request.getDescription());
     lp.setLimit(request.getLimit());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
index 4e81407..283d117 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/projects/TagApiImpl.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.api.projects;
 
+import static com.google.gerrit.server.api.ApiUtil.asRestApiException;
+
 import com.google.gerrit.extensions.api.projects.TagApi;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.api.projects.TagInput;
@@ -25,7 +27,6 @@
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.TagResource;
 import com.google.gerrit.server.project.TagsCollection;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -63,8 +64,8 @@
     try {
       createTagFactory.create(ref).apply(project, input);
       return this;
-    } catch (IOException e) {
-      throw new RestApiException("Cannot create tag", e);
+    } catch (Exception e) {
+      throw asRestApiException("Cannot create tag", e);
     }
   }
 
@@ -72,8 +73,8 @@
   public TagInfo get() throws RestApiException {
     try {
       return listTags.get(project, IdString.fromDecoded(ref));
-    } catch (IOException e) {
-      throw new RestApiException(e.getMessage());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot get tag", e);
     }
   }
 
@@ -81,8 +82,8 @@
   public void delete() throws RestApiException {
     try {
       deleteTag.apply(resource(), new DeleteTag.Input());
-    } catch (OrmException | IOException e) {
-      throw new RestApiException(e.getMessage());
+    } catch (Exception e) {
+      throw asRestApiException("Cannot delete tag", e);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
index 4d135b8..f3393c1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupIdHandler.java
@@ -16,8 +16,10 @@
 
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
+import java.util.Optional;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -39,13 +41,13 @@
   }
 
   @Override
-  public final int parseArguments(final Parameters params) throws CmdLineException {
+  public final int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
-    final AccountGroup group = groupCache.get(new AccountGroup.NameKey(n));
-    if (group == null) {
+    Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(n));
+    if (!group.isPresent()) {
       throw new CmdLineException(owner, "Group \"" + n + "\" does not exist");
     }
-    setter.addValue(group.getId());
+    setter.addValue(group.get().getId());
     return 1;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
index 5644668..35546e8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountGroupUUIDHandler.java
@@ -47,7 +47,7 @@
   }
 
   @Override
-  public final int parseArguments(final Parameters params) throws CmdLineException {
+  public final int parseArguments(Parameters params) throws CmdLineException {
     final String n = params.getParameter(0);
     AccountGroup.UUID uuid = new AccountGroup.UUID(n);
     if (groupBackend.handles(uuid)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
index 9ee6901..c7d3f73 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/AccountIdHandler.java
@@ -16,18 +16,17 @@
 
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AuthRequest;
-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.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.OptionDef;
@@ -36,14 +35,12 @@
 import org.kohsuke.args4j.spi.Setter;
 
 public class AccountIdHandler extends OptionHandler<Account.Id> {
-  private final Provider<ReviewDb> db;
   private final AccountResolver accountResolver;
   private final AccountManager accountManager;
   private final AuthType authType;
 
   @Inject
   public AccountIdHandler(
-      Provider<ReviewDb> db,
       AccountResolver accountResolver,
       AccountManager accountManager,
       AuthConfig authConfig,
@@ -51,7 +48,6 @@
       @Assisted OptionDef option,
       @Assisted Setter<Account.Id> setter) {
     super(parser, option, setter);
-    this.db = db;
     this.accountResolver = accountResolver;
     this.accountManager = accountManager;
     this.authType = authConfig.getAuthType();
@@ -62,7 +58,7 @@
     String token = params.getParameter(0);
     Account.Id accountId;
     try {
-      Account a = accountResolver.find(db.get(), token);
+      Account a = accountResolver.find(token);
       if (a != null) {
         accountId = a.getId();
       } else {
@@ -83,8 +79,12 @@
             throw new CmdLineException(owner, "user \"" + token + "\" not found");
         }
       }
-    } catch (OrmException | IOException e) {
+    } catch (OrmException e) {
       throw new CmdLineException(owner, "database is down");
+    } catch (IOException e) {
+      throw new CmdLineException(owner, "Failed to load account", e);
+    } catch (ConfigInvalidException e) {
+      throw new CmdLineException(owner, "Invalid account config", e);
     }
     setter.addValue(accountId);
     return 1;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ChangeIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
index bdf0c91..0e841ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ChangeIdHandler.java
@@ -45,7 +45,7 @@
   }
 
   @Override
-  public final int parseArguments(final Parameters params) throws CmdLineException {
+  public final int parseArguments(Parameters params) throws CmdLineException {
     final String token = params.getParameter(0);
     final String[] tokens = token.split(",");
     if (tokens.length != 3) {
@@ -57,7 +57,7 @@
       final Change.Key key = Change.Key.parse(tokens[2]);
       final Project.NameKey project = new Project.NameKey(tokens[0]);
       final Branch.NameKey branch = new Branch.NameKey(project, tokens[1]);
-      for (final ChangeData cd : queryProvider.get().byBranchKey(branch, key)) {
+      for (ChangeData cd : queryProvider.get().byBranchKey(branch, key)) {
         setter.addValue(cd.getId());
         return 1;
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
index e8283be..cb70abf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/PatchSetIdHandler.java
@@ -35,7 +35,7 @@
   }
 
   @Override
-  public final int parseArguments(final Parameters params) throws CmdLineException {
+  public final int parseArguments(Parameters params) throws CmdLineException {
     final String token = params.getParameter(0);
     final PatchSet.Id id;
     try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
index 02e907f..1823527 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/ProjectControlHandler.java
@@ -15,8 +15,12 @@
 package com.google.gerrit.server.args4j;
 
 import com.google.gerrit.common.ProjectUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+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.Inject;
@@ -34,23 +38,27 @@
 
 public class ProjectControlHandler extends OptionHandler<ProjectControl> {
   private static final Logger log = LoggerFactory.getLogger(ProjectControlHandler.class);
+
   private final ProjectControl.GenericFactory projectControlFactory;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
 
   @Inject
   public ProjectControlHandler(
-      final ProjectControl.GenericFactory projectControlFactory,
+      ProjectControl.GenericFactory projectControlFactory,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> user,
       @Assisted final CmdLineParser parser,
       @Assisted final OptionDef option,
       @Assisted final Setter<ProjectControl> setter) {
     super(parser, option, setter);
     this.projectControlFactory = projectControlFactory;
+    this.permissionBackend = permissionBackend;
     this.user = user;
   }
 
   @Override
-  public final int parseArguments(final Parameters params) throws CmdLineException {
+  public final int parseArguments(Parameters params) throws CmdLineException {
     String projectName = params.getParameter(0);
 
     while (projectName.endsWith("/")) {
@@ -69,14 +77,15 @@
     String nameWithoutSuffix = ProjectUtil.stripGitSuffix(projectName);
     Project.NameKey nameKey = new Project.NameKey(nameWithoutSuffix);
 
-    final ProjectControl control;
+    ProjectControl control;
     try {
-      control =
-          projectControlFactory.validateFor(
-              nameKey, ProjectControl.OWNER | ProjectControl.VISIBLE, user.get());
+      control = projectControlFactory.controlFor(nameKey, user.get());
+      permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+    } catch (AuthException e) {
+      throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
     } catch (NoSuchProjectException e) {
       throw new CmdLineException(owner, e.getMessage());
-    } catch (IOException e) {
+    } catch (PermissionBackendException | IOException e) {
       log.warn("Cannot load project " + nameWithoutSuffix, e);
       throw new CmdLineException(owner, new NoSuchProjectException(nameKey).getMessage());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SocketAddressHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
index e0193c5..4325c00 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SocketAddressHandler.java
@@ -36,7 +36,7 @@
   }
 
   @Override
-  public final int parseArguments(final Parameters params) throws CmdLineException {
+  public final int parseArguments(Parameters params) throws CmdLineException {
     final String token = params.getParameter(0);
     try {
       setter.addValue(SocketUtil.parse(token, 0));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SubcommandHandler.java b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SubcommandHandler.java
index b7af2e7..0be75a7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SubcommandHandler.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/args4j/SubcommandHandler.java
@@ -34,7 +34,7 @@
   }
 
   @Override
-  public final int parseArguments(final Parameters params) throws CmdLineException {
+  public final int parseArguments(Parameters params) throws CmdLineException {
     setter.addValue(params.getParameter(0));
     owner.stopOptionParsing();
     return 1;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthenticationUnavailableException.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthenticationUnavailableException.java
index b0b6142..1b1faa4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthenticationUnavailableException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/AuthenticationUnavailableException.java
@@ -20,7 +20,7 @@
 public class AuthenticationUnavailableException extends AccountException {
   private static final long serialVersionUID = 1L;
 
-  public AuthenticationUnavailableException(final String message, final Throwable why) {
+  public AuthenticationUnavailableException(String message, Throwable why) {
     super(message, why);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
index 3ad97b0..af9c51b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/UniversalAuthBackend.java
@@ -33,7 +33,7 @@
   }
 
   @Override
-  public AuthUser authenticate(final AuthRequest request) throws AuthException {
+  public AuthUser authenticate(AuthRequest request) throws AuthException {
     List<AuthUser> authUsers = new ArrayList<>();
     List<AuthException> authExs = new ArrayList<>();
     for (AuthBackend backend : authBackends) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
index d2499c0..fd88845 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/Helper.java
@@ -77,7 +77,7 @@
 
   @Inject
   Helper(
-      @GerritServerConfig final Config config,
+      @GerritServerConfig Config config,
       @Named(LdapModule.PARENT_GROUPS_CACHE) Cache<String, ImmutableSet<String>> parentGroups) {
     this.config = config;
     this.server = LdapRealm.optional(config, "server");
@@ -139,7 +139,7 @@
     return new InitialDirContext(env);
   }
 
-  private DirContext kerberosOpen(final Properties env) throws LoginException, NamingException {
+  private DirContext kerberosOpen(Properties env) throws LoginException, NamingException {
     LoginContext ctx = new LoginContext("KerberosLogin");
     ctx.login();
     Subject subject = ctx.getSubject();
@@ -211,8 +211,7 @@
   }
 
   Set<AccountGroup.UUID> queryForGroups(
-      final DirContext ctx, final String username, LdapQuery.Result account)
-      throws NamingException {
+      final DirContext ctx, String username, LdapQuery.Result account) throws NamingException {
     final LdapSchema schema = getSchema(ctx);
     final Set<String> groupDNs = new HashSet<>();
 
@@ -334,7 +333,7 @@
     final ParameterizedString groupName;
     final List<LdapQuery> groupMemberQueryList;
 
-    LdapSchema(final DirContext ctx) {
+    LdapSchema(DirContext ctx) {
       type = discoverLdapType(ctx);
       groupMemberQueryList = new ArrayList<>();
       accountQueryList = new ArrayList<>();
@@ -364,7 +363,7 @@
             throw new IllegalArgumentException("No variables in ldap.groupMemberPattern");
           }
 
-          for (final String name : groupMemberQuery.getParameters()) {
+          for (String name : groupMemberQuery.getParameters()) {
             accountAtts.add(name);
           }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
index 9efad24..324e191 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapGroupBackend.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.account.GroupBackends.GROUP_REF_NAME_COMPARATOR;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 import static com.google.gerrit.server.auth.ldap.Helper.LDAP_UUID;
 import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_CACHE;
 import static com.google.gerrit.server.auth.ldap.LdapModule.GROUP_EXIST_CACHE;
@@ -29,13 +29,13 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupMembership;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.ldap.Helper.LdapSchema;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.name.Named;
@@ -118,7 +118,7 @@
   }
 
   @Override
-  public GroupDescription.Basic get(final AccountGroup.UUID uuid) {
+  public GroupDescription.Basic get(AccountGroup.UUID uuid) {
     if (!handles(uuid)) {
       return null;
     }
@@ -163,7 +163,7 @@
   }
 
   @Override
-  public Collection<GroupReference> suggest(String name, ProjectControl project) {
+  public Collection<GroupReference> suggest(String name, ProjectState project) {
     AccountGroup.UUID uuid = new AccountGroup.UUID(name);
     if (isLdapUUID(uuid)) {
       GroupDescription.Basic g = get(uuid);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
index 28eb05d..3d25e86 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapQuery.java
@@ -61,8 +61,7 @@
     return pattern.getParameterNames();
   }
 
-  List<Result> query(final DirContext ctx, final Map<String, String> params)
-      throws NamingException {
+  List<Result> query(DirContext ctx, Map<String, String> params) throws NamingException {
     final SearchControls sc = new SearchControls();
     final NamingEnumeration<SearchResult> res;
 
@@ -87,9 +86,9 @@
   class Result {
     private final Map<String, Attribute> atts = new HashMap<>();
 
-    Result(final SearchResult sr) {
+    Result(SearchResult sr) {
       if (returnAttributes != null) {
-        for (final String attName : returnAttributes) {
+        for (String attName : returnAttributes) {
           final Attribute a = sr.getAttributes().get(attName);
           if (a != null && a.size() > 0) {
             atts.put(attName, a);
@@ -111,12 +110,12 @@
       return get("dn");
     }
 
-    String get(final String attName) throws NamingException {
+    String get(String attName) throws NamingException {
       final Attribute att = getAll(attName);
       return att != null && 0 < att.size() ? String.valueOf(att.get(0)) : null;
     }
 
-    Attribute getAll(final String attName) {
+    Attribute getAll(String attName) {
       return atts.get(attName);
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
index 122d4bc..24fdef4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapRealm.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.ldap;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_GERRIT;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_GERRIT;
 
 import com.google.common.base.Strings;
 import com.google.common.cache.CacheLoader;
@@ -25,21 +25,22 @@
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AbstractRealm;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.EmailExpander;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupBackends;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.auth.AuthenticationUnavailableException;
+import com.google.gerrit.server.auth.NoSuchUserException;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.name.Named;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
@@ -83,11 +84,9 @@
       AuthConfig authConfig,
       EmailExpander emailExpander,
       LdapGroupBackend groupBackend,
-      @Named(LdapModule.GROUP_CACHE)
-          final LoadingCache<String, Set<AccountGroup.UUID>> membershipCache,
-      @Named(LdapModule.USERNAME_CACHE)
-          final LoadingCache<String, Optional<Account.Id>> usernameCache,
-      @GerritServerConfig final Config config) {
+      @Named(LdapModule.GROUP_CACHE) LoadingCache<String, Set<AccountGroup.UUID>> membershipCache,
+      @Named(LdapModule.USERNAME_CACHE) LoadingCache<String, Optional<Account.Id>> usernameCache,
+      @GerritServerConfig Config config) {
     this.helper = helper;
     this.authConfig = authConfig;
     this.emailExpander = emailExpander;
@@ -112,11 +111,11 @@
     mandatoryGroup = optional(config, "mandatoryGroup");
   }
 
-  static SearchScope scope(final Config c, final String setting) {
+  static SearchScope scope(Config c, String setting) {
     return c.getEnum("ldap", null, setting, SearchScope.SUBTREE);
   }
 
-  static String optional(final Config config, final String name) {
+  static String optional(Config config, String name) {
     return config.getString("ldap", null, name);
   }
 
@@ -136,7 +135,7 @@
     return config.getBoolean("ldap", name, defaultValue);
   }
 
-  static String required(final Config config, final String name) {
+  static String required(Config config, String name) {
     final String v = optional(config, name);
     if (v == null || "".equals(v)) {
       throw new IllegalArgumentException("No ldap." + name + " configured");
@@ -144,12 +143,12 @@
     return v;
   }
 
-  static List<String> optionalList(final Config config, final String name) {
+  static List<String> optionalList(Config config, String name) {
     String[] s = config.getStringList("ldap", null, name);
     return Arrays.asList(s);
   }
 
-  static List<String> requiredList(final Config config, final String name) {
+  static List<String> requiredList(Config config, String name) {
     List<String> vlist = optionalList(config, name);
 
     if (vlist.isEmpty()) {
@@ -159,7 +158,7 @@
     return vlist;
   }
 
-  static String optdef(final Config c, final String n, final String d) {
+  static String optdef(Config c, String n, String d) {
     final String[] v = c.getStringList("ldap", null, n);
     if (v == null || v.length == 0) {
       return d;
@@ -173,7 +172,7 @@
     }
   }
 
-  static String reqdef(final Config c, final String n, final String d) {
+  static String reqdef(Config c, String n, String d) {
     final String v = optdef(c, n, d);
     if (v == null) {
       throw new IllegalArgumentException("No ldap." + n + " configured");
@@ -202,7 +201,7 @@
   }
 
   @Override
-  public boolean allowsEdit(final AccountFieldName field) {
+  public boolean allowsEdit(AccountFieldName field) {
     return !readOnlyAccountFields.contains(field);
   }
 
@@ -212,7 +211,7 @@
     }
 
     final Map<String, String> values = new HashMap<>();
-    for (final String name : m.attributes()) {
+    for (String name : m.attributes()) {
       values.put(name, m.get(name));
     }
 
@@ -221,7 +220,7 @@
   }
 
   @Override
-  public AuthRequest authenticate(final AuthRequest who) throws AccountException {
+  public AuthRequest authenticate(AuthRequest who) throws AccountException {
     if (config.getBoolean("ldap", "localUsernameToLowerCase", false)) {
       who.setLocalUser(who.getLocalUser().toLowerCase(Locale.US));
     }
@@ -236,7 +235,10 @@
       }
       try {
         final Helper.LdapSchema schema = helper.getSchema(ctx);
-        final LdapQuery.Result m = helper.findAccount(schema, ctx, username, fetchMemberOfEagerly);
+        LdapQuery.Result m;
+        who.setAuthProvidesAccountActiveStatus(true);
+        m = helper.findAccount(schema, ctx, username, fetchMemberOfEagerly);
+        who.setActive(true);
 
         if (authConfig.getAuthType() == AuthType.LDAP && !who.isSkipAuthentication()) {
           // We found the user account, but we need to verify
@@ -300,7 +302,7 @@
   }
 
   @Override
-  public void onCreateAccount(final AuthRequest who, final Account account) {
+  public void onCreateAccount(AuthRequest who, Account account) {
     usernameCache.put(who.getLocalUser(), Optional.of(account.getId()));
   }
 
@@ -318,25 +320,47 @@
     }
   }
 
+  @Override
+  public boolean isActive(String username)
+      throws LoginException, NamingException, AccountException {
+    final DirContext ctx = helper.open();
+    try {
+      Helper.LdapSchema schema = helper.getSchema(ctx);
+      helper.findAccount(schema, ctx, username, false);
+      return true;
+    } catch (NoSuchUserException e) {
+      return false;
+    } finally {
+      try {
+        ctx.close();
+      } catch (NamingException e) {
+        log.warn("Cannot close LDAP query handle", e);
+      }
+    }
+  }
+
+  @Override
+  public boolean accountBelongsToRealm(Collection<ExternalId> externalIds) {
+    for (ExternalId id : externalIds) {
+      if (id.toString().contains(SCHEME_GERRIT)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
   static class UserLoader extends CacheLoader<String, Optional<Account.Id>> {
-    private final SchemaFactory<ReviewDb> schema;
+    private final ExternalIds externalIds;
 
     @Inject
-    UserLoader(SchemaFactory<ReviewDb> schema) {
-      this.schema = schema;
+    UserLoader(ExternalIds externalIds) {
+      this.externalIds = externalIds;
     }
 
     @Override
     public Optional<Account.Id> load(String username) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        return Optional.ofNullable(
-                ExternalId.from(
-                    db.accountExternalIds()
-                        .get(
-                            ExternalId.Key.create(SCHEME_GERRIT, username)
-                                .asAccountExternalIdKey())))
-            .map(ExternalId::accountId);
-      }
+      return Optional.ofNullable(externalIds.get(ExternalId.Key.create(SCHEME_GERRIT, username)))
+          .map(ExternalId::accountId);
     }
   }
 
@@ -344,7 +368,7 @@
     private final Helper helper;
 
     @Inject
-    MemberLoader(final Helper helper) {
+    MemberLoader(Helper helper) {
       this.helper = helper;
     }
 
@@ -367,12 +391,12 @@
     private final Helper helper;
 
     @Inject
-    ExistenceLoader(final Helper helper) {
+    ExistenceLoader(Helper helper) {
       this.helper = helper;
     }
 
     @Override
-    public Boolean load(final String groupDn) throws Exception {
+    public Boolean load(String groupDn) throws Exception {
       final DirContext ctx = helper.open();
       try {
         Name compositeGroupName = new CompositeName().add(groupDn);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapType.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapType.java
index 5df13f9..fe1f1ff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/LdapType.java
@@ -22,7 +22,7 @@
 abstract class LdapType {
   static final LdapType RFC_2307 = new Rfc2307();
 
-  static LdapType guessType(final DirContext ctx) throws NamingException {
+  static LdapType guessType(DirContext ctx) throws NamingException {
     final Attributes rootAtts = ctx.getAttributes("");
     Attribute supported = rootAtts.get("supportedCapabilities");
     if (supported != null
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/SearchScope.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/SearchScope.java
index 369914d..0038608 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/SearchScope.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/ldap/SearchScope.java
@@ -36,7 +36,7 @@
 
   private final int scope;
 
-  SearchScope(final int scope) {
+  SearchScope(int scope) {
     this.scope = scope;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
index a1d9350..51b5e16 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.auth.oauth;
 
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_EXTERNAL;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
@@ -24,10 +26,12 @@
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Collection;
 import java.util.HashSet;
 import java.util.Set;
 import org.eclipse.jgit.lib.Config;
@@ -115,4 +119,14 @@
   public Account.Id lookup(String accountName) {
     return null;
   }
+
+  @Override
+  public boolean accountBelongsToRealm(Collection<ExternalId> externalIds) {
+    for (ExternalId id : externalIds) {
+      if (id.toString().contains(SCHEME_EXTERNAL)) {
+        return true;
+      }
+    }
+    return false;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
index d30e667..75f4213 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/openid/OpenIdProviderPattern.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.auth.openid;
 
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 
 public class OpenIdProviderPattern {
   public static OpenIdProviderPattern create(String pattern) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
index 72134c1..21a1556 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/avatar/AvatarProvider.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.avatar;
 
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.server.IdentifiedUser;
 
 /**
@@ -24,6 +25,7 @@
  */
 @ExtensionPoint
 public interface AvatarProvider {
+
   /**
    * Get avatar URL.
    *
@@ -46,4 +48,36 @@
    *     possible.
    */
   String getChangeAvatarUrl(IdentifiedUser forUser);
+
+  /**
+   * Set the avatar image URL for specified user and specified size.
+   *
+   * <p>It is the default method (not interface method declaration) for back compatibility with old
+   * code.
+   *
+   * @param forUser The user for which need to change the avatar image.
+   * @param url The avatar image URL for the specified user.
+   * @param imageSize The avatar image size in pixels. If imageSize have a zero value this indicates
+   *     to set URL for default size that provider determines.
+   * @throws Exception if an error occurred.
+   */
+  default void setUrl(IdentifiedUser forUser, String url, int imageSize) throws Exception {
+    throw new NotImplementedException();
+  }
+
+  /**
+   * Indicates whether or not the provider allows to set the image URL.
+   *
+   * <p>It is the default method (not interface method declaration) for back compatibility with old
+   * code.
+   *
+   * @return
+   *     <ul>
+   *       <li>true - avatar image URL could be set.
+   *       <li>false - avatar image URL could not be set (for example not Implemented).
+   *     </ul>
+   */
+  default boolean canSetUrl() {
+    return false;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
index 862f4e8..11f2034 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/cache/CacheMetrics.java
@@ -30,65 +30,60 @@
 @Singleton
 public class CacheMetrics {
   @Inject
-  public CacheMetrics(MetricMaker metrics, final DynamicMap<Cache<?, ?>> cacheMap) {
+  public CacheMetrics(MetricMaker metrics, DynamicMap<Cache<?, ?>> cacheMap) {
     Field<String> F_NAME = Field.ofString("cache_name");
 
-    final CallbackMetric1<String, Long> memEnt =
+    CallbackMetric1<String, Long> memEnt =
         metrics.newCallbackMetric(
             "caches/memory_cached",
             Long.class,
             new Description("Memory entries").setGauge().setUnit("entries"),
             F_NAME);
-    final CallbackMetric1<String, Double> memHit =
+    CallbackMetric1<String, Double> memHit =
         metrics.newCallbackMetric(
             "caches/memory_hit_ratio",
             Double.class,
             new Description("Memory hit ratio").setGauge().setUnit("percent"),
             F_NAME);
-    final CallbackMetric1<String, Long> memEvict =
+    CallbackMetric1<String, Long> memEvict =
         metrics.newCallbackMetric(
             "caches/memory_eviction_count",
             Long.class,
             new Description("Memory eviction count").setGauge().setUnit("evicted entries"),
             F_NAME);
-    final CallbackMetric1<String, Long> perDiskEnt =
+    CallbackMetric1<String, Long> perDiskEnt =
         metrics.newCallbackMetric(
             "caches/disk_cached",
             Long.class,
             new Description("Disk entries used by persistent cache").setGauge().setUnit("entries"),
             F_NAME);
-    final CallbackMetric1<String, Double> perDiskHit =
+    CallbackMetric1<String, Double> perDiskHit =
         metrics.newCallbackMetric(
             "caches/disk_hit_ratio",
             Double.class,
             new Description("Disk hit ratio for persistent cache").setGauge().setUnit("percent"),
             F_NAME);
 
-    final Set<CallbackMetric<?>> cacheMetrics =
+    Set<CallbackMetric<?>> cacheMetrics =
         ImmutableSet.<CallbackMetric<?>>of(memEnt, memHit, memEvict, perDiskEnt, perDiskHit);
 
     metrics.newTrigger(
         cacheMetrics,
-        new Runnable() {
-          @Override
-          public void run() {
-            for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
-              Cache<?, ?> c = e.getProvider().get();
-              String name = metricNameOf(e);
-              CacheStats cstats = c.stats();
-              memEnt.set(name, c.size());
-              memHit.set(name, cstats.hitRate() * 100);
-              memEvict.set(name, cstats.evictionCount());
-              if (c instanceof PersistentCache) {
-                PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
-                perDiskEnt.set(name, d.size());
-                perDiskHit.set(name, hitRatio(d));
-              }
-            }
-            for (CallbackMetric<?> cbm : cacheMetrics) {
-              cbm.prune();
+        () -> {
+          for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
+            Cache<?, ?> c = e.getProvider().get();
+            String name = metricNameOf(e);
+            CacheStats cstats = c.stats();
+            memEnt.set(name, c.size());
+            memHit.set(name, cstats.hitRate() * 100);
+            memEvict.set(name, cstats.evictionCount());
+            if (c instanceof PersistentCache) {
+              PersistentCache.DiskStats d = ((PersistentCache) c).diskStats();
+              perDiskEnt.set(name, d.size());
+              perDiskHit.set(name, hitRatio(d));
             }
           }
+          cacheMetrics.forEach(CallbackMetric::prune);
         });
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
index 0cafe6d..c9d016d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.TimeUtil;
@@ -21,10 +23,8 @@
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -32,25 +32,27 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.AbandonOp;
-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.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collection;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class Abandon
-    implements RestModifyView<ChangeResource, AbandonInput>, UiAction<ChangeResource> {
-  private static final Logger log = LoggerFactory.getLogger(Abandon.class);
-
+public class Abandon extends RetryingRestModifyView<ChangeResource, AbandonInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final AbandonOp.Factory abandonOpFactory;
   private final NotifyUtil notifyUtil;
 
@@ -58,54 +60,75 @@
   Abandon(
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       AbandonOp.Factory abandonOpFactory,
       NotifyUtil notifyUtil) {
+    super(retryHelper);
     this.dbProvider = dbProvider;
     this.json = json;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.abandonOpFactory = abandonOpFactory;
     this.notifyUtil = notifyUtil;
   }
 
   @Override
-  public ChangeInfo apply(ChangeResource req, AbandonInput input)
-      throws RestApiException, UpdateException, OrmException {
-    ChangeControl control = req.getControl();
-    if (!control.canAbandon(dbProvider.get())) {
-      throw new AuthException("abandon not permitted");
-    }
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, AbandonInput input)
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException,
+          IOException, ConfigInvalidException {
+    req.permissions().database(dbProvider).check(ChangePermission.ABANDON);
+
+    NotifyHandling notify = input.notify == null ? defaultNotify(req.getChange()) : input.notify;
     Change change =
         abandon(
-            control, input.message, input.notify, notifyUtil.resolveAccounts(input.notifyDetails));
+            updateFactory,
+            req.getNotes(),
+            req.getUser(),
+            input.message,
+            notify,
+            notifyUtil.resolveAccounts(input.notifyDetails));
     return json.noOptions().format(change);
   }
 
-  public Change abandon(ChangeControl control) throws RestApiException, UpdateException {
-    return abandon(control, "", NotifyHandling.ALL, ImmutableListMultimap.of());
+  private NotifyHandling defaultNotify(Change change) {
+    return change.hasReviewStarted() ? NotifyHandling.ALL : NotifyHandling.OWNER;
   }
 
-  public Change abandon(ChangeControl control, String msgTxt)
+  public Change abandon(BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user)
       throws RestApiException, UpdateException {
-    return abandon(control, msgTxt, NotifyHandling.ALL, ImmutableListMultimap.of());
+    return abandon(
+        updateFactory,
+        notes,
+        user,
+        "",
+        defaultNotify(notes.getChange()),
+        ImmutableListMultimap.of());
   }
 
   public Change abandon(
-      ChangeControl control,
+      BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, String msgTxt)
+      throws RestApiException, UpdateException {
+    return abandon(
+        updateFactory,
+        notes,
+        user,
+        msgTxt,
+        defaultNotify(notes.getChange()),
+        ImmutableListMultimap.of());
+  }
+
+  public Change abandon(
+      BatchUpdate.Factory updateFactory,
+      ChangeNotes notes,
+      CurrentUser user,
       String msgTxt,
       NotifyHandling notifyHandling,
       ListMultimap<RecipientType, Account.Id> accountsToNotify)
       throws RestApiException, UpdateException {
-    CurrentUser user = control.getUser();
     Account account = user.isIdentifiedUser() ? user.asIdentifiedUser().getAccount() : null;
     AbandonOp op = abandonOpFactory.create(account, msgTxt, notifyHandling, accountsToNotify);
     try (BatchUpdate u =
-        batchUpdateFactory.create(
-            dbProvider.get(),
-            control.getProject().getNameKey(),
-            control.getUser(),
-            TimeUtil.nowTs())) {
-      u.addOp(control.getId(), op).execute();
+        updateFactory.create(dbProvider.get(), notes.getProjectName(), user, TimeUtil.nowTs())) {
+      u.addOp(notes.getChangeId(), op).execute();
     }
     return op.getChange();
   }
@@ -115,31 +138,31 @@
    * should use the batch instead of abandoning one by one.
    *
    * <p>It's the caller's responsibility to ensure that all jobs inside the same batch have the
-   * matching project from its ChangeControl. Violations will result in a ResourceConflictException.
+   * matching project from its ChangeData. Violations will result in a ResourceConflictException.
    */
   public void batchAbandon(
+      BatchUpdate.Factory updateFactory,
       Project.NameKey project,
       CurrentUser user,
-      Collection<ChangeControl> controls,
+      Collection<ChangeData> changes,
       String msgTxt,
       NotifyHandling notifyHandling,
       ListMultimap<RecipientType, Account.Id> accountsToNotify)
       throws RestApiException, UpdateException {
-    if (controls.isEmpty()) {
+    if (changes.isEmpty()) {
       return;
     }
     Account account = user.isIdentifiedUser() ? user.asIdentifiedUser().getAccount() : null;
-    try (BatchUpdate u =
-        batchUpdateFactory.create(dbProvider.get(), project, user, TimeUtil.nowTs())) {
-      for (ChangeControl control : controls) {
-        if (!project.equals(control.getProject().getNameKey())) {
+    try (BatchUpdate u = updateFactory.create(dbProvider.get(), project, user, TimeUtil.nowTs())) {
+      for (ChangeData change : changes) {
+        if (!project.equals(change.project())) {
           throw new ResourceConflictException(
               String.format(
                   "Project name \"%s\" doesn't match \"%s\"",
-                  control.getProject().getNameKey().get(), project.get()));
+                  change.project().get(), project.get()));
         }
         u.addOp(
-            control.getId(),
+            change.getId(),
             abandonOpFactory.create(account, msgTxt, notifyHandling, accountsToNotify));
       }
       u.execute();
@@ -147,31 +170,41 @@
   }
 
   public void batchAbandon(
-      Project.NameKey project, CurrentUser user, Collection<ChangeControl> controls, String msgTxt)
+      BatchUpdate.Factory updateFactory,
+      Project.NameKey project,
+      CurrentUser user,
+      Collection<ChangeData> changes,
+      String msgTxt)
       throws RestApiException, UpdateException {
-    batchAbandon(project, user, controls, msgTxt, NotifyHandling.ALL, ImmutableListMultimap.of());
+    batchAbandon(
+        updateFactory,
+        project,
+        user,
+        changes,
+        msgTxt,
+        NotifyHandling.ALL,
+        ImmutableListMultimap.of());
   }
 
   public void batchAbandon(
-      Project.NameKey project, CurrentUser user, Collection<ChangeControl> controls)
+      BatchUpdate.Factory updateFactory,
+      Project.NameKey project,
+      CurrentUser user,
+      Collection<ChangeData> changes)
       throws RestApiException, UpdateException {
-    batchAbandon(project, user, controls, "", NotifyHandling.ALL, ImmutableListMultimap.of());
+    batchAbandon(
+        updateFactory, project, user, changes, "", NotifyHandling.ALL, ImmutableListMultimap.of());
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
-    boolean canAbandon = false;
-    try {
-      canAbandon = resource.getControl().canAbandon(dbProvider.get());
-    } catch (OrmException e) {
-      log.error("Cannot check canAbandon status. Assuming false.", e);
-    }
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
     return new UiAction.Description()
         .setLabel("Abandon")
         .setTitle("Abandon the change")
         .setVisible(
-            resource.getChange().getStatus().isOpen()
-                && resource.getChange().getStatus() != Change.Status.DRAFT
-                && canAbandon);
+            and(
+                change.getStatus().isOpen(),
+                rsrc.permissions().database(dbProvider).testCond(ChangePermission.ABANDON)));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
index 1dd321f..cbe2d1e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/AbandonUtil.java
@@ -16,16 +16,17 @@
 
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.ChangeQueryProcessor;
+import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -39,7 +40,7 @@
   private static final Logger log = LoggerFactory.getLogger(AbandonUtil.class);
 
   private final ChangeCleanupConfig cfg;
-  private final ChangeQueryProcessor queryProcessor;
+  private final Provider<ChangeQueryProcessor> queryProvider;
   private final ChangeQueryBuilder queryBuilder;
   private final Abandon abandon;
   private final InternalUser internalUser;
@@ -48,17 +49,17 @@
   AbandonUtil(
       ChangeCleanupConfig cfg,
       InternalUser.Factory internalUserFactory,
-      ChangeQueryProcessor queryProcessor,
+      Provider<ChangeQueryProcessor> queryProvider,
       ChangeQueryBuilder queryBuilder,
       Abandon abandon) {
     this.cfg = cfg;
-    this.queryProcessor = queryProcessor;
+    this.queryProvider = queryProvider;
     this.queryBuilder = queryBuilder;
     this.abandon = abandon;
     internalUser = internalUserFactory.create();
   }
 
-  public void abandonInactiveOpenChanges() {
+  public void abandonInactiveOpenChanges(BatchUpdate.Factory updateFactory) {
     if (cfg.getAbandonAfter() <= 0) {
       return;
     }
@@ -71,25 +72,24 @@
       }
 
       List<ChangeData> changesToAbandon =
-          queryProcessor.enforceVisibility(false).query(queryBuilder.parse(query)).entities();
-      ImmutableListMultimap.Builder<Project.NameKey, ChangeControl> builder =
+          queryProvider.get().enforceVisibility(false).query(queryBuilder.parse(query)).entities();
+      ImmutableListMultimap.Builder<Project.NameKey, ChangeData> builder =
           ImmutableListMultimap.builder();
       for (ChangeData cd : changesToAbandon) {
-        ChangeControl control = cd.changeControl(internalUser);
-        builder.put(control.getProject().getNameKey(), control);
+        builder.put(cd.project(), cd);
       }
 
       int count = 0;
-      ListMultimap<Project.NameKey, ChangeControl> abandons = builder.build();
+      ListMultimap<Project.NameKey, ChangeData> abandons = builder.build();
       String message = cfg.getAbandonMessage();
       for (Project.NameKey project : abandons.keySet()) {
-        Collection<ChangeControl> changes = getValidChanges(abandons.get(project), query);
+        Collection<ChangeData> changes = getValidChanges(abandons.get(project), query);
         try {
-          abandon.batchAbandon(project, internalUser, changes, message);
+          abandon.batchAbandon(updateFactory, project, internalUser, changes, message);
           count += changes.size();
         } catch (Throwable e) {
           StringBuilder msg = new StringBuilder("Failed to auto-abandon inactive change(s):");
-          for (ChangeControl change : changes) {
+          for (ChangeData change : changes) {
             msg.append(" ").append(change.getId().get());
           }
           msg.append(".");
@@ -102,21 +102,24 @@
     }
   }
 
-  private Collection<ChangeControl> getValidChanges(
-      Collection<ChangeControl> changeControls, String query)
+  private Collection<ChangeData> getValidChanges(Collection<ChangeData> changes, String query)
       throws OrmException, QueryParseException {
-    Collection<ChangeControl> validChanges = new ArrayList<>();
-    for (ChangeControl cc : changeControls) {
-      String newQuery = query + " change:" + cc.getId();
+    Collection<ChangeData> validChanges = new ArrayList<>();
+    for (ChangeData cd : changes) {
+      String newQuery = query + " change:" + cd.getId();
       List<ChangeData> changesToAbandon =
-          queryProcessor.enforceVisibility(false).query(queryBuilder.parse(newQuery)).entities();
+          queryProvider
+              .get()
+              .enforceVisibility(false)
+              .query(queryBuilder.parse(newQuery))
+              .entities();
       if (!changesToAbandon.isEmpty()) {
-        validChanges.add(cc);
+        validChanges.add(cd);
       } else {
         log.debug(
             "Change data with id \"{}\" does not satisfy the query \"{}\""
                 + " any more, hence skipping it in clean up",
-            cc.getId(),
+            cd.getId(),
             query);
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
index 519a4bc..7aa6e4e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ActionJson.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
-import com.google.common.collect.FluentIterable;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
@@ -30,15 +29,16 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.PrivateInternals_UiActionDescription;
 import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.extensions.webui.UiActions;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import com.google.inject.util.Providers;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -48,21 +48,27 @@
   private final Revisions revisions;
   private final ChangeJson.Factory changeJsonFactory;
   private final ChangeResource.Factory changeResourceFactory;
+  private final UiActions uiActions;
   private final DynamicMap<RestView<ChangeResource>> changeViews;
   private final DynamicSet<ActionVisitor> visitorSet;
+  private final Provider<CurrentUser> userProvider;
 
   @Inject
   ActionJson(
       Revisions revisions,
       ChangeJson.Factory changeJsonFactory,
       ChangeResource.Factory changeResourceFactory,
+      UiActions uiActions,
       DynamicMap<RestView<ChangeResource>> changeViews,
-      DynamicSet<ActionVisitor> visitorSet) {
+      DynamicSet<ActionVisitor> visitorSet,
+      Provider<CurrentUser> userProvider) {
     this.revisions = revisions;
     this.changeJsonFactory = changeJsonFactory;
     this.changeResourceFactory = changeResourceFactory;
+    this.uiActions = uiActions;
     this.changeViews = changeViews;
     this.visitorSet = visitorSet;
+    this.userProvider = userProvider;
   }
 
   public Map<String, ActionInfo> format(RevisionResource rsrc) throws OrmException {
@@ -85,9 +91,9 @@
     return Lists.newArrayList(visitorSet);
   }
 
-  public ChangeInfo addChangeActions(ChangeInfo to, ChangeControl ctl) {
+  public ChangeInfo addChangeActions(ChangeInfo to, ChangeNotes notes) {
     List<ActionVisitor> visitors = visitors();
-    to.actions = toActionMap(ctl, visitors, copy(visitors, to));
+    to.actions = toActionMap(notes, visitors, copy(visitors, to));
     return to;
   }
 
@@ -109,8 +115,8 @@
     if (visitors.isEmpty()) {
       return null;
     }
-    // Include all fields from ChangeJson#toChangeInfo that are not protected by
-    // any ListChangesOptions.
+    // Include all fields from ChangeJson#toChangeInfo that are not protected by any
+    // ListChangesOptions.
     ChangeInfo copy = new ChangeInfo();
     copy.project = changeInfo.project;
     copy.branch = changeInfo.branch;
@@ -122,15 +128,21 @@
     copy.mergeable = changeInfo.mergeable;
     copy.insertions = changeInfo.insertions;
     copy.deletions = changeInfo.deletions;
+    copy.hasReviewStarted = changeInfo.hasReviewStarted;
+    copy.isPrivate = changeInfo.isPrivate;
     copy.subject = changeInfo.subject;
     copy.status = changeInfo.status;
     copy.owner = changeInfo.owner;
     copy.created = changeInfo.created;
     copy.updated = changeInfo.updated;
     copy._number = changeInfo._number;
+    copy.revertOf = changeInfo.revertOf;
     copy.starred = changeInfo.starred;
     copy.stars = changeInfo.stars;
     copy.submitted = changeInfo.submitted;
+    copy.submitter = changeInfo.submitter;
+    copy.unresolvedCommentCount = changeInfo.unresolvedCommentCount;
+    copy.workInProgress = changeInfo.workInProgress;
     copy.id = changeInfo.id;
     return copy;
   }
@@ -139,15 +151,14 @@
     if (visitors.isEmpty()) {
       return null;
     }
-    // Include all fields from ChangeJson#toRevisionInfo that are not protected
-    // by any ListChangesOptions.
+    // Include all fields from ChangeJson#toRevisionInfo that are not protected by any
+    // ListChangesOptions.
     RevisionInfo copy = new RevisionInfo();
     copy.isCurrent = revisionInfo.isCurrent;
     copy._number = revisionInfo._number;
     copy.ref = revisionInfo.ref;
     copy.created = revisionInfo.created;
     copy.uploader = revisionInfo.uploader;
-    copy.draft = revisionInfo.draft;
     copy.fetch = revisionInfo.fetch;
     copy.kind = revisionInfo.kind;
     copy.description = revisionInfo.description;
@@ -155,25 +166,27 @@
   }
 
   private Map<String, ActionInfo> toActionMap(
-      ChangeControl ctl, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
+      ChangeNotes notes, List<ActionVisitor> visitors, ChangeInfo changeInfo) {
+    CurrentUser user = userProvider.get();
     Map<String, ActionInfo> out = new LinkedHashMap<>();
-    if (!ctl.getUser().isIdentifiedUser()) {
+    if (!user.isIdentifiedUser()) {
       return out;
     }
 
-    Provider<CurrentUser> userProvider = Providers.of(ctl.getUser());
-    FluentIterable<UiAction.Description> descs =
-        UiActions.from(changeViews, changeResourceFactory.create(ctl), userProvider);
+    Iterable<UiAction.Description> descs =
+        uiActions.from(changeViews, changeResourceFactory.create(notes, user));
+
     // The followup action is a client-side only operation that does not
     // have a server side handler. It must be manually registered into the
     // resulting action map.
-    if (ctl.getChange().getStatus().isOpen()) {
+    Status status = notes.getChange().getStatus();
+    if (status.isOpen() || status.equals(Status.MERGED)) {
       UiAction.Description descr = new UiAction.Description();
       PrivateInternals_UiActionDescription.setId(descr, "followup");
       PrivateInternals_UiActionDescription.setMethod(descr, "POST");
       descr.setTitle("Create follow-up change");
       descr.setLabel("Follow-Up");
-      descs = descs.append(descr);
+      descs = Iterables.concat(descs, Collections.singleton(descr));
     }
 
     ACTION:
@@ -194,13 +207,13 @@
       List<ActionVisitor> visitors,
       ChangeInfo changeInfo,
       RevisionInfo revisionInfo) {
-    if (!rsrc.getControl().getUser().isIdentifiedUser()) {
+    if (!rsrc.getUser().isIdentifiedUser()) {
       return ImmutableMap.of();
     }
+
     Map<String, ActionInfo> out = new LinkedHashMap<>();
-    Provider<CurrentUser> userProvider = Providers.of(rsrc.getControl().getUser());
     ACTION:
-    for (UiAction.Description d : UiActions.from(revisions, rsrc, userProvider)) {
+    for (UiAction.Description d : uiActions.from(revisions, rsrc)) {
       ActionInfo actionInfo = new ActionInfo(d);
       for (ActionVisitor visitor : visitors) {
         if (!visitor.visit(d.getId(), actionInfo, changeInfo, revisionInfo)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java
new file mode 100644
index 0000000..fa26eec
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ApplyFix.java
@@ -0,0 +1,88 @@
+// 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.change;
+
+import com.google.gerrit.extensions.common.EditInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditJson;
+import com.google.gerrit.server.edit.ChangeEditModifier;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.fixes.FixReplacementInterpreter;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+@Singleton
+public class ApplyFix implements RestModifyView<FixResource, Void> {
+
+  private final GitRepositoryManager gitRepositoryManager;
+  private final FixReplacementInterpreter fixReplacementInterpreter;
+  private final ChangeEditModifier changeEditModifier;
+  private final ChangeEditJson changeEditJson;
+  private final ProjectCache projectCache;
+
+  @Inject
+  public ApplyFix(
+      GitRepositoryManager gitRepositoryManager,
+      FixReplacementInterpreter fixReplacementInterpreter,
+      ChangeEditModifier changeEditModifier,
+      ChangeEditJson changeEditJson,
+      ProjectCache projectCache) {
+    this.gitRepositoryManager = gitRepositoryManager;
+    this.fixReplacementInterpreter = fixReplacementInterpreter;
+    this.changeEditModifier = changeEditModifier;
+    this.changeEditJson = changeEditJson;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  public Response<EditInfo> apply(FixResource fixResource, Void nothing)
+      throws AuthException, OrmException, ResourceConflictException, IOException,
+          ResourceNotFoundException, PermissionBackendException {
+    RevisionResource revisionResource = fixResource.getRevisionResource();
+    Project.NameKey project = revisionResource.getProject();
+    ProjectState projectState = projectCache.checkedGet(project);
+    PatchSet patchSet = revisionResource.getPatchSet();
+    ObjectId patchSetCommitId = ObjectId.fromString(patchSet.getRevision().get());
+
+    try (Repository repository = gitRepositoryManager.openRepository(project)) {
+      List<TreeModification> treeModifications =
+          fixReplacementInterpreter.toTreeModifications(
+              repository, projectState, patchSetCommitId, fixResource.getFixReplacements());
+      ChangeEdit changeEdit =
+          changeEditModifier.combineWithModifiedPatchSetTree(
+              repository, revisionResource.getNotes(), patchSet, treeModifications);
+      return Response.ok(changeEditJson.toEditInfo(changeEdit, false));
+    } catch (InvalidChangeOperationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
index d1cd238..23c5b22 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeCleanupRunner.java
@@ -17,10 +17,13 @@
 import static com.google.gerrit.server.config.ScheduleConfig.MISSING_CONFIG;
 
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.ChangeCleanupConfig;
 import com.google.gerrit.server.config.ScheduleConfig;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gwtorm.server.OrmException;
@@ -79,19 +82,29 @@
 
   private final OneOffRequestContext oneOffRequestContext;
   private final AbandonUtil abandonUtil;
+  private final RetryHelper retryHelper;
 
   @Inject
-  ChangeCleanupRunner(OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil) {
+  ChangeCleanupRunner(
+      OneOffRequestContext oneOffRequestContext, AbandonUtil abandonUtil, RetryHelper retryHelper) {
     this.oneOffRequestContext = oneOffRequestContext;
     this.abandonUtil = abandonUtil;
+    this.retryHelper = retryHelper;
   }
 
   @Override
   public void run() {
     log.info("Running change cleanups.");
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
-      abandonUtil.abandonInactiveOpenChanges();
-    } catch (OrmException e) {
+      // abandonInactiveOpenChanges skips failures instead of throwing, so retrying will never
+      // actually happen. For the purposes of this class that is fine: they'll get tried again the
+      // next time the scheduled task is run.
+      retryHelper.execute(
+          updateFactory -> {
+            abandonUtil.abandonInactiveOpenChanges(updateFactory);
+            return null;
+          });
+    } catch (RestApiException | UpdateException | OrmException e) {
       log.error("Failed to cleanup changes.", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
index 108e180..08bcabe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEditResource.java
@@ -16,10 +16,7 @@
 
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.inject.TypeLiteral;
 
 /**
@@ -56,10 +53,6 @@
     return change;
   }
 
-  public ChangeControl getControl() {
-    return getChangeResource().getControl();
-  }
-
   public ChangeEdit getChangeEdit() {
     return edit;
   }
@@ -67,12 +60,4 @@
   public String getPath() {
     return path;
   }
-
-  Account.Id getAccountId() {
-    return getUser().getAccountId();
-  }
-
-  IdentifiedUser getUser() {
-    return edit.getUser();
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
index da92964..2ab2bf8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeEdits.java
@@ -46,8 +46,9 @@
 import com.google.gerrit.server.edit.UnchangedCommitMessageException;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -104,20 +105,18 @@
   @Override
   public ChangeEditResource parse(ChangeResource rsrc, IdString id)
       throws ResourceNotFoundException, AuthException, IOException, OrmException {
-    Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
+    Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
     if (!edit.isPresent()) {
       throw new ResourceNotFoundException(id);
     }
     return new ChangeEditResource(rsrc, edit.get(), id.get());
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public Create create(ChangeResource parent, IdString id) throws RestApiException {
     return createFactory.create(id.get());
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public Post post(ChangeResource parent) throws RestApiException {
     return post;
@@ -128,7 +127,6 @@
    * PUT request with a path was called but change edit wasn't created yet. Change edit is created
    * and PUT handler is called.
    */
-  @SuppressWarnings("unchecked")
   @Override
   public DeleteFile delete(ChangeResource parent, IdString id) throws RestApiException {
     // It's safe to assume that id can never be null, because
@@ -154,8 +152,9 @@
 
     @Override
     public Response<?> apply(ChangeResource resource, Put.Input input)
-        throws AuthException, ResourceConflictException, IOException, OrmException {
-      putEdit.apply(resource.getControl(), path, input.content);
+        throws AuthException, ResourceConflictException, IOException, OrmException,
+            PermissionBackendException {
+      putEdit.apply(resource, path, input.content);
       return Response.none();
     }
   }
@@ -178,8 +177,9 @@
 
     @Override
     public Response<?> apply(ChangeResource rsrc, DeleteFile.Input in)
-        throws IOException, AuthException, ResourceConflictException, OrmException {
-      return deleteContent.apply(rsrc.getControl(), path);
+        throws IOException, AuthException, ResourceConflictException, OrmException,
+            PermissionBackendException {
+      return deleteContent.apply(rsrc, path);
     }
   }
 
@@ -214,8 +214,9 @@
 
     @Override
     public Response<EditInfo> apply(ChangeResource rsrc)
-        throws AuthException, IOException, ResourceNotFoundException, OrmException {
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
+        throws AuthException, IOException, ResourceNotFoundException, OrmException,
+            PermissionBackendException {
+      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
       if (!edit.isPresent()) {
         return Response.none();
       }
@@ -229,7 +230,8 @@
         }
         try {
           editInfo.files =
-              fileInfoJson.toFileInfoMap(rsrc.getChange(), edit.get().getRevision(), basePatchSet);
+              fileInfoJson.toFileInfoMap(
+                  rsrc.getChange(), edit.get().getEditCommit(), basePatchSet);
         } catch (PatchListNotAvailableException e) {
           throw new ResourceNotFoundException(e.getMessage());
         }
@@ -267,16 +269,16 @@
 
     @Override
     public Response<?> apply(ChangeResource resource, Post.Input input)
-        throws AuthException, IOException, ResourceConflictException, OrmException {
+        throws AuthException, IOException, ResourceConflictException, OrmException,
+            PermissionBackendException {
       Project.NameKey project = resource.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
-        ChangeControl changeControl = resource.getControl();
         if (isRestoreFile(input)) {
-          editModifier.restoreFile(repository, changeControl, input.restorePath);
+          editModifier.restoreFile(repository, resource.getNotes(), input.restorePath);
         } else if (isRenameFile(input)) {
-          editModifier.renameFile(repository, changeControl, input.oldPath, input.newPath);
+          editModifier.renameFile(repository, resource.getNotes(), input.oldPath, input.newPath);
         } else {
-          editModifier.createEdit(repository, changeControl);
+          editModifier.createEdit(repository, resource.getNotes());
         }
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
@@ -313,19 +315,20 @@
 
     @Override
     public Response<?> apply(ChangeEditResource rsrc, Input input)
-        throws AuthException, ResourceConflictException, IOException, OrmException {
-      return apply(rsrc.getControl(), rsrc.getPath(), input.content);
+        throws AuthException, ResourceConflictException, IOException, OrmException,
+            PermissionBackendException {
+      return apply(rsrc.getChangeResource(), rsrc.getPath(), input.content);
     }
 
-    public Response<?> apply(ChangeControl changeControl, String path, RawInput newContent)
-        throws ResourceConflictException, AuthException, IOException, OrmException {
+    public Response<?> apply(ChangeResource rsrc, String path, RawInput newContent)
+        throws ResourceConflictException, AuthException, IOException, OrmException,
+            PermissionBackendException {
       if (Strings.isNullOrEmpty(path) || path.charAt(0) == '/') {
         throw new ResourceConflictException("Invalid path: " + path);
       }
 
-      Project.NameKey project = changeControl.getChange().getProject();
-      try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.modifyFile(repository, changeControl, path, newContent);
+      try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
+        editModifier.modifyFile(repository, rsrc.getNotes(), path, newContent);
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
@@ -355,15 +358,16 @@
 
     @Override
     public Response<?> apply(ChangeEditResource rsrc, DeleteContent.Input input)
-        throws AuthException, ResourceConflictException, OrmException, IOException {
-      return apply(rsrc.getControl(), rsrc.getPath());
+        throws AuthException, ResourceConflictException, OrmException, IOException,
+            PermissionBackendException {
+      return apply(rsrc.getChangeResource(), rsrc.getPath());
     }
 
-    public Response<?> apply(ChangeControl changeControl, String filePath)
-        throws AuthException, IOException, OrmException, ResourceConflictException {
-      Project.NameKey project = changeControl.getChange().getProject();
-      try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.deleteFile(repository, changeControl, filePath);
+    public Response<?> apply(ChangeResource rsrc, String filePath)
+        throws AuthException, IOException, OrmException, ResourceConflictException,
+            PermissionBackendException {
+      try (Repository repository = repositoryManager.openRepository(rsrc.getProject())) {
+        editModifier.deleteFile(repository, rsrc.getNotes(), filePath);
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
@@ -373,6 +377,7 @@
 
   public static class Get implements RestReadView<ChangeEditResource> {
     private final FileContentUtil fileContentUtil;
+    private final ProjectCache projectCache;
 
     @Option(
         name = "--base",
@@ -381,8 +386,9 @@
     private boolean base;
 
     @Inject
-    Get(FileContentUtil fileContentUtil) {
+    Get(FileContentUtil fileContentUtil, ProjectCache projectCache) {
       this.fileContentUtil = fileContentUtil;
+      this.projectCache = projectCache;
     }
 
     @Override
@@ -391,12 +397,13 @@
         ChangeEdit edit = rsrc.getChangeEdit();
         return Response.ok(
             fileContentUtil.getContent(
-                rsrc.getControl().getProjectControl().getProjectState(),
+                projectCache.checkedGet(rsrc.getChangeResource().getProject()),
                 base
                     ? ObjectId.fromString(edit.getBasePatchSet().getRevision().get())
-                    : ObjectId.fromString(edit.getRevision().get()),
-                rsrc.getPath()));
-      } catch (ResourceNotFoundException rnfe) {
+                    : edit.getEditCommit(),
+                rsrc.getPath(),
+                null));
+      } catch (ResourceNotFoundException | BadRequestException e) {
         return Response.none();
       }
     }
@@ -453,15 +460,14 @@
     @Override
     public Object apply(ChangeResource rsrc, Input input)
         throws AuthException, IOException, BadRequestException, ResourceConflictException,
-            OrmException {
+            OrmException, PermissionBackendException {
       if (input == null || Strings.isNullOrEmpty(input.message)) {
         throw new BadRequestException("commit message must be provided");
       }
 
       Project.NameKey project = rsrc.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
-        ChangeControl changeControl = rsrc.getControl();
-        editModifier.modifyMessage(repository, changeControl, input.message);
+        editModifier.modifyMessage(repository, rsrc.getNotes(), input.message);
       } catch (UnchangedCommitMessageException e) {
         throw new ResourceConflictException(e.getMessage());
       }
@@ -489,7 +495,7 @@
     @Override
     public BinaryResult apply(ChangeResource rsrc)
         throws AuthException, IOException, ResourceNotFoundException, OrmException {
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
+      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
       String msg;
       if (edit.isPresent()) {
         if (base) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeIncludedIn.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeIncludedIn.java
index f852a97..47f5a16 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeIncludedIn.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeIncludedIn.java
@@ -18,10 +18,8 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -44,9 +42,7 @@
   @Override
   public IncludedInInfo apply(ChangeResource rsrc)
       throws RestApiException, OrmException, IOException {
-    ChangeControl ctl = rsrc.getControl();
     PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
-    Project.NameKey project = ctl.getProject().getNameKey();
-    return includedIn.apply(project, ps.getRevision().get());
+    return includedIn.apply(rsrc.getProject(), ps.getRevision().get());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index ad49701..17a28cd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -51,11 +51,13 @@
 import com.google.gerrit.server.mail.send.CreateChangeSender;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
@@ -68,6 +70,7 @@
 import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -75,6 +78,7 @@
 import java.util.concurrent.Future;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.eclipse.jgit.util.ChangeIdUtil;
 import org.slf4j.Logger;
@@ -82,14 +86,14 @@
 
 public class ChangeInserter implements InsertChangeOp {
   public interface Factory {
-    ChangeInserter create(Change.Id cid, RevCommit rc, String refName);
+    ChangeInserter create(Change.Id cid, ObjectId commitId, String refName);
   }
 
   private static final Logger log = LoggerFactory.getLogger(ChangeInserter.class);
 
-  private final ProjectControl.GenericFactory projectControlFactory;
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
   private final IdentifiedUser.GenericFactory userFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final PatchSetUtil psUtil;
   private final ApprovalsUtil approvalsUtil;
@@ -99,10 +103,11 @@
   private final CommitValidators.Factory commitValidatorsFactory;
   private final RevisionCreated revisionCreated;
   private final CommentAdded commentAdded;
+  private final NotesMigration migration;
 
   private final Change.Id changeId;
   private final PatchSet.Id psId;
-  private final RevCommit commit;
+  private final ObjectId commitId;
   private final String refName;
 
   // Fields exposed as setters.
@@ -110,31 +115,35 @@
   private String topic;
   private String message;
   private String patchSetDescription;
+  private boolean isPrivate;
+  private boolean workInProgress;
   private List<String> groups = Collections.emptyList();
-  private CommitValidators.Policy validatePolicy = CommitValidators.Policy.GERRIT;
+  private boolean validate = true;
   private NotifyHandling notify = NotifyHandling.ALL;
   private ListMultimap<RecipientType, Account.Id> accountsToNotify = ImmutableListMultimap.of();
   private Set<Account.Id> reviewers;
   private Set<Account.Id> extraCC;
   private Map<String, Short> approvals;
   private RequestScopePropagator requestScopePropagator;
-  private ReceiveCommand updateRefCommand;
   private boolean fireRevisionCreated;
   private boolean sendMail;
   private boolean updateRef;
+  private Change.Id revertOf;
 
   // Fields set during the insertion process.
+  private ReceiveCommand cmd;
   private Change change;
   private ChangeMessage changeMessage;
   private PatchSetInfo patchSetInfo;
   private PatchSet patchSet;
   private String pushCert;
+  private ProjectState projectState;
 
   @Inject
   ChangeInserter(
-      ProjectControl.GenericFactory projectControlFactory,
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache,
       IdentifiedUser.GenericFactory userFactory,
-      ChangeControl.GenericFactory changeControlFactory,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetUtil psUtil,
       ApprovalsUtil approvalsUtil,
@@ -144,12 +153,13 @@
       CommitValidators.Factory commitValidatorsFactory,
       CommentAdded commentAdded,
       RevisionCreated revisionCreated,
+      NotesMigration migration,
       @Assisted Change.Id changeId,
-      @Assisted RevCommit commit,
+      @Assisted ObjectId commitId,
       @Assisted String refName) {
-    this.projectControlFactory = projectControlFactory;
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
     this.userFactory = userFactory;
-    this.changeControlFactory = changeControlFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.psUtil = psUtil;
     this.approvalsUtil = approvalsUtil;
@@ -159,57 +169,63 @@
     this.commitValidatorsFactory = commitValidatorsFactory;
     this.revisionCreated = revisionCreated;
     this.commentAdded = commentAdded;
+    this.migration = migration;
 
     this.changeId = changeId;
     this.psId = new PatchSet.Id(changeId, INITIAL_PATCH_SET_ID);
-    this.commit = commit;
+    this.commitId = commitId.copy();
     this.refName = refName;
     this.reviewers = Collections.emptySet();
     this.extraCC = Collections.emptySet();
     this.approvals = Collections.emptyMap();
-    this.updateRefCommand = null;
     this.fireRevisionCreated = true;
     this.sendMail = true;
     this.updateRef = true;
   }
 
   @Override
-  public Change createChange(Context ctx) {
+  public Change createChange(Context ctx) throws IOException {
     change =
         new Change(
-            getChangeKey(commit),
+            getChangeKey(ctx.getRevWalk(), commitId),
             changeId,
             ctx.getAccountId(),
             new Branch.NameKey(ctx.getProject(), refName),
             ctx.getWhen());
     change.setStatus(MoreObjects.firstNonNull(status, Change.Status.NEW));
     change.setTopic(topic);
+    change.setPrivate(isPrivate);
+    change.setWorkInProgress(workInProgress);
+    change.setReviewStarted(!workInProgress);
+    change.setRevertOf(revertOf);
     return change;
   }
 
-  private static Change.Key getChangeKey(RevCommit commit) {
+  private static Change.Key getChangeKey(RevWalk rw, ObjectId id) throws IOException {
+    RevCommit commit = rw.parseCommit(id);
+    rw.parseBody(commit);
     List<String> idList = commit.getFooterLines(FooterConstants.CHANGE_ID);
     if (!idList.isEmpty()) {
       return new Change.Key(idList.get(idList.size() - 1).trim());
     }
-    ObjectId id =
+    ObjectId changeId =
         ChangeIdUtil.computeChangeId(
             commit.getTree(),
             commit,
             commit.getAuthorIdent(),
             commit.getCommitterIdent(),
             commit.getShortMessage());
-    StringBuilder changeId = new StringBuilder();
-    changeId.append("I").append(ObjectId.toString(id));
-    return new Change.Key(changeId.toString());
+    StringBuilder changeIdStr = new StringBuilder();
+    changeIdStr.append("I").append(ObjectId.toString(changeId));
+    return new Change.Key(changeIdStr.toString());
   }
 
   public PatchSet.Id getPatchSetId() {
     return psId;
   }
 
-  public RevCommit getCommit() {
-    return commit;
+  public ObjectId getCommitId() {
+    return commitId;
   }
 
   public Change getChange() {
@@ -233,8 +249,8 @@
     return this;
   }
 
-  public ChangeInserter setValidatePolicy(CommitValidators.Policy validate) {
-    this.validatePolicy = checkNotNull(validate);
+  public ChangeInserter setValidate(boolean validate) {
+    this.validate = validate;
     return this;
   }
 
@@ -259,9 +275,15 @@
     return this;
   }
 
-  public ChangeInserter setDraft(boolean draft) {
-    checkState(change == null, "setDraft(boolean) only valid before creating change");
-    return setStatus(draft ? Change.Status.DRAFT : Change.Status.NEW);
+  public ChangeInserter setPrivate(boolean isPrivate) {
+    checkState(change == null, "setPrivate(boolean) only valid before creating change");
+    this.isPrivate = isPrivate;
+    return this;
+  }
+
+  public ChangeInserter setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+    return this;
   }
 
   public ChangeInserter setStatus(Change.Status status) {
@@ -292,8 +314,9 @@
     return this;
   }
 
-  public void setUpdateRefCommand(ReceiveCommand cmd) {
-    updateRefCommand = cmd;
+  public ChangeInserter setRevertOf(Change.Id revertOf) {
+    this.revertOf = revertOf;
+    return this;
   }
 
   public void setPushCertificate(String cert) {
@@ -310,6 +333,18 @@
     return this;
   }
 
+  /**
+   * Set whether to include the new patch set ref update in this update.
+   *
+   * <p>If false, the caller is responsible for creating the patch set ref <strong>before</strong>
+   * executing the containing {@code BatchUpdate}.
+   *
+   * <p>Should not be used in new code, as it doesn't result in a single atomic batch ref update for
+   * code and NoteDb meta refs.
+   *
+   * @param updateRef whether to update the ref during {@code updateRepo}.
+   */
+  @Deprecated
   public ChangeInserter setUpdateRef(boolean updateRef) {
     this.updateRef = updateRef;
     return this;
@@ -323,26 +358,28 @@
     return changeMessage;
   }
 
+  public ReceiveCommand getCommand() {
+    return cmd;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx) throws ResourceConflictException, IOException {
+    cmd = new ReceiveCommand(ObjectId.zeroId(), commitId, psId.toRefName());
+    projectState = projectCache.checkedGet(ctx.getProject());
     validate(ctx);
     if (!updateRef) {
       return;
     }
-    if (updateRefCommand == null) {
-      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), commit, psId.toRefName()));
-    } else {
-      ctx.addRefUpdate(updateRefCommand);
-    }
+    ctx.addRefUpdate(cmd);
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException {
+      throws RestApiException, OrmException, IOException, PermissionBackendException {
     change = ctx.getChange(); // Use defensive copy created by ChangeControl.
     ReviewDb db = ctx.getDb();
-    ChangeControl ctl = ctx.getControl();
-    patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
+    patchSetInfo =
+        patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
     ctx.getChange().setCurrentPatchSet(patchSetInfo);
 
     ChangeUpdate update = ctx.getUpdate(psId);
@@ -351,11 +388,15 @@
     update.setBranch(change.getDest().get());
     update.setTopic(change.getTopic());
     update.setPsDescription(patchSetDescription);
+    update.setPrivate(isPrivate);
+    update.setWorkInProgress(workInProgress);
+    if (revertOf != null) {
+      update.setRevertOf(revertOf.get());
+    }
 
-    boolean draft = status == Change.Status.DRAFT;
     List<String> newGroups = groups;
     if (newGroups.isEmpty()) {
-      newGroups = GroupCollector.getDefaultGroups(commit);
+      newGroups = GroupCollector.getDefaultGroups(commitId);
     }
     patchSet =
         psUtil.insert(
@@ -363,8 +404,7 @@
             ctx.getRevWalk(),
             update,
             psId,
-            commit,
-            draft,
+            commitId,
             newGroups,
             pushCert,
             patchSetDescription);
@@ -379,7 +419,15 @@
      */
     update.fixStatus(change.getStatus());
 
-    LabelTypes labelTypes = ctl.getProjectControl().getLabelTypes();
+    Set<Account.Id> reviewersToAdd = new HashSet<>(reviewers);
+    if (migration.readChanges()) {
+      approvalsUtil.addCcs(
+          ctx.getNotes(), update, filterOnChangeVisibility(db, ctx.getNotes(), extraCC));
+    } else {
+      reviewersToAdd.addAll(extraCC);
+    }
+
+    LabelTypes labelTypes = projectState.getLabelTypes();
     approvalsUtil.addReviewers(
         db,
         update,
@@ -387,10 +435,10 @@
         change,
         patchSet,
         patchSetInfo,
-        filterOnChangeVisibility(db, ctx.getNotes(), reviewers),
+        filterOnChangeVisibility(db, ctx.getNotes(), reviewersToAdd),
         Collections.<Account.Id>emptySet());
     approvalsUtil.addApprovalsForNewPatchSet(
-        db, update, labelTypes, patchSet, ctx.getControl(), approvals);
+        db, update, labelTypes, patchSet, ctx.getUser(), approvals);
     // Check if approvals are changing in with this update. If so, add current user to reviewers.
     // Note that this is done separately as addReviewers is filtering out the change owner as
     // reviewer which is needed in several other code paths.
@@ -404,22 +452,26 @@
               ctx.getUser(),
               patchSet.getCreatedOn(),
               message,
-              ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+              ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
       cmUtil.addChangeMessage(db, update, changeMessage);
     }
     return true;
   }
 
   private Set<Account.Id> filterOnChangeVisibility(
-      final ReviewDb db, final ChangeNotes notes, Set<Account.Id> accounts) {
+      final ReviewDb db, ChangeNotes notes, Set<Account.Id> accounts) {
     return accounts
         .stream()
         .filter(
             accountId -> {
               try {
                 IdentifiedUser user = userFactory.create(accountId);
-                return changeControlFactory.controlFor(notes, user).isVisible(db);
-              } catch (OrmException e) {
+                return permissionBackend
+                    .user(user)
+                    .change(notes)
+                    .database(db)
+                    .test(ChangePermission.READ);
+              } catch (PermissionBackendException e) {
                 log.warn(
                     "Failed to check if account {} can see change {}",
                     accountId.get(),
@@ -432,7 +484,7 @@
   }
 
   @Override
-  public void postUpdate(Context ctx) throws OrmException {
+  public void postUpdate(Context ctx) throws OrmException, IOException {
     if (sendMail && (notify != NotifyHandling.NONE || !accountsToNotify.isEmpty())) {
       Runnable sender =
           new Runnable() {
@@ -475,9 +527,8 @@
     if (fireRevisionCreated) {
       revisionCreated.fire(change, patchSet, ctx.getAccount(), ctx.getWhen(), notify);
       if (approvals != null && !approvals.isEmpty()) {
-        ChangeControl changeControl =
-            changeControlFactory.controlFor(ctx.getDb(), change, ctx.getUser());
-        List<LabelType> labels = changeControl.getLabelTypes().getLabelTypes();
+        List<LabelType> labels =
+            projectState.getLabelTypes(change.getDest(), ctx.getUser()).getLabelTypes();
         Map<String, Short> allApprovals = new HashMap<>();
         Map<String, Short> oldApprovals = new HashMap<>();
         for (LabelType lt : labels) {
@@ -497,28 +548,32 @@
   }
 
   private void validate(RepoContext ctx) throws IOException, ResourceConflictException {
-    if (validatePolicy == CommitValidators.Policy.NONE) {
+    if (!validate) {
       return;
     }
 
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(ctx.getUser()).project(ctx.getProject()).ref(refName);
     try {
-      RefControl refControl =
-          projectControlFactory.controlFor(ctx.getProject(), ctx.getUser()).controlForRef(refName);
-      String refName = psId.toRefName();
-      CommitReceivedEvent event =
+      try (CommitReceivedEvent event =
           new CommitReceivedEvent(
-              new ReceiveCommand(ObjectId.zeroId(), commit.getId(), refName),
-              refControl.getProjectControl().getProject(),
+              cmd,
+              projectState.getProject(),
               change.getDest().get(),
-              commit,
-              ctx.getIdentifiedUser());
-      commitValidatorsFactory
-          .create(validatePolicy, refControl, new NoSshInfo(), ctx.getRepository())
-          .validate(event);
+              ctx.getRevWalk().getObjectReader(),
+              commitId,
+              ctx.getIdentifiedUser())) {
+        commitValidatorsFactory
+            .forGerritCommits(
+                perm,
+                new Branch.NameKey(ctx.getProject(), refName),
+                ctx.getIdentifiedUser(),
+                new NoSshInfo(),
+                ctx.getRevWalk())
+            .validate(event);
+      }
     } catch (CommitValidationException e) {
       throw new ResourceConflictException(e.getFullMessage());
-    } catch (NoSuchProjectException e) {
-      throw new ResourceConflictException(e.getMessage());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
index 41d101b..7769dbb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeJson.java
@@ -34,6 +34,7 @@
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWER_UPDATES;
 import static com.google.gerrit.extensions.client.ListChangesOption.SUBMITTABLE;
+import static com.google.gerrit.extensions.client.ListChangesOption.TRACKING_IDS;
 import static com.google.gerrit.extensions.client.ListChangesOption.WEB_LINKS;
 import static com.google.gerrit.server.CommonConverters.toGitPerson;
 import static java.util.stream.Collectors.toList;
@@ -48,6 +49,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedHashMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
@@ -59,8 +61,6 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
 import com.google.gerrit.extensions.api.changes.FixInput;
@@ -77,12 +77,15 @@
 import com.google.gerrit.extensions.common.PushCertificateInfo;
 import com.google.gerrit.extensions.common.ReviewerUpdateInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.common.TrackingIdInfo;
 import com.google.gerrit.extensions.common.VotingRangeInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.config.DownloadCommand;
 import com.google.gerrit.extensions.config.DownloadScheme;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -97,32 +100,40 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.ReviewerByEmailSet;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gerrit.server.api.accounts.GpgApiAdapter;
+import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.LabelNormalizer;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.RemoveReviewerControl;
 import com.google.gerrit.server.project.SubmitRuleOptions;
-import com.google.gerrit.server.query.QueryResult;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeData.ChangedLines;
+import com.google.gerrit.server.query.change.PluginDefinedAttributesFactory;
 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.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -157,7 +168,15 @@
       ChangeField.SUBMIT_RULE_OPTIONS_STRICT.toBuilder().fastEvalLabels(true).build();
 
   public static final ImmutableSet<ListChangesOption> REQUIRE_LAZY_LOAD =
-      ImmutableSet.of(ALL_REVISIONS, MESSAGES);
+      ImmutableSet.of(
+          ALL_COMMITS,
+          ALL_REVISIONS,
+          CHANGE_ACTIONS,
+          CHECK,
+          COMMIT_FOOTERS,
+          CURRENT_ACTIONS,
+          CURRENT_COMMIT,
+          MESSAGES);
 
   @Singleton
   public static class Factory {
@@ -186,9 +205,9 @@
   }
 
   private final Provider<ReviewDb> db;
-  private final LabelNormalizer labelNormalizer;
   private final Provider<CurrentUser> userProvider;
   private final AnonymousUser anonymous;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
   private final MergeUtil.Factory mergeUtilFactory;
@@ -209,17 +228,19 @@
   private final ChangeKindCache changeKindCache;
   private final ChangeIndexCollection indexes;
   private final ApprovalsUtil approvalsUtil;
-
+  private final RemoveReviewerControl removeReviewerControl;
+  private final TrackingFooters trackingFooters;
   private boolean lazyLoad = true;
   private AccountLoader accountLoader;
   private FixInput fix;
+  private PluginDefinedAttributesFactory pluginDefinedAttributesFactory;
 
-  @AssistedInject
+  @Inject
   ChangeJson(
       Provider<ReviewDb> db,
-      LabelNormalizer ln,
       Provider<CurrentUser> user,
       AnonymousUser au,
+      PermissionBackend permissionBackend,
       GitRepositoryManager repoManager,
       ProjectCache projectCache,
       MergeUtil.Factory mergeUtilFactory,
@@ -239,12 +260,14 @@
       ChangeKindCache changeKindCache,
       ChangeIndexCollection indexes,
       ApprovalsUtil approvalsUtil,
+      RemoveReviewerControl removeReviewerControl,
+      TrackingFooters trackingFooters,
       @Assisted Iterable<ListChangesOption> options) {
     this.db = db;
-    this.labelNormalizer = ln;
     this.userProvider = user;
     this.anonymous = au;
     this.changeDataFactory = cdf;
+    this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.userFactory = uf;
     this.projectCache = projectCache;
@@ -263,7 +286,9 @@
     this.changeKindCache = changeKindCache;
     this.indexes = indexes;
     this.approvalsUtil = approvalsUtil;
+    this.removeReviewerControl = removeReviewerControl;
     this.options = Sets.immutableEnumSet(options);
+    this.trackingFooters = trackingFooters;
   }
 
   public ChangeJson lazyLoad(boolean load) {
@@ -276,8 +301,12 @@
     return this;
   }
 
+  public void setPluginDefinedAttributesFactory(PluginDefinedAttributesFactory pluginsFactory) {
+    this.pluginDefinedAttributesFactory = pluginsFactory;
+  }
+
   public ChangeInfo format(ChangeResource rsrc) throws OrmException {
-    return format(changeDataFactory.create(db.get(), rsrc.getControl()));
+    return format(changeDataFactory.create(db.get(), rsrc.getNotes()));
   }
 
   public ChangeInfo format(Change change) throws OrmException {
@@ -316,6 +345,8 @@
         | GpgException
         | OrmException
         | IOException
+        | PermissionBackendException
+        | NoSuchProjectException
         | RuntimeException e) {
       if (!has(CHECK)) {
         Throwables.throwIfInstanceOf(e, OrmException.class);
@@ -326,7 +357,7 @@
   }
 
   public ChangeInfo format(RevisionResource rsrc) throws OrmException {
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getControl());
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
     return format(cd, Optional.of(rsrc.getPatchSet().getId()), true);
   }
 
@@ -393,9 +424,17 @@
             | GpgException
             | OrmException
             | IOException
+            | PermissionBackendException
+            | NoSuchProjectException
             | RuntimeException e) {
           if (has(CHECK)) {
             i = checkOnly(cd);
+          } else if (e instanceof NoSuchChangeException) {
+            log.info(
+                "NoSuchChangeException: Omitting corrupt change "
+                    + cd.getId()
+                    + " from results. Seems to be stale in the index.");
+            continue;
           } else {
             log.warn("Omitting corrupt change " + cd.getId() + " from results", e);
             continue;
@@ -409,9 +448,9 @@
   }
 
   private ChangeInfo checkOnly(ChangeData cd) {
-    ChangeControl ctl;
+    ChangeNotes notes;
     try {
-      ctl = cd.changeControl().forUser(userProvider.get());
+      notes = cd.notes();
     } catch (OrmException e) {
       String msg = "Error loading change";
       log.warn(msg + " " + cd.getId(), e);
@@ -423,7 +462,7 @@
       return info;
     }
 
-    ConsistencyChecker.Result result = checkerProvider.get().check(ctl, fix);
+    ConsistencyChecker.Result result = checkerProvider.get().check(notes, fix);
     ChangeInfo info;
     Change c = result.change();
     if (c != null) {
@@ -439,6 +478,9 @@
       info.updated = c.getLastUpdatedOn();
       info._number = c.getId().get();
       info.problems = result.problems();
+      info.isPrivate = c.isPrivate() ? true : null;
+      info.workInProgress = c.isWorkInProgress() ? true : null;
+      info.hasReviewStarted = c.hasReviewStarted();
       finish(info);
     } else {
       info = new ChangeInfo();
@@ -449,13 +491,13 @@
   }
 
   private ChangeInfo toChangeInfo(ChangeData cd, Optional<PatchSet.Id> limitToPsId)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException, NoSuchProjectException {
     ChangeInfo out = new ChangeInfo();
     CurrentUser user = userProvider.get();
-    ChangeControl ctl = cd.changeControl().forUser(user);
 
     if (has(CHECK)) {
-      out.problems = checkerProvider.get().check(ctl, fix).problems();
+      out.problems = checkerProvider.get().check(cd.notes(), fix).problems();
       // If any problems were fixed, the ChangeData needs to be reloaded.
       for (ProblemInfo p : out.problems) {
         if (p.status == ProblemInfo.Status.FIXED) {
@@ -465,6 +507,7 @@
       }
     }
 
+    PermissionBackend.ForChange perm = permissionBackendForChange(user, cd);
     Change in = cd.change();
     out.project = in.getProject().get();
     out.branch = in.getDest().getShortName();
@@ -491,6 +534,9 @@
       out.insertions = changedLines.get().insertions;
       out.deletions = changedLines.get().deletions;
     }
+    out.isPrivate = in.isPrivate() ? true : null;
+    out.workInProgress = in.isWorkInProgress() ? true : null;
+    out.hasReviewStarted = in.hasReviewStarted();
     out.subject = in.getSubject();
     out.status = in.getStatus().asChangeStatus();
     out.owner = accountLoader.get(in.getOwner());
@@ -508,32 +554,32 @@
     }
 
     if (in.getStatus().isOpen() && has(REVIEWED) && user.isIdentifiedUser()) {
-      Account.Id accountId = user.getAccountId();
-      out.reviewed = cd.reviewedBy().contains(accountId) ? true : null;
+      out.reviewed = cd.isReviewedBy(user.getAccountId()) ? true : null;
     }
 
-    out.labels = labelsFor(ctl, cd, has(LABELS), has(DETAILED_LABELS));
-    out.submitted = getSubmittedOn(cd);
+    out.labels = labelsFor(perm, cd, has(LABELS), has(DETAILED_LABELS));
 
     if (out.labels != null && has(DETAILED_LABELS)) {
       // If limited to specific patch sets but not the current patch set, don't
       // list permitted labels, since users can't vote on those patch sets.
-      if (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId())) {
+      if (user.isIdentifiedUser()
+          && (!limitToPsId.isPresent() || limitToPsId.get().equals(in.currentPatchSetId()))) {
         out.permittedLabels =
             cd.change().getStatus() != Change.Status.ABANDONED
-                ? permittedLabels(ctl, cd)
+                ? permittedLabels(perm, cd)
                 : ImmutableMap.of();
       }
 
-      out.reviewers = new HashMap<>();
-      for (Map.Entry<ReviewerStateInternal, Map<Account.Id, Timestamp>> e :
-          cd.reviewers().asTable().rowMap().entrySet()) {
-        out.reviewers.put(e.getKey().asReviewerState(), toAccountInfo(e.getValue().keySet()));
-      }
-
-      out.removableReviewers = removableReviewers(ctl, out);
+      out.reviewers = reviewerMap(cd.reviewers(), cd.reviewersByEmail(), false);
+      out.pendingReviewers = reviewerMap(cd.pendingReviewers(), cd.pendingReviewersByEmail(), true);
+      out.removableReviewers = removableReviewers(cd, out);
     }
 
+    setSubmitter(cd, out);
+    out.plugins =
+        pluginDefinedAttributesFactory != null ? pluginDefinedAttributesFactory.create(cd) : null;
+    out.revertOf = cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null;
+
     if (has(REVIEWER_UPDATES)) {
       out.reviewerUpdates = reviewerUpdates(cd);
     }
@@ -546,15 +592,16 @@
     } else {
       src = null;
     }
+
     if (needMessages) {
-      out.messages = messages(ctl, cd, src);
+      out.messages = messages(cd);
     }
     finish(out);
 
     // This block must come after the ChangeInfo is mostly populated, since
     // it will be passed to ActionVisitors as-is.
     if (needRevisions) {
-      out.revisions = revisions(ctl, cd, src, out);
+      out.revisions = revisions(cd, src, limitToPsId, out);
       if (out.revisions != null) {
         for (Map.Entry<String, RevisionInfo> entry : out.revisions.entrySet()) {
           if (entry.getValue().isCurrent) {
@@ -566,12 +613,37 @@
     }
 
     if (has(CURRENT_ACTIONS) || has(CHANGE_ACTIONS)) {
-      actionJson.addChangeActions(out, ctl);
+      actionJson.addChangeActions(out, cd.notes());
+    }
+
+    if (has(TRACKING_IDS)) {
+      ListMultimap<String, String> set = trackingFooters.extract(cd.commitFooters());
+      out.trackingIds =
+          set.entries()
+              .stream()
+              .map(e -> new TrackingIdInfo(e.getKey(), e.getValue()))
+              .collect(toList());
     }
 
     return out;
   }
 
+  private Map<ReviewerState, Collection<AccountInfo>> reviewerMap(
+      ReviewerSet reviewers, ReviewerByEmailSet reviewersByEmail, boolean includeRemoved) {
+    Map<ReviewerState, Collection<AccountInfo>> reviewerMap = new HashMap<>();
+    for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
+      if (!includeRemoved && state == ReviewerStateInternal.REMOVED) {
+        continue;
+      }
+      Collection<AccountInfo> reviewersByState = toAccountInfo(reviewers.byState(state));
+      reviewersByState.addAll(toAccountInfoByEmail(reviewersByEmail.byState(state)));
+      if (!reviewersByState.isEmpty()) {
+        reviewerMap.put(state.asReviewerState(), reviewersByState);
+      }
+    }
+    return reviewerMap;
+  }
+
   private Collection<ReviewerUpdateInfo> reviewerUpdates(ChangeData cd) throws OrmException {
     List<ReviewerStatusUpdate> reviewerUpdates = cd.reviewerUpdates();
     List<ReviewerUpdateInfo> result = new ArrayList<>(reviewerUpdates.size());
@@ -595,29 +667,30 @@
   }
 
   private Map<String, LabelInfo> labelsFor(
-      ChangeControl ctl, ChangeData cd, boolean standard, boolean detailed) throws OrmException {
+      PermissionBackend.ForChange perm, ChangeData cd, boolean standard, boolean detailed)
+      throws OrmException, PermissionBackendException {
     if (!standard && !detailed) {
       return null;
     }
 
-    if (ctl == null) {
-      return null;
-    }
-
-    LabelTypes labelTypes = ctl.getLabelTypes();
+    LabelTypes labelTypes = cd.getLabelTypes();
     Map<String, LabelWithStatus> withStatus =
-        cd.change().getStatus().isOpen()
-            ? labelsForOpenChange(ctl, cd, labelTypes, standard, detailed)
-            : labelsForClosedChange(ctl, cd, labelTypes, standard, detailed);
+        cd.change().getStatus() == Change.Status.MERGED
+            ? labelsForSubmittedChange(perm, cd, labelTypes, standard, detailed)
+            : labelsForUnsubmittedChange(perm, cd, labelTypes, standard, detailed);
     return ImmutableMap.copyOf(Maps.transformValues(withStatus, LabelWithStatus::label));
   }
 
-  private Map<String, LabelWithStatus> labelsForOpenChange(
-      ChangeControl ctl, ChangeData cd, LabelTypes labelTypes, boolean standard, boolean detailed)
-      throws OrmException {
+  private Map<String, LabelWithStatus> labelsForUnsubmittedChange(
+      PermissionBackend.ForChange perm,
+      ChangeData cd,
+      LabelTypes labelTypes,
+      boolean standard,
+      boolean detailed)
+      throws OrmException, PermissionBackendException {
     Map<String, LabelWithStatus> labels = initLabels(cd, labelTypes, standard);
     if (detailed) {
-      setAllApprovals(ctl, cd, labels);
+      setAllApprovals(perm, cd, labels);
     }
     for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
       LabelType type = labelTypes.byLabel(e.getKey());
@@ -642,7 +715,6 @@
 
   private Map<String, LabelWithStatus> initLabels(
       ChangeData cd, LabelTypes labelTypes, boolean standard) throws OrmException {
-    // Don't use Maps.newTreeMap(Comparator) due to OpenJDK bug 100167.
     Map<String, LabelWithStatus> labels = new TreeMap<>(labelTypes.nameComparator());
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels == null) {
@@ -704,10 +776,11 @@
   }
 
   private void setAllApprovals(
-      ChangeControl baseCtrl, ChangeData cd, Map<String, LabelWithStatus> labels)
-      throws OrmException {
+      PermissionBackend.ForChange basePerm, ChangeData cd, Map<String, LabelWithStatus> labels)
+      throws OrmException, PermissionBackendException {
     Change.Status status = cd.change().getStatus();
-    checkState(status.isOpen(), "should not call setAllApprovals on %s change", status);
+    checkState(
+        status != Change.Status.MERGED, "should not call setAllApprovals on %s change", status);
 
     // Include a user in the output for this label if either:
     //  - They are an explicit reviewer.
@@ -719,17 +792,17 @@
     }
 
     Table<Account.Id, String, PatchSetApproval> current =
-        HashBasedTable.create(allUsers.size(), baseCtrl.getLabelTypes().getLabelTypes().size());
+        HashBasedTable.create(allUsers.size(), cd.getLabelTypes().getLabelTypes().size());
     for (PatchSetApproval psa : cd.currentApprovals()) {
       current.put(psa.getAccountId(), psa.getLabel(), psa);
     }
 
+    LabelTypes labelTypes = cd.getLabelTypes();
     for (Account.Id accountId : allUsers) {
-      IdentifiedUser user = userFactory.create(accountId);
-      ChangeControl ctl = baseCtrl.forUser(user);
-      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(ctl, cd));
+      PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
+      Map<String, VotingRangeInfo> pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
       for (Map.Entry<String, LabelWithStatus> e : labels.entrySet()) {
-        LabelType lt = ctl.getLabelTypes().byLabel(e.getKey());
+        LabelType lt = labelTypes.byLabel(e.getKey());
         if (lt == null) {
           // Ignore submit record for undefined label; likely the submit rule
           // author didn't intend for the label to show up in the table.
@@ -746,7 +819,7 @@
             // This may be a dummy approval that was inserted when the reviewer
             // was added. Explicitly check whether the user can vote on this
             // label.
-            value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
+            value = perm.test(new LabelPermission(lt)) ? 0 : null;
           }
           tag = psa.getTag();
           date = psa.getGranted();
@@ -757,7 +830,7 @@
           // Either the user cannot vote on this label, or they were added as a
           // reviewer but have not responded yet. Explicitly check whether the
           // user can vote on this label.
-          value = labelNormalizer.canVote(ctl, lt, accountId) ? 0 : null;
+          value = perm.test(new LabelPermission(lt)) ? 0 : null;
         }
         addApproval(
             e.getValue().label(), approvalInfo(accountId, value, permittedVotingRange, tag, date));
@@ -799,18 +872,22 @@
     return Ints.tryParse(value);
   }
 
-  private Timestamp getSubmittedOn(ChangeData cd) throws OrmException {
+  private void setSubmitter(ChangeData cd, ChangeInfo out) throws OrmException {
     Optional<PatchSetApproval> s = cd.getSubmitApproval();
-    return s.isPresent() ? s.get().getGranted() : null;
+    if (!s.isPresent()) {
+      return;
+    }
+    out.submitted = s.get().getGranted();
+    out.submitter = accountLoader.get(s.get().getAccountId());
   }
 
-  private Map<String, LabelWithStatus> labelsForClosedChange(
-      ChangeControl baseCtrl,
+  private Map<String, LabelWithStatus> labelsForSubmittedChange(
+      PermissionBackend.ForChange basePerm,
       ChangeData cd,
       LabelTypes labelTypes,
       boolean standard,
       boolean detailed)
-      throws OrmException {
+      throws OrmException, PermissionBackendException {
     Set<Account.Id> allUsers = new HashSet<>();
     if (detailed) {
       // Users expect to see all reviewers on closed changes, even if they
@@ -836,32 +913,22 @@
       }
     }
 
+    // Since voting on merged changes is allowed all labels which apply to
+    // the change must be returned. All applying labels can be retrieved from
+    // the submit records, which is what initLabels does.
+    // It's not possible to only compute the labels based on the approvals
+    // since merged changes may not have approvals for all labels (e.g. if not
+    // all labels are required for submit or if the change was auto-closed due
+    // to direct push or if new labels were defined after the change was
+    // merged).
     Map<String, LabelWithStatus> labels;
-    if (cd.change().getStatus() == Change.Status.MERGED) {
-      // Since voting on merged changes is allowed all labels which apply to
-      // the change must be returned. All applying labels can be retrieved from
-      // the submit records, which is what initLabels does.
-      // It's not possible to only compute the labels based on the approvals
-      // since merged changes may not have approvals for all labels (e.g. if not
-      // all labels are required for submit or if the change was auto-closed due
-      // to direct push or if new labels were defined after the change was
-      // merged).
-      labels = initLabels(cd, labelTypes, standard);
+    labels = initLabels(cd, labelTypes, standard);
 
-      // Also include all labels for which approvals exists. E.g. there can be
-      // approvals for labels that are ignored by a Prolog submit rule and hence
-      // it wouldn't be included in the submit records.
-      for (String name : labelNames) {
-        if (!labels.containsKey(name)) {
-          labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
-        }
-      }
-    } else {
-      // For abandoned changes return only labels for which approvals exist.
-      // Other labels are not needed since voting on abandoned changes is not
-      // allowed.
-      labels = new TreeMap<>(labelTypes.nameComparator());
-      for (String name : labelNames) {
+    // Also include all labels for which approvals exists. E.g. there can be
+    // approvals for labels that are ignored by a Prolog submit rule and hence
+    // it wouldn't be included in the submit records.
+    for (String name : labelNames) {
+      if (!labels.containsKey(name)) {
         labels.put(name, LabelWithStatus.create(new LabelInfo(), null));
       }
     }
@@ -878,8 +945,8 @@
       Map<String, ApprovalInfo> byLabel = Maps.newHashMapWithExpectedSize(labels.size());
       Map<String, VotingRangeInfo> pvr = Collections.emptyMap();
       if (detailed) {
-        ChangeControl ctl = baseCtrl.forUser(userFactory.create(accountId));
-        pvr = getPermittedVotingRanges(permittedLabels(ctl, cd));
+        PermissionBackend.ForChange perm = basePerm.user(userFactory.create(accountId));
+        pvr = getPermittedVotingRanges(permittedLabels(perm, cd));
         for (Map.Entry<String, LabelWithStatus> entry : labels.entrySet()) {
           ApprovalInfo ai = approvalInfo(accountId, 0, null, null, null);
           byLabel.put(entry.getKey(), ai);
@@ -953,15 +1020,25 @@
     }
   }
 
-  private Map<String, Collection<String>> permittedLabels(ChangeControl ctl, ChangeData cd)
-      throws OrmException {
-    if (ctl == null || !ctl.getUser().isIdentifiedUser()) {
-      return null;
+  private Map<String, Collection<String>> permittedLabels(
+      PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException, PermissionBackendException {
+    boolean isMerged = cd.change().getStatus() == Change.Status.MERGED;
+    LabelTypes labelTypes = cd.getLabelTypes();
+    Map<String, LabelType> toCheck = new HashMap<>();
+    for (SubmitRecord rec : submitRecords(cd)) {
+      if (rec.labels != null) {
+        for (SubmitRecord.Label r : rec.labels) {
+          LabelType type = labelTypes.byLabel(r.label);
+          if (type != null && (!isMerged || type.allowPostSubmit())) {
+            toCheck.put(type.getName(), type);
+          }
+        }
+      }
     }
 
     Map<String, Short> labels = null;
-    boolean isMerged = ctl.getChange().getStatus() == Change.Status.MERGED;
-    LabelTypes labelTypes = ctl.getLabelTypes();
+    Set<LabelPermission.WithValue> can = perm.testLabels(toCheck.values());
     SetMultimap<String, String> permitted = LinkedHashMultimap.create();
     for (SubmitRecord rec : submitRecords(cd)) {
       if (rec.labels == null) {
@@ -972,12 +1049,12 @@
         if (type == null || (isMerged && !type.allowPostSubmit())) {
           continue;
         }
-        PermissionRange range = ctl.getRange(Permission.forLabel(r.label));
+
         for (LabelValue v : type.getValues()) {
-          boolean ok = range.contains(v.getValue());
+          boolean ok = can.contains(new LabelPermission.WithValue(type, v));
           if (isMerged) {
             if (labels == null) {
-              labels = currentLabels(ctl);
+              labels = currentLabels(perm, cd);
             }
             short prev = labels.getOrDefault(type.getName(), (short) 0);
             ok &= v.getValue() >= prev;
@@ -988,6 +1065,7 @@
         }
       }
     }
+
     List<String> toClear = Lists.newArrayListWithCapacity(permitted.keySet().size());
     for (Map.Entry<String, Collection<String>> e : permitted.asMap().entrySet()) {
       if (isOnlyZero(e.getValue())) {
@@ -1000,18 +1078,25 @@
     return permitted.asMap();
   }
 
-  private Map<String, Short> currentLabels(ChangeControl ctl) throws OrmException {
+  private Map<String, Short> currentLabels(PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException {
+    IdentifiedUser user = perm.user().asIdentifiedUser();
     Map<String, Short> result = new HashMap<>();
     for (PatchSetApproval psa :
         approvalsUtil.byPatchSetUser(
-            db.get(), ctl, ctl.getChange().currentPatchSetId(), ctl.getUser().getAccountId())) {
+            db.get(),
+            lazyLoad ? cd.notes() : notesFactory.createFromIndexedChange(cd.change()),
+            user,
+            cd.change().currentPatchSetId(),
+            user.getAccountId(),
+            null,
+            null)) {
       result.put(psa.getLabel(), psa.getValue());
     }
     return result;
   }
 
-  private Collection<ChangeMessageInfo> messages(
-      ChangeControl ctl, ChangeData cd, Map<PatchSet.Id, PatchSet> map) throws OrmException {
+  private Collection<ChangeMessageInfo> messages(ChangeData cd) throws OrmException {
     List<ChangeMessage> messages = cmUtil.byChange(db.get(), cd.notes());
     if (messages.isEmpty()) {
       return Collections.emptyList();
@@ -1020,22 +1105,24 @@
     List<ChangeMessageInfo> result = Lists.newArrayListWithCapacity(messages.size());
     for (ChangeMessage message : messages) {
       PatchSet.Id patchNum = message.getPatchSetId();
-      PatchSet ps = patchNum != null ? map.get(patchNum) : null;
-      if (patchNum == null || ctl.isPatchVisible(ps, db.get())) {
-        ChangeMessageInfo cmi = new ChangeMessageInfo();
-        cmi.id = message.getKey().get();
-        cmi.author = accountLoader.get(message.getAuthor());
-        cmi.date = message.getWrittenOn();
-        cmi.message = message.getMessage();
-        cmi.tag = message.getTag();
-        cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
-        result.add(cmi);
+      ChangeMessageInfo cmi = new ChangeMessageInfo();
+      cmi.id = message.getKey().get();
+      cmi.author = accountLoader.get(message.getAuthor());
+      cmi.date = message.getWrittenOn();
+      cmi.message = message.getMessage();
+      cmi.tag = message.getTag();
+      cmi._revisionNumber = patchNum != null ? patchNum.get() : null;
+      Account.Id realAuthor = message.getRealAuthor();
+      if (realAuthor != null) {
+        cmi.realAuthor = accountLoader.get(realAuthor);
       }
+      result.add(cmi);
     }
     return result;
   }
 
-  private Collection<AccountInfo> removableReviewers(ChangeControl ctl, ChangeInfo out) {
+  private Collection<AccountInfo> removableReviewers(ChangeData cd, ChangeInfo out)
+      throws PermissionBackendException, NoSuchProjectException, OrmException, IOException {
     // Although this is called removableReviewers, this method also determines
     // which CCs are removable.
     //
@@ -1055,7 +1142,9 @@
       }
       for (ApprovalInfo ai : label.all) {
         Account.Id id = new Account.Id(ai._accountId);
-        if (ctl.canRemoveReviewer(id, MoreObjects.firstNonNull(ai.value, 0))) {
+
+        if (removeReviewerControl.testRemoveReviewer(
+            cd, userProvider.get(), id, MoreObjects.firstNonNull(ai.value, 0))) {
           removable.add(id);
         } else {
           fixed.add(id);
@@ -1070,9 +1159,11 @@
     Collection<AccountInfo> ccs = out.reviewers.get(ReviewerState.CC);
     if (ccs != null) {
       for (AccountInfo ai : ccs) {
-        Account.Id id = new Account.Id(ai._accountId);
-        if (ctl.canRemoveReviewer(id, 0)) {
-          removable.add(id);
+        if (ai._accountId != null) {
+          Account.Id id = new Account.Id(ai._accountId);
+          if (removeReviewerControl.testRemoveReviewer(cd, userProvider.get(), id, 0)) {
+            removable.add(id);
+          }
         }
       }
     }
@@ -1086,6 +1177,14 @@
     for (Account.Id id : removable) {
       result.add(accountLoader.get(id));
     }
+    // Reviewers added by email are always removable
+    for (Collection<AccountInfo> infos : out.reviewers.values()) {
+      for (AccountInfo info : infos) {
+        if (info._accountId == null) {
+          result.add(info);
+        }
+      }
+    }
     return result;
   }
 
@@ -1097,23 +1196,55 @@
         .collect(toList());
   }
 
+  private Collection<AccountInfo> toAccountInfoByEmail(Collection<Address> addresses) {
+    return addresses
+        .stream()
+        .map(a -> new AccountInfo(a.getName(), a.getEmail()))
+        .sorted(AccountInfoComparator.ORDER_NULLS_FIRST)
+        .collect(toList());
+  }
+
   @Nullable
-  private Repository openRepoIfNecessary(ChangeControl ctl) throws IOException {
+  private Repository openRepoIfNecessary(Project.NameKey project) throws IOException {
     if (has(ALL_COMMITS) || has(CURRENT_COMMIT) || has(COMMIT_FOOTERS)) {
-      return repoManager.openRepository(ctl.getProject().getNameKey());
+      return repoManager.openRepository(project);
     }
     return null;
   }
 
+  @Nullable
+  private RevWalk newRevWalk(@Nullable Repository repo) {
+    return repo != null ? new RevWalk(repo) : null;
+  }
+
   private Map<String, RevisionInfo> revisions(
-      ChangeControl ctl, ChangeData cd, Map<PatchSet.Id, PatchSet> map, ChangeInfo changeInfo)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
+      ChangeData cd,
+      Map<PatchSet.Id, PatchSet> map,
+      Optional<PatchSet.Id> limitToPsId,
+      ChangeInfo changeInfo)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
     Map<String, RevisionInfo> res = new LinkedHashMap<>();
-    try (Repository repo = openRepoIfNecessary(ctl)) {
+    Boolean isWorldReadable = null;
+    try (Repository repo = openRepoIfNecessary(cd.project());
+        RevWalk rw = newRevWalk(repo)) {
       for (PatchSet in : map.values()) {
-        if ((has(ALL_REVISIONS) || in.getId().equals(ctl.getChange().currentPatchSetId()))
-            && ctl.isPatchVisible(in, db.get())) {
-          res.put(in.getRevision().get(), toRevisionInfo(ctl, cd, in, repo, false, changeInfo));
+        PatchSet.Id id = in.getId();
+        boolean want = false;
+        if (has(ALL_REVISIONS)) {
+          want = true;
+        } else if (limitToPsId.isPresent()) {
+          want = id.equals(limitToPsId.get());
+        } else {
+          want = id.equals(cd.change().currentPatchSetId());
+        }
+        if (want) {
+          if (isWorldReadable == null) {
+            isWorldReadable = isWorldReadable(cd);
+          }
+          res.put(
+              in.getRevision().get(),
+              toRevisionInfo(cd, in, repo, rw, false, changeInfo, isWorldReadable));
         }
       }
       return res;
@@ -1147,60 +1278,62 @@
     return map;
   }
 
-  public RevisionInfo getRevisionInfo(ChangeControl ctl, PatchSet in)
-      throws PatchListNotAvailableException, GpgException, OrmException, IOException {
+  public RevisionInfo getRevisionInfo(ChangeData cd, PatchSet in)
+      throws PatchListNotAvailableException, GpgException, OrmException, IOException,
+          PermissionBackendException {
     accountLoader = accountLoaderFactory.create(has(DETAILED_ACCOUNTS));
-    try (Repository repo = openRepoIfNecessary(ctl)) {
-      RevisionInfo rev =
-          toRevisionInfo(ctl, changeDataFactory.create(db.get(), ctl), in, repo, true, null);
+    try (Repository repo = openRepoIfNecessary(cd.project());
+        RevWalk rw = newRevWalk(repo)) {
+      RevisionInfo rev = toRevisionInfo(cd, in, repo, rw, true, null, isWorldReadable(cd));
       accountLoader.fill();
       return rev;
     }
   }
 
   private RevisionInfo toRevisionInfo(
-      ChangeControl ctl,
       ChangeData cd,
       PatchSet in,
       @Nullable Repository repo,
+      @Nullable RevWalk rw,
       boolean fillCommit,
-      @Nullable ChangeInfo changeInfo)
+      @Nullable ChangeInfo changeInfo,
+      boolean isWorldReadable)
       throws PatchListNotAvailableException, GpgException, OrmException, IOException {
-    Change c = ctl.getChange();
+    Change c = cd.change();
     RevisionInfo out = new RevisionInfo();
     out.isCurrent = in.getId().equals(c.currentPatchSetId());
     out._number = in.getId().get();
     out.ref = in.getRefName();
     out.created = in.getCreatedOn();
     out.uploader = accountLoader.get(in.getUploader());
-    out.draft = in.isDraft() ? true : null;
-    out.fetch = makeFetchMap(ctl, in);
-    out.kind = changeKindCache.getChangeKind(repo, cd, in);
+    out.fetch = makeFetchMap(cd, in, isWorldReadable);
+    out.kind = changeKindCache.getChangeKind(rw, repo != null ? repo.getConfig() : null, cd, in);
     out.description = in.getDescription();
 
     boolean setCommit = has(ALL_COMMITS) || (out.isCurrent && has(CURRENT_COMMIT));
     boolean addFooters = out.isCurrent && has(COMMIT_FOOTERS);
     if (setCommit || addFooters) {
+      checkState(rw != null);
+      checkState(repo != null);
       Project.NameKey project = c.getProject();
-      try (RevWalk rw = new RevWalk(repo)) {
-        String rev = in.getRevision().get();
-        RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
-        rw.parseBody(commit);
-        if (setCommit) {
-          out.commit = toCommit(ctl, rw, commit, has(WEB_LINKS), fillCommit);
+      String rev = in.getRevision().get();
+      RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
+      rw.parseBody(commit);
+      if (setCommit) {
+        out.commit = toCommit(project, rw, commit, has(WEB_LINKS), fillCommit);
+      }
+      if (addFooters) {
+        Ref ref = repo.exactRef(cd.change().getDest().get());
+        RevCommit mergeTip = null;
+        if (ref != null) {
+          mergeTip = rw.parseCommit(ref.getObjectId());
+          rw.parseBody(mergeTip);
         }
-        if (addFooters) {
-          Ref ref = repo.exactRef(ctl.getChange().getDest().get());
-          RevCommit mergeTip = null;
-          if (ref != null) {
-            mergeTip = rw.parseCommit(ref.getObjectId());
-            rw.parseBody(mergeTip);
-          }
-          out.commitWithFooters =
-              mergeUtilFactory
-                  .create(projectCache.get(project))
-                  .createCommitMessageOnSubmit(commit, mergeTip, ctl, in.getId());
-        }
+        out.commitWithFooters =
+            mergeUtilFactory
+                .create(projectCache.get(project))
+                .createCommitMessageOnSubmit(
+                    commit, mergeTip, cd.notes(), userProvider.get(), in.getId());
       }
     }
 
@@ -1210,12 +1343,12 @@
       out.files.remove(Patch.MERGE_LIST);
     }
 
-    if ((out.isCurrent || (out.draft != null && out.draft))
-        && has(CURRENT_ACTIONS)
-        && userProvider.get().isIdentifiedUser()) {
+    if (out.isCurrent && has(CURRENT_ACTIONS) && userProvider.get().isIdentifiedUser()) {
 
       actionJson.addRevisionActions(
-          changeInfo, out, new RevisionResource(changeResourceFactory.create(ctl), in));
+          changeInfo,
+          out,
+          new RevisionResource(changeResourceFactory.create(cd.notes(), userProvider.get()), in));
     }
 
     if (gpgApi.isEnabled() && has(PUSH_CERTIFICATES)) {
@@ -1232,9 +1365,8 @@
   }
 
   CommitInfo toCommit(
-      ChangeControl ctl, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
+      Project.NameKey project, RevWalk rw, RevCommit commit, boolean addLinks, boolean fillCommit)
       throws IOException {
-    Project.NameKey project = ctl.getProject().getNameKey();
     CommitInfo info = new CommitInfo();
     if (fillCommit) {
       info.commit = commit.name();
@@ -1264,9 +1396,8 @@
     return info;
   }
 
-  private Map<String, FetchInfo> makeFetchMap(ChangeControl ctl, PatchSet in) throws OrmException {
+  private Map<String, FetchInfo> makeFetchMap(ChangeData cd, PatchSet in, boolean isWorldReadable) {
     Map<String, FetchInfo> r = new LinkedHashMap<>();
-
     for (DynamicMap.Entry<DownloadScheme> e : downloadSchemes) {
       String schemeName = e.getExportName();
       DownloadScheme scheme = e.getProvider().get();
@@ -1274,12 +1405,11 @@
           || (scheme.isAuthRequired() && !userProvider.get().isIdentifiedUser())) {
         continue;
       }
-
-      if (!scheme.isAuthSupported() && !ctl.forUser(anonymous).isPatchVisible(in, db.get())) {
+      if (!scheme.isAuthSupported() && !isWorldReadable) {
         continue;
       }
 
-      String projectName = ctl.getProject().getNameKey().get();
+      String projectName = cd.project().get();
       String url = scheme.getUrl(projectName);
       String refName = in.getRefName();
       FetchInfo fetchInfo = new FetchInfo(url, refName);
@@ -1329,6 +1459,28 @@
     label.all.add(approval);
   }
 
+  /**
+   * @return {@link com.google.gerrit.server.permissions.PermissionBackend.ForChange} constructed
+   *     from either an index-backed or a database-backed {@link ChangeData} depending on {@code
+   *     lazyload}.
+   */
+  private PermissionBackend.ForChange permissionBackendForChange(CurrentUser user, ChangeData cd)
+      throws OrmException {
+    PermissionBackend.WithUser withUser = permissionBackend.user(user).database(db);
+    return lazyLoad
+        ? withUser.change(cd)
+        : withUser.indexedChange(cd, notesFactory.createFromIndexedChange(cd.change()));
+  }
+
+  private boolean isWorldReadable(ChangeData cd) throws OrmException, PermissionBackendException {
+    try {
+      permissionBackendForChange(anonymous, cd).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException ae) {
+      return false;
+    }
+  }
+
   @AutoValue
   abstract static class LabelWithStatus {
     private static LabelWithStatus create(LabelInfo label, SubmitRecord.Label.Status status) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
index aa47827..6baeefc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
@@ -21,8 +21,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.query.change.ChangeData;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
  * Cache of {@link ChangeKind} per commit.
@@ -32,9 +33,14 @@
  */
 public interface ChangeKindCache {
   ChangeKind getChangeKind(
-      Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next);
+      Project.NameKey project,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      ObjectId prior,
+      ObjectId next);
 
   ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
 
-  ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch);
+  ChangeKind getChangeKind(
+      @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index c75a413..89fcb6d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static org.eclipse.jgit.lib.ObjectIdSerialization.readNotNull;
 import static org.eclipse.jgit.lib.ObjectIdSerialization.writeNotNull;
@@ -94,10 +95,14 @@
 
     @Override
     public ChangeKind getChangeKind(
-        Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next) {
+        Project.NameKey project,
+        @Nullable RevWalk rw,
+        @Nullable Config repoConfig,
+        ObjectId prior,
+        ObjectId next) {
       try {
         Key key = new Key(prior, next, useRecursiveMerge);
-        return new Loader(key, repoManager, project, repo).call();
+        return new Loader(key, repoManager, project, rw, repoConfig).call();
       } catch (IOException e) {
         log.warn(
             "Cannot check trivial rebase of new patch set " + next.name() + " in " + project, e);
@@ -111,8 +116,9 @@
     }
 
     @Override
-    public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch) {
-      return getChangeKindInternal(this, repo, cd, patch);
+    public ChangeKind getChangeKind(
+        @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch) {
+      return getChangeKindInternal(this, rw, repoConfig, cd, patch);
     }
   }
 
@@ -182,36 +188,47 @@
     private final Key key;
     private final GitRepositoryManager repoManager;
     private final Project.NameKey projectName;
-    private final Repository alreadyOpenRepo;
+    private final RevWalk alreadyOpenRw;
+    private final Config repoConfig;
 
     private Loader(
         Key key,
         GitRepositoryManager repoManager,
         Project.NameKey projectName,
-        @Nullable Repository alreadyOpenRepo) {
+        @Nullable RevWalk rw,
+        @Nullable Config repoConfig) {
+      checkArgument(
+          (rw == null && repoConfig == null) || (rw != null && repoConfig != null),
+          "must either provide both revwalk/config, or neither; got %s/%s",
+          rw,
+          repoConfig);
       this.key = key;
       this.repoManager = repoManager;
       this.projectName = projectName;
-      this.alreadyOpenRepo = alreadyOpenRepo;
+      this.alreadyOpenRw = rw;
+      this.repoConfig = repoConfig;
     }
 
+    @SuppressWarnings("resource") // Resources are manually managed.
     @Override
     public ChangeKind call() throws IOException {
       if (Objects.equals(key.prior, key.next)) {
         return ChangeKind.NO_CODE_CHANGE;
       }
 
-      Repository repo = alreadyOpenRepo;
-      boolean close = false;
-      if (repo == null) {
+      RevWalk rw = alreadyOpenRw;
+      Config config = repoConfig;
+      Repository repo = null;
+      if (alreadyOpenRw == null) {
         repo = repoManager.openRepository(projectName);
-        close = true;
+        rw = new RevWalk(repo);
+        config = repo.getConfig();
       }
-      try (RevWalk walk = new RevWalk(repo)) {
-        RevCommit prior = walk.parseCommit(key.prior);
-        walk.parseBody(prior);
-        RevCommit next = walk.parseCommit(key.next);
-        walk.parseBody(next);
+      try {
+        RevCommit prior = rw.parseCommit(key.prior);
+        rw.parseBody(prior);
+        RevCommit next = rw.parseCommit(key.next);
+        rw.parseBody(next);
 
         if (!next.getFullMessage().equals(prior.getFullMessage())) {
           if (isSameDeltaAndTree(prior, next)) {
@@ -240,8 +257,8 @@
         // A trivial rebase can be detected by looking for the next commit
         // having the same tree as would exist when the prior commit is
         // cherry-picked onto the next commit's new first parent.
-        try (ObjectInserter ins = new InMemoryInserter(repo)) {
-          ThreeWayMerger merger = MergeUtil.newThreeWayMerger(repo, ins, key.strategyName);
+        try (ObjectInserter ins = new InMemoryInserter(rw.getObjectReader())) {
+          ThreeWayMerger merger = MergeUtil.newThreeWayMerger(ins, config, key.strategyName);
           merger.setBase(prior.getParent(0));
           if (merger.merge(next.getParent(0), prior)
               && merger.getResultTreeId().equals(next.getTree())) {
@@ -256,7 +273,8 @@
         }
         return ChangeKind.REWORK;
       } finally {
-        if (close) {
+        if (repo != null) {
+          rw.close();
           repo.close();
         }
       }
@@ -334,10 +352,14 @@
 
   @Override
   public ChangeKind getChangeKind(
-      Project.NameKey project, @Nullable Repository repo, ObjectId prior, ObjectId next) {
+      Project.NameKey project,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      ObjectId prior,
+      ObjectId next) {
     try {
       Key key = new Key(prior, next, useRecursiveMerge);
-      return cache.get(key, new Loader(key, repoManager, project, repo));
+      return cache.get(key, new Loader(key, repoManager, project, rw, repoConfig));
     } catch (ExecutionException e) {
       log.warn("Cannot check trivial rebase of new patch set " + next.name() + " in " + project, e);
       return ChangeKind.REWORK;
@@ -350,12 +372,17 @@
   }
 
   @Override
-  public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd, PatchSet patch) {
-    return getChangeKindInternal(this, repo, cd, patch);
+  public ChangeKind getChangeKind(
+      @Nullable RevWalk rw, @Nullable Config repoConfig, ChangeData cd, PatchSet patch) {
+    return getChangeKindInternal(this, rw, repoConfig, cd, patch);
   }
 
   private static ChangeKind getChangeKindInternal(
-      ChangeKindCache cache, @Nullable Repository repo, ChangeData change, PatchSet patch) {
+      ChangeKindCache cache,
+      @Nullable RevWalk rw,
+      @Nullable Config repoConfig,
+      ChangeData change,
+      PatchSet patch) {
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to use
     // the repository.
@@ -379,7 +406,8 @@
           kind =
               cache.getChangeKind(
                   change.project(),
-                  repo,
+                  rw,
+                  repoConfig,
                   ObjectId.fromString(priorPs.getRevision().get()),
                   ObjectId.fromString(patch.getRevision().get()));
         }
@@ -408,8 +436,11 @@
     // Trivial case: if we're on the first patch, we don't need to open
     // the repository.
     if (patch.getId().get() > 1) {
-      try (Repository repo = repoManager.openRepository(change.getProject())) {
-        kind = getChangeKindInternal(cache, repo, changeDataFactory.create(db, change), patch);
+      try (Repository repo = repoManager.openRepository(change.getProject());
+          RevWalk rw = new RevWalk(repo)) {
+        kind =
+            getChangeKindInternal(
+                cache, rw, repo.getConfig(), changeDataFactory.create(db, change), patch);
       } catch (IOException e) {
         // Do nothing; assume we have a complex change
         log.warn(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
index 92b4150..41b6855 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeMessages.java
@@ -23,6 +23,10 @@
   }
 
   public String revertChangeDefaultMessage;
+
+  public String reviewerCantSeeChange;
+  public String reviewerInactive;
+  public String reviewerInvalid;
   public String reviewerNotFoundUser;
   public String reviewerNotFoundUserOrGroup;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
index 8422bba..90f8162 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeResource.java
@@ -17,27 +17,42 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
+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.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class ChangeResource implements RestResource, HasETag {
+  private static final Logger log = LoggerFactory.getLogger(ChangeResource.class);
+
   /**
    * JSON format version number for ETag computations.
    *
@@ -50,32 +65,63 @@
       new TypeLiteral<RestView<ChangeResource>>() {};
 
   public interface Factory {
-    ChangeResource create(ChangeControl ctl);
+    ChangeResource create(ChangeNotes notes, CurrentUser user);
   }
 
+  private static final String ZERO_ID_STRING = ObjectId.zeroId().name();
+
+  private final Provider<ReviewDb> db;
+  private final AccountCache accountCache;
+  private final ApprovalsUtil approvalUtil;
+  private final PatchSetUtil patchSetUtil;
+  private final PermissionBackend permissionBackend;
   private final StarredChangesUtil starredChangesUtil;
-  private final ChangeControl control;
+  private final ProjectCache projectCache;
+  private final ChangeNotes notes;
+  private final CurrentUser user;
 
-  @AssistedInject
-  ChangeResource(StarredChangesUtil starredChangesUtil, @Assisted ChangeControl control) {
+  @Inject
+  ChangeResource(
+      Provider<ReviewDb> db,
+      AccountCache accountCache,
+      ApprovalsUtil approvalUtil,
+      PatchSetUtil patchSetUtil,
+      PermissionBackend permissionBackend,
+      StarredChangesUtil starredChangesUtil,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
+      @Assisted CurrentUser user) {
+    this.db = db;
+    this.accountCache = accountCache;
+    this.approvalUtil = approvalUtil;
+    this.patchSetUtil = patchSetUtil;
+    this.permissionBackend = permissionBackend;
     this.starredChangesUtil = starredChangesUtil;
-    this.control = control;
+    this.projectCache = projectCache;
+    this.notes = notes;
+    this.user = user;
   }
 
-  public ChangeControl getControl() {
-    return control;
+  public PermissionBackend.ForChange permissions() {
+    return permissionBackend.user(user).change(notes);
   }
 
-  public IdentifiedUser getUser() {
-    return getControl().getUser().asIdentifiedUser();
+  public CurrentUser getUser() {
+    return user;
   }
 
   public Change.Id getId() {
-    return getControl().getId();
+    return notes.getChangeId();
+  }
+
+  /** @return true if {@link #getUser()} is the change's owner. */
+  public boolean isUserOwner() {
+    Account.Id owner = getChange().getOwner();
+    return user.isIdentifiedUser() && user.asIdentifiedUser().getAccountId().equals(owner);
   }
 
   public Change getChange() {
-    return getControl().getChange();
+    return notes.getChange();
   }
 
   public Project.NameKey getProject() {
@@ -83,7 +129,7 @@
   }
 
   public ChangeNotes getNotes() {
-    return getControl().getNotes();
+    return notes;
   }
 
   // This includes all information relevant for ETag computation
@@ -101,9 +147,34 @@
     }
 
     byte[] buf = new byte[20];
+    Set<Account.Id> accounts = new HashSet<>();
+    accounts.add(getChange().getOwner());
+    if (getChange().getAssignee() != null) {
+      accounts.add(getChange().getAssignee());
+    }
+    try {
+      patchSetUtil
+          .byChange(db.get(), notes)
+          .stream()
+          .map(ps -> ps.getUploader())
+          .forEach(accounts::add);
+
+      // It's intentional to include the states for *all* reviewers into the ETag computation.
+      // We need the states of all current reviewers and CCs because they are part of ChangeInfo.
+      // Including removed reviewers is a cheap way of making sure that the states of accounts that
+      // posted a message on the change are included. Loading all change messages to find the exact
+      // set of accounts that posted a message is too expensive. However everyone who posts a
+      // message is automatically added as reviewer. Hence if we include removed reviewers we can
+      // be sure that we have all accounts that posted messages on the change.
+      accounts.addAll(approvalUtil.getReviewers(db.get(), notes).all());
+    } catch (OrmException e) {
+      // This ETag will be invalidated if it loads next time.
+    }
+    accounts.stream().forEach(a -> hashAccount(h, accountCache.get(a), buf));
+
     ObjectId noteId;
     try {
-      noteId = getNotes().loadRevision();
+      noteId = notes.loadRevision();
     } catch (OrmException e) {
       noteId = null; // This ETag will be invalidated if it loads next time.
     }
@@ -111,16 +182,22 @@
     // TODO(dborowitz): Include more NoteDb and other related refs, e.g. drafts
     // and edits.
 
-    for (ProjectState p : control.getProjectControl().getProjectState().tree()) {
+    Iterable<ProjectState> projectStateTree;
+    try {
+      projectStateTree = projectCache.checkedGet(getProject()).tree();
+    } catch (IOException e) {
+      log.error("could not load project {} while computing etag", getProject());
+      projectStateTree = ImmutableList.of();
+    }
+
+    for (ProjectState p : projectStateTree) {
       hashObjectId(h, p.getConfig().getRevision(), buf);
     }
   }
 
   @Override
-  @SuppressWarnings("deprecation") // Use Hashing.md5 for compatibility.
   public String getETag() {
-    CurrentUser user = control.getUser();
-    Hasher h = Hashing.md5().newHasher();
+    Hasher h = Hashing.murmur3_128().newHasher();
     if (user.isIdentifiedUser()) {
       h.putString(starredChangesUtil.getObjectId(user.getAccountId(), getId()).name(), UTF_8);
     }
@@ -132,4 +209,10 @@
     MoreObjects.firstNonNull(id, ObjectId.zeroId()).copyRawTo(buf, 0);
     h.putBytes(buf);
   }
+
+  private void hashAccount(Hasher h, AccountState accountState, byte[] buf) {
+    h.putString(
+        MoreObjects.firstNonNull(accountState.getAccount().getMetaId(), ZERO_ID_STRING), UTF_8);
+    accountState.getExternalIds().stream().forEach(e -> hashObjectId(h, e.blobId(), buf));
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java
index 71a3db7..2daeb7c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeTriplet.java
@@ -38,20 +38,22 @@
    * @return the triplet if the input string has the proper format, or absent if not.
    */
   public static Optional<ChangeTriplet> parse(String triplet) {
-    int t2 = triplet.lastIndexOf('~');
-    int t1 = triplet.lastIndexOf('~', t2 - 1);
-    if (t1 < 0 || t2 < 0) {
+    int z = triplet.lastIndexOf('~');
+    int y = triplet.lastIndexOf('~', z - 1);
+    return parse(triplet, y, z);
+  }
+
+  public static Optional<ChangeTriplet> parse(String triplet, int y, int z) {
+    if (y < 0 || z < 0) {
       return Optional.empty();
     }
 
-    String project = Url.decode(triplet.substring(0, t1));
-    String branch = Url.decode(triplet.substring(t1 + 1, t2));
-    String changeId = Url.decode(triplet.substring(t2 + 1));
-
-    ChangeTriplet result =
+    String project = Url.decode(triplet.substring(0, y));
+    String branch = Url.decode(triplet.substring(y + 1, z));
+    String changeId = Url.decode(triplet.substring(z + 1));
+    return Optional.of(
         new AutoValue_ChangeTriplet(
-            new Branch.NameKey(new Project.NameKey(project), branch), new Change.Key(changeId));
-    return Optional.of(result);
+            new Branch.NameKey(new Project.NameKey(project), branch), new Change.Key(changeId)));
   }
 
   public final Project.NameKey project() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
index eeb1ab3..805512e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangesCollection.java
@@ -26,7 +26,10 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.CurrentUser;
-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.query.change.QueryChanges;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -44,6 +47,7 @@
   private final ChangeFinder changeFinder;
   private final CreateChange createChange;
   private final ChangeResource.Factory changeResourceFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   ChangesCollection(
@@ -53,7 +57,8 @@
       DynamicMap<RestView<ChangeResource>> views,
       ChangeFinder changeFinder,
       CreateChange createChange,
-      ChangeResource.Factory changeResourceFactory) {
+      ChangeResource.Factory changeResourceFactory,
+      PermissionBackend permissionBackend) {
     this.db = db;
     this.user = user;
     this.queryFactory = queryFactory;
@@ -61,6 +66,7 @@
     this.changeFinder = changeFinder;
     this.createChange = createChange;
     this.changeResourceFactory = changeResourceFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -75,47 +81,51 @@
 
   @Override
   public ChangeResource parse(TopLevelResource root, IdString id)
-      throws ResourceNotFoundException, OrmException {
-    List<ChangeControl> ctls = changeFinder.find(id.encoded(), user.get());
-    if (ctls.isEmpty()) {
+      throws ResourceNotFoundException, OrmException, PermissionBackendException {
+    List<ChangeNotes> notes = changeFinder.find(id.encoded());
+    if (notes.isEmpty()) {
       throw new ResourceNotFoundException(id);
-    } else if (ctls.size() != 1) {
+    } else if (notes.size() != 1) {
       throw new ResourceNotFoundException("Multiple changes found for " + id);
     }
 
-    ChangeControl ctl = ctls.get(0);
-    if (!ctl.isVisible(db.get())) {
+    ChangeNotes change = notes.get(0);
+    if (!canRead(change)) {
       throw new ResourceNotFoundException(id);
     }
-    return changeResourceFactory.create(ctl);
+    return changeResourceFactory.create(change, user.get());
   }
 
-  public ChangeResource parse(Change.Id id) throws ResourceNotFoundException, OrmException {
-    List<ChangeControl> ctls = changeFinder.find(id, user.get());
-    if (ctls.isEmpty()) {
+  public ChangeResource parse(Change.Id id)
+      throws ResourceNotFoundException, OrmException, PermissionBackendException {
+    List<ChangeNotes> notes = changeFinder.find(id);
+    if (notes.isEmpty()) {
       throw new ResourceNotFoundException(toIdString(id));
-    } else if (ctls.size() != 1) {
+    } else if (notes.size() != 1) {
       throw new ResourceNotFoundException("Multiple changes found for " + id);
     }
 
-    ChangeControl ctl = ctls.get(0);
-    if (!ctl.isVisible(db.get())) {
+    ChangeNotes change = notes.get(0);
+    if (!canRead(change)) {
       throw new ResourceNotFoundException(toIdString(id));
     }
-    return changeResourceFactory.create(ctl);
+    return changeResourceFactory.create(change, user.get());
   }
 
   private static IdString toIdString(Change.Id id) {
     return IdString.fromDecoded(id.toString());
   }
 
-  public ChangeResource parse(ChangeControl control) {
-    return changeResourceFactory.create(control);
+  public ChangeResource parse(ChangeNotes notes, CurrentUser user) {
+    return changeResourceFactory.create(notes, user);
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public CreateChange post(TopLevelResource parent) throws RestApiException {
     return createChange;
   }
+
+  private boolean canRead(ChangeNotes notes) throws PermissionBackendException {
+    return permissionBackend.user(user).change(notes).database(db).test(ChangePermission.READ);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
index 3b67930..157928b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Check.java
@@ -17,22 +17,38 @@
 import com.google.gerrit.extensions.api.changes.FixInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.CurrentUser;
+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.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
 
 public class Check
     implements RestReadView<ChangeResource>, RestModifyView<ChangeResource, FixInput> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final ChangeJson.Factory jsonFactory;
+  private final ProjectControl.GenericFactory projectControlFactory;
 
   @Inject
-  Check(ChangeJson.Factory json) {
+  Check(
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      ChangeJson.Factory json,
+      ProjectControl.GenericFactory projectControlFactory) {
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.jsonFactory = json;
+    this.projectControlFactory = projectControlFactory;
   }
 
   @Override
@@ -42,12 +58,11 @@
 
   @Override
   public Response<ChangeInfo> apply(ChangeResource rsrc, FixInput input)
-      throws RestApiException, OrmException {
-    ChangeControl ctl = rsrc.getControl();
-    if (!ctl.isOwner()
-        && !ctl.getProjectControl().isOwner()
-        && !ctl.getUser().getCapabilities().canMaintainServer()) {
-      throw new AuthException("Cannot fix change");
+      throws RestApiException, OrmException, PermissionBackendException, NoSuchProjectException,
+          IOException {
+    if (!rsrc.isUserOwner()
+        && !projectControlFactory.controlFor(rsrc.getProject(), rsrc.getUser()).isOwner()) {
+      permissionBackend.user(user).check(GlobalPermission.MAINTAIN_SERVER);
     }
     return Response.withMustRevalidate(newChangeJson().fix(input).format(rsrc));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
index e5a4d0f..7fffd3a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
@@ -14,90 +14,94 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.common.data.Capable;
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.IntegrationException;
-import com.google.gerrit.server.project.ChangeControl;
+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.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class CherryPick
-    implements RestModifyView<RevisionResource, CherryPickInput>, UiAction<RevisionResource> {
-  private final Provider<ReviewDb> dbProvider;
+    extends RetryingRestModifyView<RevisionResource, CherryPickInput, ChangeInfo>
+    implements UiAction<RevisionResource> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final CherryPickChange cherryPickChange;
   private final ChangeJson.Factory json;
+  private final ContributorAgreementsChecker contributorAgreements;
 
   @Inject
   CherryPick(
-      Provider<ReviewDb> dbProvider, CherryPickChange cherryPickChange, ChangeJson.Factory json) {
-    this.dbProvider = dbProvider;
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      RetryHelper retryHelper,
+      CherryPickChange cherryPickChange,
+      ChangeJson.Factory json,
+      ContributorAgreementsChecker contributorAgreements) {
+    super(retryHelper);
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.cherryPickChange = cherryPickChange;
     this.json = json;
+    this.contributorAgreements = contributorAgreements;
   }
 
   @Override
-  public ChangeInfo apply(RevisionResource revision, CherryPickInput input)
-      throws OrmException, IOException, UpdateException, RestApiException {
-    final ChangeControl control = revision.getControl();
-    int parent = input.parent == null ? 1 : input.parent;
-
+  public ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, CherryPickInput input)
+      throws OrmException, IOException, UpdateException, RestApiException,
+          PermissionBackendException, ConfigInvalidException, NoSuchProjectException {
+    input.parent = input.parent == null ? 1 : input.parent;
     if (input.message == null || input.message.trim().isEmpty()) {
       throw new BadRequestException("message must be non-empty");
     } else if (input.destination == null || input.destination.trim().isEmpty()) {
       throw new BadRequestException("destination must be non-empty");
     }
 
-    @SuppressWarnings("resource")
-    ReviewDb db = dbProvider.get();
-    if (!control.isVisible(db)) {
-      throw new AuthException("Cherry pick not permitted");
-    }
-
-    ProjectControl projectControl = control.getProjectControl();
-    Capable capable = projectControl.canPushToAtLeastOneRef();
-    if (capable != Capable.OK) {
-      throw new AuthException(capable.getMessage());
-    }
-
     String refName = RefNames.fullName(input.destination);
-    RefControl refControl = projectControl.controlForRef(refName);
-    if (!refControl.canUpload()) {
-      throw new AuthException(
-          "Not allowed to cherry pick "
-              + revision.getChange().getId().toString()
-              + " to "
-              + input.destination);
-    }
+    contributorAgreements.check(rsrc.getProject(), rsrc.getUser());
+
+    permissionBackend
+        .user(user)
+        .project(rsrc.getChange().getProject())
+        .ref(refName)
+        .check(RefPermission.CREATE_CHANGE);
 
     try {
       Change.Id cherryPickedChangeId =
           cherryPickChange.cherryPick(
-              revision.getChange(),
-              revision.getPatchSet(),
-              input.message,
-              refName,
-              refControl,
-              parent);
-      return json.noOptions().format(revision.getProject(), cherryPickedChangeId);
+              updateFactory,
+              rsrc.getChange(),
+              rsrc.getPatchSet(),
+              input,
+              new Branch.NameKey(rsrc.getProject(), refName));
+      return json.noOptions().format(rsrc.getProject(), cherryPickedChangeId);
     } catch (InvalidChangeOperationException e) {
       throw new BadRequestException(e.getMessage());
     } catch (IntegrationException | NoSuchChangeException e) {
@@ -106,10 +110,16 @@
   }
 
   @Override
-  public UiAction.Description getDescription(RevisionResource resource) {
+  public UiAction.Description getDescription(RevisionResource rsrc) {
     return new UiAction.Description()
         .setLabel("Cherry Pick")
         .setTitle("Cherry pick change to a different branch")
-        .setVisible(resource.getControl().getProjectControl().canUpload() && resource.isCurrent());
+        .setVisible(
+            and(
+                rsrc.isCurrent(),
+                permissionBackend
+                    .user(user)
+                    .project(rsrc.getProject())
+                    .testCond(ProjectPermission.CREATE_CHANGE)));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
index b2455f1..4eca6aa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
@@ -16,22 +16,28 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -39,12 +45,12 @@
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
@@ -57,23 +63,26 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 import java.util.TimeZone;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.ChangeIdUtil;
 
 @Singleton
 public class CherryPickChange {
 
-  private final Provider<ReviewDb> db;
+  private final Provider<ReviewDb> dbProvider;
   private final Sequences seq;
   private final Provider<InternalChangeQuery> queryProvider;
   private final GitRepositoryManager gitManager;
@@ -82,13 +91,15 @@
   private final ChangeInserter.Factory changeInserterFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final MergeUtil.Factory mergeUtilFactory;
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ProjectControl.GenericFactory projectControlFactory;
+  private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil changeMessagesUtil;
-  private final PatchSetUtil psUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
+  private final NotifyUtil notifyUtil;
 
   @Inject
   CherryPickChange(
-      Provider<ReviewDb> db,
+      Provider<ReviewDb> dbProvider,
       Sequences seq,
       Provider<InternalChangeQuery> queryProvider,
       @GerritPersonIdent PersonIdent myIdent,
@@ -97,10 +108,12 @@
       ChangeInserter.Factory changeInserterFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtil.Factory mergeUtilFactory,
+      ChangeNotes.Factory changeNotesFactory,
+      ProjectControl.GenericFactory projectControlFactory,
+      ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil changeMessagesUtil,
-      PatchSetUtil psUtil,
-      BatchUpdate.Factory batchUpdateFactory) {
-    this.db = db;
+      NotifyUtil notifyUtil) {
+    this.dbProvider = dbProvider;
     this.seq = seq;
     this.queryProvider = queryProvider;
     this.gitManager = gitManager;
@@ -109,29 +122,42 @@
     this.changeInserterFactory = changeInserterFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
+    this.changeNotesFactory = changeNotesFactory;
+    this.projectControlFactory = projectControlFactory;
+    this.approvalsUtil = approvalsUtil;
     this.changeMessagesUtil = changeMessagesUtil;
-    this.psUtil = psUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
+    this.notifyUtil = notifyUtil;
   }
 
   public Change.Id cherryPick(
+      BatchUpdate.Factory batchUpdateFactory,
       Change change,
       PatchSet patch,
-      final String message,
-      final String ref,
-      final RefControl refControl,
-      int parent)
-      throws NoSuchChangeException, OrmException, MissingObjectException,
-          IncorrectObjectTypeException, IOException, InvalidChangeOperationException,
-          IntegrationException, UpdateException, RestApiException {
+      CherryPickInput input,
+      Branch.NameKey dest)
+      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
+          UpdateException, RestApiException, ConfigInvalidException, NoSuchProjectException {
+    return cherryPick(
+        batchUpdateFactory,
+        change,
+        patch.getId(),
+        change.getProject(),
+        ObjectId.fromString(patch.getRevision().get()),
+        input,
+        dest);
+  }
 
-    if (Strings.isNullOrEmpty(ref)) {
-      throw new InvalidChangeOperationException(
-          "Cherry Pick: Destination branch cannot be null or empty");
-    }
+  public Change.Id cherryPick(
+      BatchUpdate.Factory batchUpdateFactory,
+      @Nullable Change sourceChange,
+      @Nullable PatchSet.Id sourcePatchId,
+      Project.NameKey project,
+      ObjectId sourceCommit,
+      CherryPickInput input,
+      Branch.NameKey dest)
+      throws OrmException, IOException, InvalidChangeOperationException, IntegrationException,
+          UpdateException, RestApiException, ConfigInvalidException, NoSuchProjectException {
 
-    Project.NameKey project = change.getProject();
-    String destinationBranch = RefNames.shortName(ref);
     IdentifiedUser identifiedUser = user.get();
     try (Repository git = gitManager.openRepository(project);
         // This inserter and revwalk *must* be passed to any BatchUpdates
@@ -140,23 +166,22 @@
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
         CodeReviewRevWalk revWalk = CodeReviewCommit.newRevWalk(reader)) {
-      Ref destRef = git.getRefDatabase().exactRef(ref);
+      Ref destRef = git.getRefDatabase().exactRef(dest.get());
       if (destRef == null) {
         throw new InvalidChangeOperationException(
-            String.format("Branch %s does not exist.", destinationBranch));
+            String.format("Branch %s does not exist.", dest.get()));
       }
 
-      CodeReviewCommit mergeTip = revWalk.parseCommit(destRef.getObjectId());
+      RevCommit baseCommit = getBaseCommit(destRef, project.get(), revWalk, input.base);
 
-      CodeReviewCommit commitToCherryPick =
-          revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
+      CodeReviewCommit commitToCherryPick = revWalk.parseCommit(sourceCommit);
 
-      if (parent <= 0 || parent > commitToCherryPick.getParentCount()) {
+      if (input.parent <= 0 || input.parent > commitToCherryPick.getParentCount()) {
         throw new InvalidChangeOperationException(
             String.format(
                 "Cherry Pick: Parent %s does not exist. Please specify a parent in"
                     + " range [1, %s].",
-                parent, commitToCherryPick.getParentCount()));
+                input.parent, commitToCherryPick.getParentCount()));
       }
 
       Timestamp now = TimeUtil.nowTs();
@@ -165,27 +190,29 @@
       final ObjectId computedChangeId =
           ChangeIdUtil.computeChangeId(
               commitToCherryPick.getTree(),
-              mergeTip,
+              baseCommit,
               commitToCherryPick.getAuthorIdent(),
               committerIdent,
-              message);
-      String commitMessage = ChangeIdUtil.insertId(message, computedChangeId).trim() + '\n';
+              input.message);
+      String commitMessage = ChangeIdUtil.insertId(input.message, computedChangeId).trim() + '\n';
 
       CodeReviewCommit cherryPickCommit;
+      ProjectControl projectControl =
+          projectControlFactory.controlFor(dest.getParentKey(), identifiedUser);
       try {
-        ProjectState projectState = refControl.getProjectControl().getProjectState();
+        ProjectState projectState = projectControl.getProjectState();
         cherryPickCommit =
             mergeUtilFactory
                 .create(projectState)
                 .createCherryPickFromCommit(
-                    git,
                     oi,
-                    mergeTip,
+                    git.getConfig(),
+                    baseCommit,
                     commitToCherryPick,
                     committerIdent,
                     commitMessage,
                     revWalk,
-                    parent - 1,
+                    input.parent - 1,
                     false);
 
         Change.Key changeKey;
@@ -197,7 +224,7 @@
           changeKey = new Change.Key("I" + computedChangeId.name());
         }
 
-        Branch.NameKey newDest = new Branch.NameKey(change.getProject(), destRef.getName());
+        Branch.NameKey newDest = new Branch.NameKey(project, destRef.getName());
         List<ChangeData> destChanges =
             queryProvider.get().setLimit(2).byBranchKey(newDest, changeKey);
         if (destChanges.size() > 1) {
@@ -208,31 +235,30 @@
                   + "Cannot create a new patch set.");
         }
         try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                db.get(), change.getDest().getParentKey(), identifiedUser, now)) {
+            batchUpdateFactory.create(dbProvider.get(), project, identifiedUser, now)) {
           bu.setRepository(git, revWalk, oi);
           Change.Id result;
           if (destChanges.size() == 1) {
             // The change key exists on the destination branch. The cherry pick
             // will be added as a new patch set.
-            ChangeControl destCtl =
-                refControl.getProjectControl().controlFor(destChanges.get(0).notes());
-            result = insertPatchSet(bu, git, destCtl, cherryPickCommit);
+            result = insertPatchSet(bu, git, destChanges.get(0).notes(), cherryPickCommit, input);
           } else {
             // Change key not found on destination branch. We can create a new
             // change.
             String newTopic = null;
-            if (!Strings.isNullOrEmpty(change.getTopic())) {
-              newTopic = change.getTopic() + "-" + newDest.getShortName();
+            if (sourceChange != null && !Strings.isNullOrEmpty(sourceChange.getTopic())) {
+              newTopic = sourceChange.getTopic() + "-" + newDest.getShortName();
             }
             result =
                 createNewChange(
-                    bu, cherryPickCommit, refControl.getRefName(), newTopic, change.getDest());
+                    bu, cherryPickCommit, dest.get(), newTopic, sourceChange, sourceCommit, input);
 
-            bu.addOp(
-                change.getId(),
-                new AddMessageToSourceChangeOp(
-                    changeMessagesUtil, patch.getId(), destinationBranch, cherryPickCommit));
+            if (sourceChange != null && sourcePatchId != null) {
+              bu.addOp(
+                  sourceChange.getId(),
+                  new AddMessageToSourceChangeOp(
+                      changeMessagesUtil, sourcePatchId, dest.getShortName(), cherryPickCommit));
+            }
           }
           bu.execute();
           return result;
@@ -240,26 +266,67 @@
       } catch (MergeIdenticalTreeException | MergeConflictException e) {
         throw new IntegrationException("Cherry pick failed: " + e.getMessage());
       }
-    } catch (RepositoryNotFoundException e) {
-      throw new NoSuchChangeException(change.getId(), e);
     }
   }
 
-  private Change.Id insertPatchSet(
-      BatchUpdate bu, Repository git, ChangeControl destCtl, CodeReviewCommit cherryPickCommit)
-      throws IOException, OrmException {
-    Change destChange = destCtl.getChange();
-    PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
-    PatchSetInserter inserter = patchSetInserterFactory.create(destCtl, psId, cherryPickCommit);
-    PatchSet.Id newPatchSetId = inserter.getPatchSetId();
-    PatchSet current = psUtil.current(db.get(), destCtl.getNotes());
+  private RevCommit getBaseCommit(Ref destRef, String project, RevWalk revWalk, String base)
+      throws RestApiException, IOException, OrmException {
+    RevCommit destRefTip = revWalk.parseCommit(destRef.getObjectId());
+    // The tip commit of the destination ref is the default base for the newly created change.
+    if (Strings.isNullOrEmpty(base)) {
+      return destRefTip;
+    }
 
-    bu.addOp(
-        destChange.getId(),
-        inserter
-            .setMessage("Uploaded patch set " + newPatchSetId.get() + ".")
-            .setDraft(current.isDraft())
-            .setNotify(NotifyHandling.NONE));
+    ObjectId baseObjectId;
+    try {
+      baseObjectId = ObjectId.fromString(base);
+    } catch (InvalidObjectIdException e) {
+      throw new BadRequestException(String.format("Base %s doesn't represent a valid SHA-1", base));
+    }
+
+    RevCommit baseCommit = revWalk.parseCommit(baseObjectId);
+    InternalChangeQuery changeQuery = queryProvider.get();
+    changeQuery.enforceVisibility(true);
+    List<ChangeData> changeDatas = changeQuery.byBranchCommit(project, destRef.getName(), base);
+
+    if (changeDatas.isEmpty()) {
+      if (revWalk.isMergedInto(baseCommit, destRefTip)) {
+        // The base commit is a merged commit with no change associated.
+        return baseCommit;
+      }
+      throw new UnprocessableEntityException(
+          String.format("Commit %s does not exist on branch %s", base, destRef.getName()));
+    } else if (changeDatas.size() != 1) {
+      throw new ResourceConflictException("Multiple changes found for commit " + base);
+    }
+
+    Change change = changeDatas.get(0).change();
+    Change.Status status = change.getStatus();
+    if (status == Status.NEW || status == Status.MERGED) {
+      // The base commit is a valid change revision.
+      return baseCommit;
+    }
+
+    throw new ResourceConflictException(
+        String.format(
+            "Change %s with commit %s is %s", change.getChangeId(), base, status.asChangeStatus()));
+  }
+
+  private Change.Id insertPatchSet(
+      BatchUpdate bu,
+      Repository git,
+      ChangeNotes destNotes,
+      CodeReviewCommit cherryPickCommit,
+      CherryPickInput input)
+      throws IOException, OrmException, BadRequestException, ConfigInvalidException {
+    Change destChange = destNotes.getChange();
+    PatchSet.Id psId = ChangeUtil.nextPatchSetId(git, destChange.currentPatchSetId());
+    PatchSetInserter inserter = patchSetInserterFactory.create(destNotes, psId, cherryPickCommit);
+    inserter
+        .setMessage("Uploaded patch set " + inserter.getPatchSetId().get() + ".")
+        .setNotify(input.notify)
+        .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+    bu.addOp(destChange.getId(), inserter);
     return destChange.getId();
   }
 
@@ -268,16 +335,30 @@
       CodeReviewCommit cherryPickCommit,
       String refName,
       String topic,
-      Branch.NameKey sourceBranch)
-      throws OrmException {
+      @Nullable Change sourceChange,
+      ObjectId sourceCommit,
+      CherryPickInput input)
+      throws OrmException, IOException, BadRequestException, ConfigInvalidException {
     Change.Id changeId = new Change.Id(seq.nextChangeId());
-    ChangeInserter ins =
-        changeInserterFactory
-            .create(changeId, cherryPickCommit, refName)
-            .setValidatePolicy(CommitValidators.Policy.GERRIT)
-            .setTopic(topic);
-
-    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch));
+    ChangeInserter ins = changeInserterFactory.create(changeId, cherryPickCommit, refName);
+    Branch.NameKey sourceBranch = sourceChange == null ? null : sourceChange.getDest();
+    ins.setMessage(messageForDestinationChange(ins.getPatchSetId(), sourceBranch, sourceCommit))
+        .setTopic(topic)
+        .setWorkInProgress(sourceChange != null && sourceChange.isWorkInProgress())
+        .setNotify(input.notify)
+        .setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+    if (input.keepReviewers && sourceChange != null) {
+      ReviewerSet reviewerSet =
+          approvalsUtil.getReviewers(
+              dbProvider.get(), changeNotesFactory.createChecked(dbProvider.get(), sourceChange));
+      Set<Account.Id> reviewers =
+          new HashSet<>(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
+      reviewers.add(sourceChange.getOwner());
+      reviewers.remove(user.get().getAccountId());
+      Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
+      ccs.remove(user.get().getAccountId());
+      ins.setReviewers(reviewers).setExtraCC(ccs);
+    }
     bu.insertChange(ins);
     return changeId;
   }
@@ -319,12 +400,16 @@
     }
   }
 
-  private String messageForDestinationChange(PatchSet.Id patchSetId, Branch.NameKey sourceBranch) {
-    return new StringBuilder("Patch Set ")
-        .append(patchSetId.get())
-        .append(": Cherry Picked from branch ")
-        .append(sourceBranch.getShortName())
-        .append(".")
-        .toString();
+  private String messageForDestinationChange(
+      PatchSet.Id patchSetId, Branch.NameKey sourceBranch, ObjectId sourceCommit) {
+    StringBuilder stringBuilder = new StringBuilder("Patch Set ").append(patchSetId.get());
+
+    if (sourceBranch != null) {
+      stringBuilder.append(": Cherry Picked from branch ").append(sourceBranch.getShortName());
+    } else {
+      stringBuilder.append(": Cherry Picked from commit ").append(sourceCommit.getName());
+    }
+
+    return stringBuilder.append(".").toString();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
new file mode 100644
index 0000000..4980975
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickCommit.java
@@ -0,0 +1,114 @@
+// 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.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.changes.CherryPickInput;
+import com.google.gerrit.extensions.common.ChangeInfo;
+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.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.IntegrationException;
+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.CommitResource;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+@Singleton
+public class CherryPickCommit
+    extends RetryingRestModifyView<CommitResource, CherryPickInput, ChangeInfo> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final CherryPickChange cherryPickChange;
+  private final ChangeJson.Factory json;
+  private final ContributorAgreementsChecker contributorAgreements;
+
+  @Inject
+  CherryPickCommit(
+      RetryHelper retryHelper,
+      Provider<CurrentUser> user,
+      CherryPickChange cherryPickChange,
+      ChangeJson.Factory json,
+      PermissionBackend permissionBackend,
+      ContributorAgreementsChecker contributorAgreements) {
+    super(retryHelper);
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.cherryPickChange = cherryPickChange;
+    this.json = json;
+    this.contributorAgreements = contributorAgreements;
+  }
+
+  @Override
+  public ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, CommitResource rsrc, CherryPickInput input)
+      throws OrmException, IOException, UpdateException, RestApiException,
+          PermissionBackendException, ConfigInvalidException, NoSuchProjectException {
+    RevCommit commit = rsrc.getCommit();
+    String message = Strings.nullToEmpty(input.message).trim();
+    input.message = message.isEmpty() ? commit.getFullMessage() : message;
+    String destination = Strings.nullToEmpty(input.destination).trim();
+    input.parent = input.parent == null ? 1 : input.parent;
+    Project.NameKey projectName = rsrc.getProjectState().getNameKey();
+
+    if (destination.isEmpty()) {
+      throw new BadRequestException("destination must be non-empty");
+    }
+
+    String refName = RefNames.fullName(destination);
+    contributorAgreements.check(projectName, user.get());
+    permissionBackend
+        .user(user)
+        .project(projectName)
+        .ref(refName)
+        .check(RefPermission.CREATE_CHANGE);
+
+    try {
+      Change.Id cherryPickedChangeId =
+          cherryPickChange.cherryPick(
+              updateFactory,
+              null,
+              null,
+              projectName,
+              commit,
+              input,
+              new Branch.NameKey(rsrc.getProjectState().getNameKey(), refName));
+      return json.noOptions().format(projectName, cherryPickedChangeId);
+    } catch (InvalidChangeOperationException e) {
+      throw new BadRequestException(e.getMessage());
+    } catch (IntegrationException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
index 40c8515..f7fc576 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentResource.java
@@ -48,4 +48,8 @@
   Account.Id getAuthorId() {
     return comment.author.getId();
   }
+
+  RevisionResource getRevisionResource() {
+    return rev;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
index 41845e3..a149935 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ConsistencyChecker.java
@@ -22,6 +22,7 @@
 
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.Collections2;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
@@ -43,17 +44,17 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.PatchSetState;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.project.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.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -66,6 +67,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
@@ -77,7 +79,6 @@
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -91,8 +92,9 @@
 
   @AutoValue
   public abstract static class Result {
-    private static Result create(ChangeControl ctl, List<ProblemInfo> problems) {
-      return new AutoValue_ConsistencyChecker_Result(ctl.getId(), ctl.getChange(), problems);
+    private static Result create(ChangeNotes notes, List<ProblemInfo> problems) {
+      return new AutoValue_ConsistencyChecker_Result(
+          notes.getChangeId(), notes.getChange(), problems);
     }
 
     public abstract Change.Id id();
@@ -103,9 +105,8 @@
     public abstract List<ProblemInfo> problems();
   }
 
-  private final BatchUpdate.Factory updateFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeNotes.Factory notesFactory;
+  private final Accounts accounts;
   private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
   private final GitRepositoryManager repoManager;
   private final PatchSetInfoFactory patchSetInfoFactory;
@@ -114,11 +115,14 @@
   private final Provider<CurrentUser> user;
   private final Provider<PersonIdent> serverIdent;
   private final Provider<ReviewDb> db;
+  private final RetryHelper retryHelper;
 
+  private BatchUpdate.Factory updateFactory;
   private FixInput fix;
-  private ChangeControl ctl;
+  private ChangeNotes notes;
   private Repository repo;
   private RevWalk rw;
+  private ObjectInserter oi;
 
   private RevCommit tip;
   private SetMultimap<ObjectId, PatchSet> patchSetsBySha;
@@ -130,59 +134,77 @@
   @Inject
   ConsistencyChecker(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
-      BatchUpdate.Factory updateFactory,
-      ChangeControl.GenericFactory changeControlFactory,
       ChangeNotes.Factory notesFactory,
+      Accounts accounts,
       DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
       GitRepositoryManager repoManager,
       PatchSetInfoFactory patchSetInfoFactory,
       PatchSetInserter.Factory patchSetInserterFactory,
       PatchSetUtil psUtil,
       Provider<CurrentUser> user,
-      Provider<ReviewDb> db) {
+      Provider<ReviewDb> db,
+      RetryHelper retryHelper) {
+    this.accounts = accounts;
     this.accountPatchReviewStore = accountPatchReviewStore;
-    this.changeControlFactory = changeControlFactory;
     this.db = db;
     this.notesFactory = notesFactory;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.psUtil = psUtil;
     this.repoManager = repoManager;
+    this.retryHelper = retryHelper;
     this.serverIdent = serverIdent;
-    this.updateFactory = updateFactory;
     this.user = user;
     reset();
   }
 
   private void reset() {
-    ctl = null;
+    updateFactory = null;
+    notes = null;
     repo = null;
     rw = null;
     problems = new ArrayList<>();
   }
 
   private Change change() {
-    return ctl.getChange();
+    return notes.getChange();
   }
 
-  public Result check(ChangeControl cc, @Nullable FixInput f) {
-    checkNotNull(cc);
+  public Result check(ChangeNotes notes, @Nullable FixInput f) {
+    checkNotNull(notes);
     try {
-      reset();
-      ctl = cc;
-      fix = f;
-      checkImpl();
-      return result();
-    } finally {
-      if (rw != null) {
-        rw.close();
-      }
-      if (repo != null) {
-        repo.close();
-      }
+      return retryHelper.execute(
+          buf -> {
+            try {
+              reset();
+              this.updateFactory = buf;
+              this.notes = notes;
+              fix = f;
+              checkImpl();
+              return result();
+            } finally {
+              if (rw != null) {
+                rw.getObjectReader().close();
+                rw.close();
+                oi.close();
+              }
+              if (repo != null) {
+                repo.close();
+              }
+            }
+          });
+    } catch (RestApiException e) {
+      return logAndReturnOneProblem(e, notes, "Error checking change: " + e.getMessage());
+    } catch (UpdateException e) {
+      return logAndReturnOneProblem(e, notes, "Error checking change");
     }
   }
 
+  private Result logAndReturnOneProblem(Exception e, ChangeNotes notes, String problem) {
+    log.warn("Error checking change " + notes.getChangeId(), e);
+    return Result.create(notes, ImmutableList.of(problem(problem)));
+  }
+
   private void checkImpl() {
     checkOwner();
     checkCurrentPatchSetEntity();
@@ -199,17 +221,17 @@
 
   private void checkOwner() {
     try {
-      if (db.get().accounts().get(change().getOwner()) == null) {
+      if (accounts.get(change().getOwner()) == null) {
         problem("Missing change owner: " + change().getOwner());
       }
-    } catch (OrmException e) {
+    } catch (IOException | ConfigInvalidException e) {
       error("Failed to look up owner", e);
     }
   }
 
   private void checkCurrentPatchSetEntity() {
     try {
-      currPs = psUtil.current(db.get(), ctl.getNotes());
+      currPs = psUtil.current(db.get(), notes);
       if (currPs == null) {
         problem(
             String.format("Current patch set %d not found", change().currentPatchSetId().get()));
@@ -223,7 +245,8 @@
     Project.NameKey project = change().getDest().getParentKey();
     try {
       repo = repoManager.openRepository(project);
-      rw = new RevWalk(repo);
+      oi = repo.newObjectInserter();
+      rw = new RevWalk(oi.newReader());
       return true;
     } catch (RepositoryNotFoundException e) {
       return error("Destination repository not found: " + project, e);
@@ -236,7 +259,7 @@
     List<PatchSet> all;
     try {
       // Iterate in descending order.
-      all = PS_ID_ORDER.sortedCopy(psUtil.byChange(db.get(), ctl.getNotes()));
+      all = PS_ID_ORDER.sortedCopy(psUtil.byChange(db.get(), notes));
     } catch (OrmException e) {
       return error("Failed to look up patch sets", e);
     }
@@ -450,7 +473,7 @@
   }
 
   private void insertMergedPatchSet(
-      final RevCommit commit, final @Nullable PatchSet.Id psIdToDelete, boolean reuseOldPsId) {
+      final RevCommit commit, @Nullable PatchSet.Id psIdToDelete, boolean reuseOldPsId) {
     ProblemInfo notFound = problem("No patch set found for merged commit " + commit.name());
     if (!user.get().isIdentifiedUser()) {
       notFound.status = Status.FIX_FAILED;
@@ -489,42 +512,40 @@
           (psIdToDelete != null && reuseOldPsId)
               ? psIdToDelete
               : ChangeUtil.nextPatchSetId(repo, change().currentPatchSetId());
-      PatchSetInserter inserter = patchSetInserterFactory.create(ctl, psId, commit);
-      try (BatchUpdate bu = newBatchUpdate();
-          ObjectInserter oi = repo.newObjectInserter()) {
+      PatchSetInserter inserter = patchSetInserterFactory.create(notes, psId, commit);
+      try (BatchUpdate bu = newBatchUpdate()) {
         bu.setRepository(repo, rw, oi);
 
         if (psIdToDelete != null) {
           // Delete the given patch set ref. If reuseOldPsId is true,
           // PatchSetInserter will reinsert the same ref, making it a no-op.
           bu.addOp(
-              ctl.getId(),
+              notes.getChangeId(),
               new BatchUpdateOp() {
                 @Override
                 public void updateRepo(RepoContext ctx) throws IOException {
-                  ctx.addRefUpdate(
-                      new ReceiveCommand(commit, ObjectId.zeroId(), psIdToDelete.toRefName()));
+                  ctx.addRefUpdate(commit, ObjectId.zeroId(), psIdToDelete.toRefName());
                 }
               });
           if (!reuseOldPsId) {
             bu.addOp(
-                ctl.getId(),
+                notes.getChangeId(),
                 new DeletePatchSetFromDbOp(checkNotNull(deleteOldPatchSetProblem), psIdToDelete));
           }
         }
 
         bu.addOp(
-            ctl.getId(),
+            notes.getChangeId(),
             inserter
-                .setValidatePolicy(CommitValidators.Policy.NONE)
+                .setValidate(false)
                 .setFireRevisionCreated(false)
                 .setNotify(NotifyHandling.NONE)
                 .setAllowClosed(true)
                 .setMessage("Patch set for merged commit inserted by consistency checker"));
-        bu.addOp(ctl.getId(), new FixMergedOp(notFound));
+        bu.addOp(notes.getChangeId(), new FixMergedOp(notFound));
         bu.execute();
       }
-      ctl = changeControlFactory.controlFor(db.get(), inserter.getChange(), ctl.getUser());
+      notes = notesFactory.createChecked(db.get(), inserter.getChange());
       insertPatchSetProblem.status = Status.FIXED;
       insertPatchSetProblem.outcome = "Inserted as patch set " + psId.get();
     } catch (OrmException | IOException | UpdateException | RestApiException e) {
@@ -555,20 +576,19 @@
   }
 
   private void fixMerged(ProblemInfo p) {
-    try (BatchUpdate bu = newBatchUpdate();
-        ObjectInserter oi = repo.newObjectInserter()) {
+    try (BatchUpdate bu = newBatchUpdate()) {
       bu.setRepository(repo, rw, oi);
-      bu.addOp(ctl.getId(), new FixMergedOp(p));
+      bu.addOp(notes.getChangeId(), new FixMergedOp(p));
       bu.execute();
     } catch (UpdateException | RestApiException e) {
-      log.warn("Error marking " + ctl.getId() + "as merged", e);
+      log.warn("Error marking " + notes.getChangeId() + "as merged", e);
       p.status = Status.FIX_FAILED;
       p.outcome = "Error updating status to merged";
     }
   }
 
   private BatchUpdate newBatchUpdate() {
-    return updateFactory.create(db.get(), change().getProject(), ctl.getUser(), TimeUtil.nowTs());
+    return updateFactory.create(db.get(), change().getProject(), user.get(), TimeUtil.nowTs());
   }
 
   private void fixPatchSetRef(ProblemInfo p, PatchSet ps) {
@@ -593,6 +613,8 @@
         case REJECTED:
         case REJECTED_CURRENT_BRANCH:
         case RENAMED:
+        case REJECTED_MISSING_OBJECT:
+        case REJECTED_OTHER_REASON:
         default:
           p.status = Status.FIX_FAILED;
           p.outcome = "Failed to update patch set ref: " + result;
@@ -607,14 +629,13 @@
   }
 
   private void deletePatchSets(List<DeletePatchSetFromDbOp> ops) {
-    try (BatchUpdate bu = newBatchUpdate();
-        ObjectInserter oi = repo.newObjectInserter()) {
+    try (BatchUpdate bu = newBatchUpdate()) {
       bu.setRepository(repo, rw, oi);
       for (DeletePatchSetFromDbOp op : ops) {
-        checkArgument(op.psId.getParentKey().equals(ctl.getId()));
-        bu.addOp(ctl.getId(), op);
+        checkArgument(op.psId.getParentKey().equals(notes.getChangeId()));
+        bu.addOp(notes.getChangeId(), op);
       }
-      bu.addOp(ctl.getId(), new UpdateCurrentPatchSetOp(ops));
+      bu.addOp(notes.getChangeId(), new UpdateCurrentPatchSetOp(ops));
       bu.execute();
     } catch (NoPatchSetsWouldRemainException e) {
       for (DeletePatchSetFromDbOp op : ops) {
@@ -755,10 +776,10 @@
   }
 
   private void warn(Throwable t) {
-    log.warn("Error in consistency check of change " + ctl.getId(), t);
+    log.warn("Error in consistency check of change " + notes.getChangeId(), t);
   }
 
   private Result result() {
-    return Result.create(ctl, problems);
+    return Result.create(notes, problems);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
index 97d2b70..bbc3de0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateChange.java
@@ -21,20 +21,17 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.MergeInput;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Change;
@@ -54,14 +51,20 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.validators.CommitValidators;
-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.permissions.RefPermission;
+import com.google.gerrit.server.project.CommitsCollection;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.ProjectsCollection;
-import com.google.gerrit.server.project.RefControl;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -73,6 +76,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.TimeZone;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -87,25 +91,27 @@
 import org.eclipse.jgit.util.ChangeIdUtil;
 
 @Singleton
-public class CreateChange implements RestModifyView<TopLevelResource, ChangeInput> {
-
+public class CreateChange
+    extends RetryingRestModifyView<TopLevelResource, ChangeInput, Response<ChangeInfo>> {
   private final String anonymousCowardName;
   private final Provider<ReviewDb> db;
   private final GitRepositoryManager gitManager;
   private final AccountCache accountCache;
   private final Sequences seq;
   private final TimeZone serverTimeZone;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final ProjectsCollection projectsCollection;
+  private final CommitsCollection commits;
   private final ChangeInserter.Factory changeInserterFactory;
   private final ChangeJson.Factory jsonFactory;
   private final ChangeFinder changeFinder;
-  private final BatchUpdate.Factory updateFactory;
   private final PatchSetUtil psUtil;
-  private final boolean allowDrafts;
   private final MergeUtil.Factory mergeUtilFactory;
   private final SubmitType submitType;
   private final NotifyUtil notifyUtil;
+  private final ContributorAgreementsChecker contributorAgreements;
+  private final boolean disablePrivateChanges;
 
   @Inject
   CreateChange(
@@ -115,39 +121,46 @@
       AccountCache accountCache,
       Sequences seq,
       @GerritPersonIdent PersonIdent myIdent,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> user,
       ProjectsCollection projectsCollection,
+      CommitsCollection commits,
       ChangeInserter.Factory changeInserterFactory,
       ChangeJson.Factory json,
       ChangeFinder changeFinder,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       PatchSetUtil psUtil,
       @GerritServerConfig Config config,
       MergeUtil.Factory mergeUtilFactory,
-      NotifyUtil notifyUtil) {
+      NotifyUtil notifyUtil,
+      ContributorAgreementsChecker contributorAgreements) {
+    super(retryHelper);
     this.anonymousCowardName = anonymousCowardName;
     this.db = db;
     this.gitManager = gitManager;
     this.accountCache = accountCache;
     this.seq = seq;
     this.serverTimeZone = myIdent.getTimeZone();
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.projectsCollection = projectsCollection;
+    this.commits = commits;
     this.changeInserterFactory = changeInserterFactory;
     this.jsonFactory = json;
     this.changeFinder = changeFinder;
-    this.updateFactory = updateFactory;
     this.psUtil = psUtil;
-    this.allowDrafts = config.getBoolean("change", "allowDrafts", true);
     this.submitType = config.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
+    this.disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
     this.mergeUtilFactory = mergeUtilFactory;
     this.notifyUtil = notifyUtil;
+    this.contributorAgreements = contributorAgreements;
   }
 
   @Override
-  public Response<ChangeInfo> apply(TopLevelResource parent, ChangeInput input)
+  protected Response<ChangeInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, TopLevelResource parent, ChangeInput input)
       throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
-          UpdateException {
+          UpdateException, PermissionBackendException, ConfigInvalidException {
     if (Strings.isNullOrEmpty(input.project)) {
       throw new BadRequestException("project must be non-empty");
     }
@@ -162,29 +175,25 @@
     }
 
     if (input.status != null) {
-      if (input.status != ChangeStatus.NEW && input.status != ChangeStatus.DRAFT) {
+      if (input.status != ChangeStatus.NEW) {
         throw new BadRequestException("unsupported change status");
       }
-
-      if (!allowDrafts && input.status == ChangeStatus.DRAFT) {
-        throw new MethodNotAllowedException("draft workflow is disabled");
-      }
     }
 
-    String refName = RefNames.fullName(input.branch);
     ProjectResource rsrc = projectsCollection.parse(input.project);
+    boolean privateByDefault = rsrc.getProjectState().isPrivateByDefault();
+    boolean isPrivate = input.isPrivate == null ? privateByDefault : input.isPrivate;
 
-    Capable r = rsrc.getControl().canPushToAtLeastOneRef();
-    if (r != Capable.OK) {
-      throw new AuthException(r.getMessage());
+    if (isPrivate && disablePrivateChanges) {
+      throw new MethodNotAllowedException("private changes are disabled");
     }
 
-    RefControl refControl = rsrc.getControl().controlForRef(refName);
-    if (!refControl.canUpload() || !refControl.canRead()) {
-      throw new AuthException("cannot upload review");
-    }
+    contributorAgreements.check(rsrc.getNameKey(), rsrc.getUser());
 
     Project.NameKey project = rsrc.getNameKey();
+    String refName = RefNames.fullName(input.branch);
+    permissionBackend.user(user).project(project).ref(refName).check(RefPermission.CREATE_CHANGE);
+
     try (Repository git = gitManager.openRepository(project);
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
@@ -192,15 +201,15 @@
       ObjectId parentCommit;
       List<String> groups;
       if (input.baseChange != null) {
-        List<ChangeControl> ctls = changeFinder.find(input.baseChange, rsrc.getControl().getUser());
-        if (ctls.size() != 1) {
-          throw new InvalidChangeOperationException("Base change not found: " + input.baseChange);
+        List<ChangeNotes> notes = changeFinder.find(input.baseChange);
+        if (notes.size() != 1) {
+          throw new UnprocessableEntityException("Base change not found: " + input.baseChange);
         }
-        ChangeControl ctl = Iterables.getOnlyElement(ctls);
-        if (!ctl.isVisible(db.get())) {
-          throw new InvalidChangeOperationException("Base change not found: " + input.baseChange);
+        ChangeNotes change = Iterables.getOnlyElement(notes);
+        if (!permissionBackend.user(user).change(change).database(db).test(ChangePermission.READ)) {
+          throw new UnprocessableEntityException("Base change not found: " + input.baseChange);
         }
-        PatchSet ps = psUtil.current(db.get(), ctl.getNotes());
+        PatchSet ps = psUtil.current(db.get(), change);
         parentCommit = ObjectId.fromString(ps.getRevision().get());
         groups = ps.getGroups();
       } else {
@@ -229,6 +238,12 @@
       AccountState account = accountCache.get(me.getAccountId());
       GeneralPreferencesInfo info = account.getAccount().getGeneralPreferencesInfo();
 
+      boolean isWorkInProgress =
+          input.workInProgress == null
+              ? rsrc.getProjectState().isWorkInProgressByDefault()
+                  || MoreObjects.firstNonNull(info.workInProgressByDefault, false)
+              : input.workInProgress;
+
       // Add a Change-Id line if there isn't already one
       String commitMessage = subject;
       if (ChangeIdUtil.indexOfChangeId(commitMessage, "\n") == -1) {
@@ -256,24 +271,22 @@
         }
         c =
             newMergeCommit(
-                git, oi, rw, rsrc.getControl(), mergeTip, input.merge, author, commitMessage);
+                git, oi, rw, rsrc.getProjectState(), mergeTip, input.merge, author, commitMessage);
       } else {
         // create an empty commit
         c = newCommit(oi, rw, author, mergeTip, commitMessage);
       }
 
       Change.Id changeId = new Change.Id(seq.nextChangeId());
-      ChangeInserter ins =
-          changeInserterFactory
-              .create(changeId, c, refName)
-              .setValidatePolicy(CommitValidators.Policy.GERRIT);
+      ChangeInserter ins = changeInserterFactory.create(changeId, c, refName);
       ins.setMessage(String.format("Uploaded patch set %s.", ins.getPatchSetId().get()));
       String topic = input.topic;
       if (topic != null) {
         topic = Strings.emptyToNull(topic.trim());
       }
       ins.setTopic(topic);
-      ins.setDraft(input.status == ChangeStatus.DRAFT);
+      ins.setPrivate(isPrivate);
+      ins.setWorkInProgress(isWorkInProgress);
       ins.setGroups(groups);
       ins.setNotify(input.notify);
       ins.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
@@ -313,7 +326,7 @@
       Repository repo,
       ObjectInserter oi,
       RevWalk rw,
-      ProjectControl projectControl,
+      ProjectState projectState,
       RevCommit mergeTip,
       MergeInput merge,
       PersonIdent authorIdent,
@@ -324,18 +337,25 @@
     }
 
     RevCommit sourceCommit = MergeUtil.resolveCommit(repo, rw, merge.source);
-    if (!projectControl.canReadCommit(db.get(), repo, sourceCommit)) {
+    if (!commits.canRead(projectState, repo, sourceCommit)) {
       throw new BadRequestException("do not have read permission for: " + merge.source);
     }
 
-    MergeUtil mergeUtil = mergeUtilFactory.create(projectControl.getProjectState());
+    MergeUtil mergeUtil = mergeUtilFactory.create(projectState);
     // default merge strategy from project settings
     String mergeStrategy =
         MoreObjects.firstNonNull(
             Strings.emptyToNull(merge.strategy), mergeUtil.mergeStrategyName());
 
     return MergeUtil.createMergeCommit(
-        repo, oi, mergeTip, sourceCommit, mergeStrategy, authorIdent, commitMessage, rw);
+        oi,
+        repo.getConfig(),
+        mergeTip,
+        sourceCommit,
+        mergeStrategy,
+        authorIdent,
+        commitMessage,
+        rw);
   }
 
   private static ObjectId insert(ObjectInserter inserter, CommitBuilder commit)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
index 5032e57..898f634 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
@@ -24,7 +24,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Comment;
@@ -34,9 +33,12 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 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.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -45,9 +47,9 @@
 import java.util.Collections;
 
 @Singleton
-public class CreateDraftComment implements RestModifyView<RevisionResource, DraftInput> {
+public class CreateDraftComment
+    extends RetryingRestModifyView<RevisionResource, DraftInput, Response<CommentInfo>> {
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
@@ -56,13 +58,13 @@
   @Inject
   CreateDraftComment(
       Provider<ReviewDb> db,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
       PatchListCache patchListCache) {
+    super(retryHelper);
     this.db = db;
-    this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
@@ -70,7 +72,8 @@
   }
 
   @Override
-  public Response<CommentInfo> apply(RevisionResource rsrc, DraftInput in)
+  protected Response<CommentInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, DraftInput in)
       throws RestApiException, UpdateException, OrmException {
     if (Strings.isNullOrEmpty(in.path)) {
       throw new BadRequestException("path must be non-empty");
@@ -105,7 +108,8 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws ResourceNotFoundException, OrmException, UnprocessableEntityException {
+        throws ResourceNotFoundException, OrmException, UnprocessableEntityException,
+            PatchListNotAvailableException {
       PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
@@ -122,7 +126,7 @@
 
       commentsUtil.putComments(
           ctx.getDb(), ctx.getUpdate(psId), Status.DRAFT, Collections.singleton(comment));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
index 5bd651d..0425e53 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateMergePatchSet.java
@@ -22,13 +22,11 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.MergeInput;
 import com.google.gerrit.extensions.common.MergePatchSetInput;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -42,10 +40,15 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.CommitsCollection;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -65,64 +68,60 @@
 import org.eclipse.jgit.util.ChangeIdUtil;
 
 @Singleton
-public class CreateMergePatchSet implements RestModifyView<ChangeResource, MergePatchSetInput> {
-
+public class CreateMergePatchSet
+    extends RetryingRestModifyView<ChangeResource, MergePatchSetInput, Response<ChangeInfo>> {
   private final Provider<ReviewDb> db;
   private final GitRepositoryManager gitManager;
+  private final CommitsCollection commits;
   private final TimeZone serverTimeZone;
   private final Provider<CurrentUser> user;
   private final ChangeJson.Factory jsonFactory;
   private final PatchSetUtil psUtil;
   private final MergeUtil.Factory mergeUtilFactory;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final PatchSetInserter.Factory patchSetInserterFactory;
+  private final ProjectCache projectCache;
 
   @Inject
   CreateMergePatchSet(
       Provider<ReviewDb> db,
       GitRepositoryManager gitManager,
+      CommitsCollection commits,
       @GerritPersonIdent PersonIdent myIdent,
       Provider<CurrentUser> user,
       ChangeJson.Factory json,
       PatchSetUtil psUtil,
       MergeUtil.Factory mergeUtilFactory,
-      BatchUpdate.Factory batchUpdateFactory,
-      PatchSetInserter.Factory patchSetInserterFactory) {
+      RetryHelper retryHelper,
+      PatchSetInserter.Factory patchSetInserterFactory,
+      ProjectCache projectCache) {
+    super(retryHelper);
     this.db = db;
     this.gitManager = gitManager;
+    this.commits = commits;
     this.serverTimeZone = myIdent.getTimeZone();
     this.user = user;
     this.jsonFactory = json;
     this.psUtil = psUtil;
     this.mergeUtilFactory = mergeUtilFactory;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.patchSetInserterFactory = patchSetInserterFactory;
+    this.projectCache = projectCache;
   }
 
   @Override
-  public Response<ChangeInfo> apply(ChangeResource req, MergePatchSetInput in)
+  protected Response<ChangeInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, MergePatchSetInput in)
       throws OrmException, IOException, InvalidChangeOperationException, RestApiException,
-          UpdateException {
-    if (in.merge == null) {
-      throw new BadRequestException("merge field is required");
-    }
+          UpdateException, PermissionBackendException {
+    rsrc.permissions().database(db).check(ChangePermission.ADD_PATCH_SET);
 
     MergeInput merge = in.merge;
-    if (Strings.isNullOrEmpty(merge.source)) {
+    if (merge == null || Strings.isNullOrEmpty(merge.source)) {
       throw new BadRequestException("merge.source must be non-empty");
     }
 
-    ChangeControl ctl = req.getControl();
-    if (!ctl.isVisible(db.get())) {
-      throw new InvalidChangeOperationException("Base change not found: " + req.getId());
-    }
-    PatchSet ps = psUtil.current(db.get(), ctl.getNotes());
-    if (!ctl.canAddPatchSet(db.get())) {
-      throw new AuthException("cannot add patch set");
-    }
-
-    ProjectControl projectControl = ctl.getProjectControl();
-    Change change = ctl.getChange();
+    PatchSet ps = psUtil.current(db.get(), rsrc.getNotes());
+    ProjectState projectState = projectCache.checkedGet(rsrc.getProject());
+    Change change = rsrc.getChange();
     Project.NameKey project = change.getProject();
     Branch.NameKey dest = change.getDest();
     try (Repository git = gitManager.openRepository(project);
@@ -131,21 +130,19 @@
         RevWalk rw = new RevWalk(reader)) {
 
       RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, merge.source);
-      if (!projectControl.canReadCommit(db.get(), git, sourceCommit)) {
+      if (!commits.canRead(projectState, git, sourceCommit)) {
         throw new ResourceNotFoundException(
             "cannot find source commit: " + merge.source + " to merge.");
       }
 
       RevCommit currentPsCommit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
-
       Timestamp now = TimeUtil.nowTs();
       IdentifiedUser me = user.get().asIdentifiedUser();
       PersonIdent author = me.newCommitterIdent(now, serverTimeZone);
-
       RevCommit newCommit =
           createMergeCommit(
               in,
-              projectControl,
+              projectState,
               dest,
               git,
               oi,
@@ -156,14 +153,16 @@
               ObjectId.fromString(change.getKey().get().substring(1)));
 
       PatchSet.Id nextPsId = ChangeUtil.nextPatchSetId(ps.getId());
-      PatchSetInserter psInserter = patchSetInserterFactory.create(ctl, nextPsId, newCommit);
-      try (BatchUpdate bu = batchUpdateFactory.create(db.get(), project, me, now)) {
+      PatchSetInserter psInserter =
+          patchSetInserterFactory.create(rsrc.getNotes(), nextPsId, newCommit);
+      try (BatchUpdate bu = updateFactory.create(db.get(), project, me, now)) {
         bu.setRepository(git, rw, oi);
         bu.addOp(
-            ctl.getId(),
+            rsrc.getId(),
             psInserter
                 .setMessage("Uploaded patch set " + nextPsId.get() + ".")
-                .setDraft(ps.isDraft())
+                .setNotify(NotifyHandling.NONE)
+                .setCheckAddPatchSetPermission(false)
                 .setNotify(NotifyHandling.NONE));
         bu.execute();
       }
@@ -175,7 +174,7 @@
 
   private RevCommit createMergeCommit(
       MergePatchSetInput in,
-      ProjectControl projectControl,
+      ProjectState projectState,
       Branch.NameKey dest,
       Repository git,
       ObjectInserter oi,
@@ -213,9 +212,9 @@
     String mergeStrategy =
         MoreObjects.firstNonNull(
             Strings.emptyToNull(in.merge.strategy),
-            mergeUtilFactory.create(projectControl.getProjectState()).mergeStrategyName());
+            mergeUtilFactory.create(projectState).mergeStrategyName());
 
     return MergeUtil.createMergeCommit(
-        git, oi, mergeTip, sourceCommit, mergeStrategy, author, commitMsg, rw);
+        oi, git.getConfig(), mergeTip, sourceCommit, mergeStrategy, author, commitMsg, rw);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
index b8556d6..d3feb31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
@@ -16,10 +16,8 @@
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -30,10 +28,14 @@
 import com.google.gerrit.server.change.DeleteAssignee.Input;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 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.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,10 +43,10 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class DeleteAssignee implements RestModifyView<ChangeResource, Input> {
+public class DeleteAssignee
+    extends RetryingRestModifyView<ChangeResource, Input, Response<AccountInfo>> {
   public static class Input {}
 
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangeMessagesUtil cmUtil;
   private final Provider<ReviewDb> db;
   private final AssigneeChanged assigneeChanged;
@@ -53,13 +55,13 @@
 
   @Inject
   DeleteAssignee(
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       ChangeMessagesUtil cmUtil,
       Provider<ReviewDb> db,
       AssigneeChanged assigneeChanged,
       IdentifiedUser.GenericFactory userFactory,
       AccountLoader.Factory accountLoaderFactory) {
-    this.batchUpdateFactory = batchUpdateFactory;
+    super(retryHelper);
     this.cmUtil = cmUtil;
     this.db = db;
     this.assigneeChanged = assigneeChanged;
@@ -68,10 +70,13 @@
   }
 
   @Override
-  public Response<AccountInfo> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException, OrmException {
+  protected Response<AccountInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
+
     try (BatchUpdate bu =
-        batchUpdateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Op op = new Op();
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -88,9 +93,6 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) throws RestApiException, OrmException {
-      if (!ctx.getControl().canEditAssignee()) {
-        throw new AuthException("Delete Assignee not permitted");
-      }
       change = ctx.getChange();
       ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
       Account.Id currentAssigneeId = change.getAssignee();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
index ad823d4..af26e8a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChange.java
@@ -14,51 +14,53 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.change.DeleteChange.Input;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ChangeControl;
+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.update.BatchUpdate;
 import com.google.gerrit.server.update.Order;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import org.eclipse.jgit.lib.Config;
 
 @Singleton
-public class DeleteChange
-    implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+public class DeleteChange extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+    implements UiAction<ChangeResource> {
   public static class Input {}
 
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory updateFactory;
   private final Provider<DeleteChangeOp> opProvider;
-  private final boolean allowDrafts;
 
   @Inject
   public DeleteChange(
-      Provider<ReviewDb> db,
-      BatchUpdate.Factory updateFactory,
-      Provider<DeleteChangeOp> opProvider,
-      @GerritServerConfig Config cfg) {
+      Provider<ReviewDb> db, RetryHelper retryHelper, Provider<DeleteChangeOp> opProvider) {
+    super(retryHelper);
     this.db = db;
-    this.updateFactory = updateFactory;
     this.opProvider = opProvider;
-    this.allowDrafts = DeleteChangeOp.allowDrafts(cfg);
   }
 
   @Override
-  public Response<?> apply(ChangeResource rsrc, Input input)
-      throws RestApiException, UpdateException {
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException, PermissionBackendException {
+    if (rsrc.getChange().getStatus() == Change.Status.MERGED) {
+      throw new MethodNotAllowedException("delete not permitted");
+    }
+    rsrc.permissions().database(db).check(ChangePermission.DELETE);
+
     try (BatchUpdate bu =
         updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Change.Id id = rsrc.getChange().getId();
@@ -71,21 +73,25 @@
 
   @Override
   public UiAction.Description getDescription(ChangeResource rsrc) {
-    try {
-      Change.Status status = rsrc.getChange().getStatus();
-      ChangeControl changeControl = rsrc.getControl();
-      boolean visible =
-          isActionAllowed(changeControl, status) && changeControl.canDelete(db.get(), status);
-      return new UiAction.Description()
-          .setLabel("Delete")
-          .setTitle("Delete change " + rsrc.getId())
-          .setVisible(visible);
-    } catch (OrmException e) {
-      throw new IllegalStateException(e);
-    }
+    Change.Status status = rsrc.getChange().getStatus();
+    PermissionBackend.ForChange perm = rsrc.permissions().database(db);
+    return new UiAction.Description()
+        .setLabel("Delete")
+        .setTitle("Delete change " + rsrc.getId())
+        .setVisible(and(couldDeleteWhenIn(status), perm.testCond(ChangePermission.DELETE)));
   }
 
-  private boolean isActionAllowed(ChangeControl changeControl, Status status) {
-    return status != Status.DRAFT || allowDrafts || changeControl.isAdmin();
+  private boolean couldDeleteWhenIn(Change.Status status) {
+    switch (status) {
+      case NEW:
+      case ABANDONED:
+        // New or abandoned changes can be deleted with the right permissions.
+        return true;
+
+      case MERGED:
+        // Merged changes should never be deleted.
+        return false;
+    }
+    return false;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
index f196ec8..e2e3920 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeEdit.java
@@ -41,7 +41,7 @@
   @Override
   public Response<?> apply(ChangeResource rsrc, Input input)
       throws AuthException, ResourceNotFoundException, IOException, OrmException {
-    Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
+    Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
     if (edit.isPresent()) {
       editUtil.delete(edit.get());
     } else {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
index 3db995a..52bd357 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteChangeOp.java
@@ -17,7 +17,6 @@
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -27,7 +26,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.ChangeDeleted;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.BatchUpdateOp;
@@ -39,20 +37,12 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Collection;
-import java.util.Collections;
-import org.eclipse.jgit.lib.Config;
+import java.util.Map;
+import java.util.Optional;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 class DeleteChangeOp implements BatchUpdateOp {
-  static boolean allowDrafts(Config cfg) {
-    return cfg.getBoolean("change", "allowDrafts", true);
-  }
-
   static ReviewDb unwrap(ReviewDb db) {
     // This is special. We want to delete exactly the rows that are present in
     // the database, even when reading everything else from NoteDb, so we need
@@ -66,7 +56,6 @@
   private final PatchSetUtil psUtil;
   private final StarredChangesUtil starredChangesUtil;
   private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-  private final boolean allowDrafts;
   private final ChangeDeleted changeDeleted;
 
   private Change.Id id;
@@ -76,12 +65,10 @@
       PatchSetUtil psUtil,
       StarredChangesUtil starredChangesUtil,
       DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
-      @GerritServerConfig Config cfg,
       ChangeDeleted changeDeleted) {
     this.psUtil = psUtil;
     this.starredChangesUtil = starredChangesUtil;
     this.accountPatchReviewStore = accountPatchReviewStore;
-    this.allowDrafts = allowDrafts(cfg);
     this.changeDeleted = changeDeleted;
   }
 
@@ -107,8 +94,7 @@
   }
 
   private void ensureDeletable(ChangeContext ctx, Change.Id id, Collection<PatchSet> patchSets)
-      throws ResourceConflictException, MethodNotAllowedException, OrmException, AuthException,
-          IOException {
+      throws ResourceConflictException, MethodNotAllowedException, IOException {
     Change.Status status = ctx.getChange().getStatus();
     if (status == Change.Status.MERGED) {
       throw new MethodNotAllowedException("Deleting merged change " + id + " is not allowed");
@@ -121,40 +107,17 @@
                 id, patchSet.getPatchSetId()));
       }
     }
-
-    if (!ctx.getControl().canDelete(ctx.getDb(), status)) {
-      throw new AuthException("Deleting change " + id + " is not permitted");
-    }
-
-    if (status == Change.Status.DRAFT) {
-      if (!allowDrafts && !ctx.getControl().isAdmin()) {
-        throw new MethodNotAllowedException("Draft workflow is disabled");
-      }
-      for (PatchSet ps : patchSets) {
-        if (!ps.isDraft()) {
-          throw new ResourceConflictException(
-              "Cannot delete draft change "
-                  + id
-                  + ": patch set "
-                  + ps.getPatchSetId()
-                  + " is not a draft");
-        }
-      }
-    }
   }
 
   private boolean isPatchSetMerged(ChangeContext ctx, PatchSet patchSet) throws IOException {
-    Repository repository = ctx.getRepository();
-    Ref destinationRef = repository.exactRef(ctx.getChange().getDest().get());
-    if (destinationRef == null) {
+    Optional<ObjectId> destId = ctx.getRepoView().getRef(ctx.getChange().getDest().get());
+    if (!destId.isPresent()) {
       return false;
     }
 
     RevWalk revWalk = ctx.getRevWalk();
     ObjectId objectId = ObjectId.fromString(patchSet.getRevision().get());
-    RevCommit revCommit = revWalk.parseCommit(objectId);
-    return IncludedInResolver.includedInOne(
-        repository, revWalk, revCommit, Collections.singletonList(destinationRef));
+    return revWalk.isMergedInto(revWalk.parseCommit(objectId), revWalk.parseCommit(destId.get()));
   }
 
   private void deleteChangeElementsFromDb(ChangeContext ctx, Change.Id id) throws OrmException {
@@ -182,8 +145,8 @@
   public void updateRepo(RepoContext ctx) throws IOException {
     String prefix = new PatchSet.Id(id, 1).toRefName();
     prefix = prefix.substring(0, prefix.length() - 1);
-    for (Ref ref : ctx.getRepository().getRefDatabase().getRefs(prefix).values()) {
-      ctx.addRefUpdate(new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName()));
+    for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(prefix).entrySet()) {
+      ctx.addRefUpdate(e.getValue(), ObjectId.zeroId(), prefix + e.getKey());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteComment.java
new file mode 100644
index 0000000..00f6568
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteComment.java
@@ -0,0 +1,140 @@
+// 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.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.DeleteCommentInput;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+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.update.BatchUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class DeleteComment
+    extends RetryingRestModifyView<CommentResource, DeleteCommentInput, CommentInfo> {
+
+  private final Provider<CurrentUser> userProvider;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+  private final CommentsUtil commentsUtil;
+  private final Provider<CommentJson> commentJson;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  public DeleteComment(
+      Provider<CurrentUser> userProvider,
+      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
+      RetryHelper retryHelper,
+      CommentsUtil commentsUtil,
+      Provider<CommentJson> commentJson,
+      ChangeNotes.Factory notesFactory) {
+    super(retryHelper);
+    this.userProvider = userProvider;
+    this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
+    this.commentsUtil = commentsUtil;
+    this.commentJson = commentJson;
+    this.notesFactory = notesFactory;
+  }
+
+  @Override
+  public CommentInfo applyImpl(
+      BatchUpdate.Factory batchUpdateFactory, CommentResource rsrc, DeleteCommentInput input)
+      throws RestApiException, IOException, ConfigInvalidException, OrmException,
+          PermissionBackendException, UpdateException {
+    CurrentUser user = userProvider.get();
+    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    if (input == null) {
+      input = new DeleteCommentInput();
+    }
+
+    String newMessage = getCommentNewMessage(user.asIdentifiedUser().getName(), input.reason);
+    DeleteCommentOp deleteCommentOp = new DeleteCommentOp(rsrc, newMessage);
+    try (BatchUpdate batchUpdate =
+        batchUpdateFactory.create(
+            dbProvider.get(), rsrc.getRevisionResource().getProject(), user, TimeUtil.nowTs())) {
+      batchUpdate.addOp(rsrc.getRevisionResource().getChange().getId(), deleteCommentOp).execute();
+    }
+
+    ChangeNotes updatedNotes =
+        notesFactory.createChecked(rsrc.getRevisionResource().getChange().getId());
+    List<Comment> changeComments = commentsUtil.publishedByChange(dbProvider.get(), updatedNotes);
+    Optional<Comment> updatedComment =
+        changeComments.stream().filter(c -> c.key.equals(rsrc.getComment().key)).findFirst();
+    if (!updatedComment.isPresent()) {
+      // This should not happen as this endpoint should not remove the whole comment.
+      throw new ResourceNotFoundException("comment not found: " + rsrc.getComment().key);
+    }
+
+    return commentJson.get().newCommentFormatter().format(updatedComment.get());
+  }
+
+  private static String getCommentNewMessage(String name, String reason) {
+    StringBuilder stringBuilder = new StringBuilder("Comment removed by: ").append(name);
+    if (!Strings.isNullOrEmpty(reason)) {
+      stringBuilder.append("; Reason: ").append(reason);
+    }
+    return stringBuilder.toString();
+  }
+
+  private class DeleteCommentOp implements BatchUpdateOp {
+    private final CommentResource rsrc;
+    private final String newMessage;
+
+    DeleteCommentOp(CommentResource rsrc, String newMessage) {
+      this.rsrc = rsrc;
+      this.newMessage = newMessage;
+    }
+
+    @Override
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceConflictException, OrmException, ResourceNotFoundException {
+      PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+      commentsUtil.deleteCommentByRewritingHistory(
+          ctx.getDb(),
+          ctx.getUpdate(psId),
+          rsrc.getComment().key,
+          rsrc.getPatchSet().getId(),
+          newMessage);
+      return true;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
index 7787260..aec233e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftComment.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -29,9 +28,12 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.DeleteDraftComment.Input;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 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.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,13 +43,13 @@
 import java.util.Optional;
 
 @Singleton
-public class DeleteDraftComment implements RestModifyView<DraftCommentResource, Input> {
+public class DeleteDraftComment
+    extends RetryingRestModifyView<DraftCommentResource, Input, Response<CommentInfo>> {
   static class Input {}
 
   private final Provider<ReviewDb> db;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final BatchUpdate.Factory updateFactory;
   private final PatchListCache patchListCache;
 
   @Inject
@@ -55,24 +57,22 @@
       Provider<ReviewDb> db,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       PatchListCache patchListCache) {
+    super(retryHelper);
     this.db = db;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.updateFactory = updateFactory;
     this.patchListCache = patchListCache;
   }
 
   @Override
-  public Response<CommentInfo> apply(DraftCommentResource rsrc, Input input)
+  protected Response<CommentInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, Input input)
       throws RestApiException, UpdateException {
     try (BatchUpdate bu =
         updateFactory.create(
-            db.get(),
-            rsrc.getChange().getProject(),
-            rsrc.getControl().getUser(),
-            TimeUtil.nowTs())) {
+            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Op op = new Op(rsrc.getComment().key);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -88,8 +88,10 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException, OrmException {
-      Optional<Comment> maybeComment = commentsUtil.get(ctx.getDb(), ctx.getNotes(), key);
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException, PatchListNotAvailableException {
+      Optional<Comment> maybeComment =
+          commentsUtil.getDraft(ctx.getDb(), ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         return false; // Nothing to do.
       }
@@ -101,7 +103,7 @@
       Comment c = maybeComment.get();
       setCommentRevId(c, patchListCache, ctx.getChange(), ps);
       commentsUtil.deleteComments(ctx.getDb(), ctx.getUpdate(psId), Collections.singleton(c));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
deleted file mode 100644
index d452489..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteDraftPatchSet.java
+++ /dev/null
@@ -1,215 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.change.DeleteDraftPatchSet.Input;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-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.Order;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.Map;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.transport.ReceiveCommand;
-
-@Singleton
-public class DeleteDraftPatchSet
-    implements RestModifyView<RevisionResource, Input>, UiAction<RevisionResource> {
-  public static class Input {}
-
-  private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory updateFactory;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final PatchSetUtil psUtil;
-  private final Provider<DeleteChangeOp> deleteChangeOpProvider;
-  private final DynamicItem<AccountPatchReviewStore> accountPatchReviewStore;
-  private final boolean allowDrafts;
-
-  @Inject
-  public DeleteDraftPatchSet(
-      Provider<ReviewDb> db,
-      BatchUpdate.Factory updateFactory,
-      PatchSetInfoFactory patchSetInfoFactory,
-      PatchSetUtil psUtil,
-      Provider<DeleteChangeOp> deleteChangeOpProvider,
-      DynamicItem<AccountPatchReviewStore> accountPatchReviewStore,
-      @GerritServerConfig Config cfg) {
-    this.db = db;
-    this.updateFactory = updateFactory;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.psUtil = psUtil;
-    this.deleteChangeOpProvider = deleteChangeOpProvider;
-    this.accountPatchReviewStore = accountPatchReviewStore;
-    this.allowDrafts = cfg.getBoolean("change", "allowDrafts", true);
-  }
-
-  @Override
-  public Response<?> apply(RevisionResource rsrc, Input input)
-      throws RestApiException, UpdateException {
-    try (BatchUpdate bu =
-        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      bu.setOrder(Order.DB_BEFORE_REPO);
-      bu.addOp(rsrc.getChange().getId(), new Op(rsrc.getPatchSet().getId()));
-      bu.execute();
-    }
-    return Response.none();
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final PatchSet.Id psId;
-
-    private Collection<PatchSet> patchSetsBeforeDeletion;
-    private PatchSet patchSet;
-    private DeleteChangeOp deleteChangeOp;
-
-    private Op(PatchSet.Id psId) {
-      this.psId = psId;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws RestApiException, OrmException, IOException, NoSuchChangeException {
-      Map<PatchSet.Id, PatchSet> patchSets = psUtil.byChangeAsMap(db.get(), ctx.getNotes());
-      patchSet = patchSets.get(psId);
-      if (patchSet == null) {
-        return false; // Nothing to do.
-      }
-      if (!patchSet.isDraft()) {
-        throw new ResourceConflictException("Patch set is not a draft");
-      }
-      if (!allowDrafts) {
-        throw new MethodNotAllowedException("Draft workflow is disabled");
-      }
-      if (!ctx.getControl().canDelete(ctx.getDb(), Change.Status.DRAFT)) {
-        throw new AuthException("Not permitted to delete this draft patch set");
-      }
-
-      patchSetsBeforeDeletion = patchSets.values();
-      deleteDraftPatchSet(patchSet, ctx);
-      deleteOrUpdateDraftChange(ctx, patchSets);
-      return true;
-    }
-
-    @Override
-    public void updateRepo(RepoContext ctx) throws IOException {
-      if (deleteChangeOp != null) {
-        deleteChangeOp.updateRepo(ctx);
-        return;
-      }
-      ctx.addRefUpdate(
-          new ReceiveCommand(
-              ObjectId.fromString(patchSet.getRevision().get()),
-              ObjectId.zeroId(),
-              patchSet.getRefName()));
-    }
-
-    private void deleteDraftPatchSet(PatchSet patchSet, ChangeContext ctx) throws OrmException {
-      // For NoteDb itself, no need to delete these entities, as they are
-      // automatically filtered out when patch sets are deleted.
-      psUtil.delete(ctx.getDb(), ctx.getUpdate(patchSet.getId()), patchSet);
-
-      accountPatchReviewStore.get().clearReviewed(psId);
-      // Use the unwrap from DeleteChangeOp to handle BatchUpdateReviewDb.
-      ReviewDb db = DeleteChangeOp.unwrap(ctx.getDb());
-      db.changeMessages().delete(db.changeMessages().byPatchSet(psId));
-      db.patchComments().delete(db.patchComments().byPatchSet(psId));
-      db.patchSetApprovals().delete(db.patchSetApprovals().byPatchSet(psId));
-    }
-
-    private void deleteOrUpdateDraftChange(ChangeContext ctx, Map<PatchSet.Id, PatchSet> patchSets)
-        throws OrmException, RestApiException, IOException, NoSuchChangeException {
-      Change c = ctx.getChange();
-      if (deletedOnlyPatchSet()) {
-        deleteChangeOp = deleteChangeOpProvider.get();
-        deleteChangeOp.updateChange(ctx);
-        return;
-      }
-      if (c.currentPatchSetId().equals(psId)) {
-        c.setCurrentPatchSet(previousPatchSetInfo(ctx, patchSets));
-      }
-    }
-
-    private boolean deletedOnlyPatchSet() {
-      return patchSetsBeforeDeletion.size() == 1
-          && patchSetsBeforeDeletion.iterator().next().getId().equals(psId);
-    }
-
-    private PatchSetInfo previousPatchSetInfo(
-        ChangeContext ctx, Map<PatchSet.Id, PatchSet> patchSets) throws OrmException {
-      PatchSet.Id prevPsId = null;
-      for (PatchSet.Id id : patchSets.keySet()) {
-        if (id.get() < psId.get() && (prevPsId == null || id.get() > prevPsId.get())) {
-          prevPsId = id;
-        }
-      }
-
-      try {
-        // TODO(dborowitz): Get this in a way that doesn't involve re-opening
-        // the repo after the updateRepo phase.
-        return patchSetInfoFactory.get(
-            ctx.getDb(),
-            ctx.getNotes(),
-            new PatchSet.Id(psId.getParentKey(), checkNotNull(prevPsId).get()));
-      } catch (PatchSetInfoNotAvailableException e) {
-        throw new OrmException(e);
-      }
-    }
-  }
-
-  @Override
-  public UiAction.Description getDescription(RevisionResource rsrc) {
-    try {
-      int psCount = psUtil.byChange(db.get(), rsrc.getNotes()).size();
-      return new UiAction.Description()
-          .setLabel("Delete")
-          .setTitle(String.format("Delete draft revision %d", rsrc.getPatchSet().getPatchSetId()))
-          .setVisible(
-              allowDrafts
-                  && rsrc.getPatchSet().isDraft()
-                  && rsrc.getControl().canDelete(db.get(), Change.Status.DRAFT)
-                  && psCount > 1);
-    } catch (OrmException e) {
-      throw new IllegalStateException(e);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java
new file mode 100644
index 0000000..ba5403a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivate.java
@@ -0,0 +1,85 @@
+// 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.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeletePrivate
+    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>> {
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+  private final SetPrivateOp.Factory setPrivateOpFactory;
+
+  @Inject
+  DeletePrivate(
+      Provider<ReviewDb> dbProvider,
+      RetryHelper retryHelper,
+      ChangeMessagesUtil cmUtil,
+      PermissionBackend permissionBackend,
+      SetPrivateOp.Factory setPrivateOpFactory) {
+    super(retryHelper);
+    this.dbProvider = dbProvider;
+    this.cmUtil = cmUtil;
+    this.permissionBackend = permissionBackend;
+    this.setPrivateOpFactory = setPrivateOpFactory;
+  }
+
+  @Override
+  protected Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
+      throws RestApiException, UpdateException {
+    if (!canDeletePrivate(rsrc).value()) {
+      throw new AuthException("not allowed to unmark private");
+    }
+
+    if (!rsrc.getChange().isPrivate()) {
+      throw new ResourceConflictException("change is not private");
+    }
+
+    SetPrivateOp op = setPrivateOpFactory.create(cmUtil, false, input);
+    try (BatchUpdate u =
+        updateFactory.create(
+            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      u.addOp(rsrc.getId(), op).execute();
+    }
+
+    return Response.none();
+  }
+
+  protected BooleanCondition canDeletePrivate(ChangeResource rsrc) {
+    PermissionBackend.WithUser user = permissionBackend.user(rsrc.getUser());
+    return or(rsrc.isUserOwner(), user.testCond(GlobalPermission.ADMINISTRATE_SERVER));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivateByPost.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivateByPost.java
new file mode 100644
index 0000000..a392492
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeletePrivateByPost.java
@@ -0,0 +1,47 @@
+// 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.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class DeletePrivateByPost extends DeletePrivate implements UiAction<ChangeResource> {
+  @Inject
+  DeletePrivateByPost(
+      Provider<ReviewDb> dbProvider,
+      RetryHelper retryHelper,
+      ChangeMessagesUtil cmUtil,
+      PermissionBackend permissionBackend,
+      SetPrivateOp.Factory setPrivateOpFactory) {
+    super(dbProvider, retryHelper, cmUtil, permissionBackend, setPrivateOpFactory);
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unmark private")
+        .setTitle("Unmark change as private")
+        .setVisible(and(rsrc.getChange().isPrivate(), canDeletePrivate(rsrc)));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
index 1485d03..c2bcd69 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
@@ -14,255 +14,63 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.common.collect.Iterables;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.extensions.events.ReviewerDeleted;
-import com.google.gerrit.server.mail.send.DeleteReviewerSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.BatchUpdateReviewDb;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
-public class DeleteReviewer implements RestModifyView<ReviewerResource, DeleteReviewerInput> {
-  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
+public class DeleteReviewer
+    extends RetryingRestModifyView<ReviewerResource, DeleteReviewerInput, Response<?>> {
 
   private final Provider<ReviewDb> dbProvider;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final ReviewerDeleted reviewerDeleted;
-  private final Provider<IdentifiedUser> user;
-  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
-  private final NotesMigration migration;
-  private final NotifyUtil notifyUtil;
+  private final DeleteReviewerOp.Factory deleteReviewerOpFactory;
+  private final DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory;
 
   @Inject
   DeleteReviewer(
       Provider<ReviewDb> dbProvider,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      ReviewerDeleted reviewerDeleted,
-      Provider<IdentifiedUser> user,
-      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
-      NotesMigration migration,
-      NotifyUtil notifyUtil) {
+      RetryHelper retryHelper,
+      DeleteReviewerOp.Factory deleteReviewerOpFactory,
+      DeleteReviewerByEmailOp.Factory deleteReviewerByEmailOpFactory) {
+    super(retryHelper);
     this.dbProvider = dbProvider;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.cmUtil = cmUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.userFactory = userFactory;
-    this.reviewerDeleted = reviewerDeleted;
-    this.user = user;
-    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
-    this.migration = migration;
-    this.notifyUtil = notifyUtil;
+    this.deleteReviewerOpFactory = deleteReviewerOpFactory;
+    this.deleteReviewerByEmailOpFactory = deleteReviewerByEmailOpFactory;
   }
 
   @Override
-  public Response<?> apply(ReviewerResource rsrc, DeleteReviewerInput input)
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ReviewerResource rsrc, DeleteReviewerInput input)
       throws RestApiException, UpdateException {
     if (input == null) {
       input = new DeleteReviewerInput();
     }
-    if (input.notify == null) {
-      input.notify = NotifyHandling.ALL;
-    }
 
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
+        updateFactory.create(
             dbProvider.get(),
             rsrc.getChangeResource().getProject(),
             rsrc.getChangeResource().getUser(),
             TimeUtil.nowTs())) {
-      Op op = new Op(rsrc.getReviewerUser().getAccount(), input);
+      BatchUpdateOp op;
+      if (rsrc.isByEmail()) {
+        op = deleteReviewerByEmailOpFactory.create(rsrc.getReviewerByEmail(), input);
+      } else {
+        op = deleteReviewerOpFactory.create(rsrc.getReviewerUser().getAccount(), input);
+      }
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
     }
-
     return Response.none();
   }
-
-  private class Op implements BatchUpdateOp {
-    private final Account reviewer;
-    private final DeleteReviewerInput input;
-    ChangeMessage changeMessage;
-    Change currChange;
-    PatchSet currPs;
-    Map<String, Short> newApprovals = new HashMap<>();
-    Map<String, Short> oldApprovals = new HashMap<>();
-
-    Op(Account reviewerAccount, DeleteReviewerInput input) {
-      this.reviewer = reviewerAccount;
-      this.input = input;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws AuthException, ResourceNotFoundException, OrmException {
-      Account.Id reviewerId = reviewer.getId();
-      if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
-        throw new ResourceNotFoundException();
-      }
-      currChange = ctx.getChange();
-      currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
-
-      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
-      // removing a reviewer will remove all her votes
-      for (LabelType lt : labelTypes.getLabelTypes()) {
-        newApprovals.put(lt.getName(), (short) 0);
-      }
-
-      StringBuilder msg = new StringBuilder();
-      msg.append("Removed reviewer " + reviewer.getFullName());
-      StringBuilder removedVotesMsg = new StringBuilder();
-      removedVotesMsg.append(" with the following votes:\n\n");
-      List<PatchSetApproval> del = new ArrayList<>();
-      boolean votesRemoved = false;
-      for (PatchSetApproval a : approvals(ctx, reviewerId)) {
-        if (ctx.getControl().canRemoveReviewer(a)) {
-          del.add(a);
-          if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
-            oldApprovals.put(a.getLabel(), a.getValue());
-            removedVotesMsg
-                .append("* ")
-                .append(a.getLabel())
-                .append(formatLabelValue(a.getValue()))
-                .append(" by ")
-                .append(userFactory.create(a.getAccountId()).getNameEmail())
-                .append("\n");
-            votesRemoved = true;
-          }
-        } else {
-          throw new AuthException("delete reviewer not permitted");
-        }
-      }
-
-      if (votesRemoved) {
-        msg.append(removedVotesMsg);
-      } else {
-        msg.append(".");
-      }
-      ctx.getDb().patchSetApprovals().delete(del);
-      ChangeUpdate update = ctx.getUpdate(currPs.getId());
-      update.removeReviewer(reviewerId);
-
-      changeMessage =
-          ChangeMessagesUtil.newMessage(
-              ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
-      cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
-
-      return true;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) {
-      if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
-        emailReviewers(ctx.getProject(), currChange, changeMessage);
-      }
-      reviewerDeleted.fire(
-          currChange,
-          currPs,
-          reviewer,
-          ctx.getAccount(),
-          changeMessage.getMessage(),
-          newApprovals,
-          oldApprovals,
-          input.notify,
-          ctx.getWhen());
-    }
-
-    private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
-        throws OrmException {
-      Change.Id changeId = ctx.getNotes().getChangeId();
-      Iterable<PatchSetApproval> approvals;
-      PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
-
-      if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
-        // Because NoteDb and ReviewDb have different semantics for zero-value
-        // approvals, we must fall back to ReviewDb as the source of truth here.
-        ReviewDb db = ctx.getDb();
-
-        if (db instanceof BatchUpdateReviewDb) {
-          db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
-        }
-        db = ReviewDbUtil.unwrapDb(db);
-        approvals = db.patchSetApprovals().byChange(changeId);
-      } else {
-        approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
-      }
-
-      return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
-    }
-
-    private String formatLabelValue(short value) {
-      if (value > 0) {
-        return "+" + value;
-      }
-      return Short.toString(value);
-    }
-
-    private void emailReviewers(
-        Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
-      Account.Id userId = user.get().getAccountId();
-      if (userId.equals(reviewer.getId())) {
-        // The user knows they removed themselves, don't bother emailing them.
-        return;
-      }
-      try {
-        DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
-        cm.setFrom(userId);
-        cm.addReviewers(Collections.singleton(reviewer.getId()));
-        cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
-        cm.setNotify(input.notify);
-        cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
-        cm.send();
-      } catch (Exception err) {
-        log.error("Cannot email update for change " + change.getId(), err);
-      }
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
new file mode 100644
index 0000000..341ad4a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerByEmailOp.java
@@ -0,0 +1,105 @@
+// 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.change;
+
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+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.server.ChangeUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.util.Collections;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteReviewerByEmailOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
+
+  public interface Factory {
+    DeleteReviewerByEmailOp create(Address reviewer, DeleteReviewerInput input);
+  }
+
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotifyUtil notifyUtil;
+  private final Address reviewer;
+  private final DeleteReviewerInput input;
+
+  private ChangeMessage changeMessage;
+  private Change change;
+
+  @Inject
+  DeleteReviewerByEmailOp(
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotifyUtil notifyUtil,
+      @Assisted Address reviewer,
+      @Assisted DeleteReviewerInput input) {
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.notifyUtil = notifyUtil;
+    this.reviewer = reviewer;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException {
+    change = ctx.getChange();
+    PatchSet.Id psId = ctx.getChange().currentPatchSetId();
+    String msg = "Removed reviewer " + reviewer;
+    changeMessage =
+        new ChangeMessage(
+            new ChangeMessage.Key(change.getId(), ChangeUtil.messageUuid()),
+            ctx.getAccountId(),
+            ctx.getWhen(),
+            psId);
+    changeMessage.setMessage(msg);
+
+    ctx.getUpdate(psId).setChangeMessage(msg);
+    ctx.getUpdate(psId).removeReviewerByEmail(reviewer);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (input.notify == null) {
+      if (change.isWorkInProgress()) {
+        input.notify = NotifyHandling.NONE;
+      } else {
+        input.notify = NotifyHandling.ALL;
+      }
+    }
+    if (!NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+      return;
+    }
+    try {
+      DeleteReviewerSender cm =
+          deleteReviewerSenderFactory.create(ctx.getProject(), change.getId());
+      cm.setFrom(ctx.getAccountId());
+      cm.addReviewersByEmail(Collections.singleton(reviewer));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(input.notify);
+      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot email update for change " + change.getId(), err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java
new file mode 100644
index 0000000..ad1cf60
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewerOp.java
@@ -0,0 +1,253 @@
+// 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.change;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.extensions.api.changes.DeleteReviewerInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.ReviewerDeleted;
+import com.google.gerrit.server.mail.send.DeleteReviewerSender;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.RemoveReviewerControl;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.BatchUpdateReviewDb;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class DeleteReviewerOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(DeleteReviewer.class);
+
+  public interface Factory {
+    DeleteReviewerOp create(Account reviewerAccount, DeleteReviewerInput input);
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ChangeMessagesUtil cmUtil;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final ReviewerDeleted reviewerDeleted;
+  private final Provider<IdentifiedUser> user;
+  private final DeleteReviewerSender.Factory deleteReviewerSenderFactory;
+  private final NotesMigration migration;
+  private final NotifyUtil notifyUtil;
+  private final RemoveReviewerControl removeReviewerControl;
+  private final ProjectCache projectCache;
+
+  private final Account reviewer;
+  private final DeleteReviewerInput input;
+
+  ChangeMessage changeMessage;
+  Change currChange;
+  PatchSet currPs;
+  Map<String, Short> newApprovals = new HashMap<>();
+  Map<String, Short> oldApprovals = new HashMap<>();
+
+  @Inject
+  DeleteReviewerOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ChangeMessagesUtil cmUtil,
+      IdentifiedUser.GenericFactory userFactory,
+      ReviewerDeleted reviewerDeleted,
+      Provider<IdentifiedUser> user,
+      DeleteReviewerSender.Factory deleteReviewerSenderFactory,
+      NotesMigration migration,
+      NotifyUtil notifyUtil,
+      RemoveReviewerControl removeReviewerControl,
+      ProjectCache projectCache,
+      @Assisted Account reviewerAccount,
+      @Assisted DeleteReviewerInput input) {
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.cmUtil = cmUtil;
+    this.userFactory = userFactory;
+    this.reviewerDeleted = reviewerDeleted;
+    this.user = user;
+    this.deleteReviewerSenderFactory = deleteReviewerSenderFactory;
+    this.migration = migration;
+    this.notifyUtil = notifyUtil;
+    this.removeReviewerControl = removeReviewerControl;
+    this.projectCache = projectCache;
+    this.reviewer = reviewerAccount;
+    this.input = input;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws AuthException, ResourceNotFoundException, OrmException, PermissionBackendException,
+          IOException, NoSuchProjectException {
+    Account.Id reviewerId = reviewer.getId();
+    // Check of removing this reviewer (even if there is no vote processed by the loop below) is OK
+    removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), reviewerId);
+
+    if (!approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all().contains(reviewerId)) {
+      throw new ResourceNotFoundException();
+    }
+    currChange = ctx.getChange();
+    currPs = psUtil.current(ctx.getDb(), ctx.getNotes());
+
+    LabelTypes labelTypes =
+        projectCache.checkedGet(ctx.getProject()).getLabelTypes(ctx.getNotes(), ctx.getUser());
+    // removing a reviewer will remove all her votes
+    for (LabelType lt : labelTypes.getLabelTypes()) {
+      newApprovals.put(lt.getName(), (short) 0);
+    }
+
+    StringBuilder msg = new StringBuilder();
+    msg.append("Removed reviewer " + reviewer.getFullName());
+    StringBuilder removedVotesMsg = new StringBuilder();
+    removedVotesMsg.append(" with the following votes:\n\n");
+    List<PatchSetApproval> del = new ArrayList<>();
+    boolean votesRemoved = false;
+    for (PatchSetApproval a : approvals(ctx, reviewerId)) {
+      // Check if removing this vote is OK
+      removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
+      del.add(a);
+      if (a.getPatchSetId().equals(currPs.getId()) && a.getValue() != 0) {
+        oldApprovals.put(a.getLabel(), a.getValue());
+        removedVotesMsg
+            .append("* ")
+            .append(a.getLabel())
+            .append(formatLabelValue(a.getValue()))
+            .append(" by ")
+            .append(userFactory.create(a.getAccountId()).getNameEmail())
+            .append("\n");
+        votesRemoved = true;
+      }
+    }
+
+    if (votesRemoved) {
+      msg.append(removedVotesMsg);
+    } else {
+      msg.append(".");
+    }
+    ctx.getDb().patchSetApprovals().delete(del);
+    ChangeUpdate update = ctx.getUpdate(currPs.getId());
+    update.removeReviewer(reviewerId);
+
+    changeMessage =
+        ChangeMessagesUtil.newMessage(ctx, msg.toString(), ChangeMessagesUtil.TAG_DELETE_REVIEWER);
+    cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
+
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    if (input.notify == null) {
+      if (currChange.isWorkInProgress()) {
+        input.notify = oldApprovals.isEmpty() ? NotifyHandling.NONE : NotifyHandling.OWNER;
+      } else {
+        input.notify = NotifyHandling.ALL;
+      }
+    }
+    if (NotifyUtil.shouldNotify(input.notify, input.notifyDetails)) {
+      emailReviewers(ctx.getProject(), currChange, changeMessage);
+    }
+    reviewerDeleted.fire(
+        currChange,
+        currPs,
+        reviewer,
+        ctx.getAccount(),
+        changeMessage.getMessage(),
+        newApprovals,
+        oldApprovals,
+        input.notify,
+        ctx.getWhen());
+  }
+
+  private Iterable<PatchSetApproval> approvals(ChangeContext ctx, Account.Id accountId)
+      throws OrmException {
+    Change.Id changeId = ctx.getNotes().getChangeId();
+    Iterable<PatchSetApproval> approvals;
+    PrimaryStorage r = PrimaryStorage.of(ctx.getChange());
+
+    if (migration.readChanges() && r == PrimaryStorage.REVIEW_DB) {
+      // Because NoteDb and ReviewDb have different semantics for zero-value
+      // approvals, we must fall back to ReviewDb as the source of truth here.
+      ReviewDb db = ctx.getDb();
+
+      if (db instanceof BatchUpdateReviewDb) {
+        db = ((BatchUpdateReviewDb) db).unsafeGetDelegate();
+      }
+      db = ReviewDbUtil.unwrapDb(db);
+      approvals = db.patchSetApprovals().byChange(changeId);
+    } else {
+      approvals = approvalsUtil.byChange(ctx.getDb(), ctx.getNotes()).values();
+    }
+
+    return Iterables.filter(approvals, psa -> accountId.equals(psa.getAccountId()));
+  }
+
+  private String formatLabelValue(short value) {
+    if (value > 0) {
+      return "+" + value;
+    }
+    return Short.toString(value);
+  }
+
+  private void emailReviewers(
+      Project.NameKey projectName, Change change, ChangeMessage changeMessage) {
+    Account.Id userId = user.get().getAccountId();
+    if (userId.equals(reviewer.getId())) {
+      // The user knows they removed themselves, don't bother emailing them.
+      return;
+    }
+    try {
+      DeleteReviewerSender cm = deleteReviewerSenderFactory.create(projectName, change.getId());
+      cm.setFrom(userId);
+      cm.addReviewers(Collections.singleton(reviewer.getId()));
+      cm.setChangeMessage(changeMessage.getMessage(), changeMessage.getWrittenOn());
+      cm.setNotify(input.notify);
+      cm.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot email update for change " + change.getId(), err);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
index e02dee9..8c6c3cc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -41,17 +40,24 @@
 import com.google.gerrit.server.extensions.events.VoteDeleted;
 import com.google.gerrit.server.mail.send.DeleteVoteSender;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.RemoveReviewerControl;
 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.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
@@ -59,11 +65,10 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class DeleteVote implements RestModifyView<VoteResource, DeleteVoteInput> {
+public class DeleteVote extends RetryingRestModifyView<VoteResource, DeleteVoteInput, Response<?>> {
   private static final Logger log = LoggerFactory.getLogger(DeleteVote.class);
 
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final ApprovalsUtil approvalsUtil;
   private final PatchSetUtil psUtil;
   private final ChangeMessagesUtil cmUtil;
@@ -71,20 +76,24 @@
   private final VoteDeleted voteDeleted;
   private final DeleteVoteSender.Factory deleteVoteSenderFactory;
   private final NotifyUtil notifyUtil;
+  private final RemoveReviewerControl removeReviewerControl;
+  private final ProjectCache projectCache;
 
   @Inject
   DeleteVote(
       Provider<ReviewDb> db,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       ApprovalsUtil approvalsUtil,
       PatchSetUtil psUtil,
       ChangeMessagesUtil cmUtil,
       IdentifiedUser.GenericFactory userFactory,
       VoteDeleted voteDeleted,
       DeleteVoteSender.Factory deleteVoteSenderFactory,
-      NotifyUtil notifyUtil) {
+      NotifyUtil notifyUtil,
+      RemoveReviewerControl removeReviewerControl,
+      ProjectCache projectCache) {
+    super(retryHelper);
     this.db = db;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.approvalsUtil = approvalsUtil;
     this.psUtil = psUtil;
     this.cmUtil = cmUtil;
@@ -92,11 +101,14 @@
     this.voteDeleted = voteDeleted;
     this.deleteVoteSenderFactory = deleteVoteSenderFactory;
     this.notifyUtil = notifyUtil;
+    this.removeReviewerControl = removeReviewerControl;
+    this.projectCache = projectCache;
   }
 
   @Override
-  public Response<?> apply(VoteResource rsrc, DeleteVoteInput input)
-      throws RestApiException, UpdateException {
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, VoteResource rsrc, DeleteVoteInput input)
+      throws RestApiException, UpdateException, IOException {
     if (input == null) {
       input = new DeleteVoteInput();
     }
@@ -114,9 +126,15 @@
     }
 
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db.get(), change.getProject(), r.getControl().getUser(), TimeUtil.nowTs())) {
-      bu.addOp(change.getId(), new Op(r.getReviewerUser().getAccount(), rsrc.getLabel(), input));
+        updateFactory.create(
+            db.get(), change.getProject(), r.getChangeResource().getUser(), TimeUtil.nowTs())) {
+      bu.addOp(
+          change.getId(),
+          new Op(
+              projectCache.checkedGet(r.getChange().getProject()),
+              r.getReviewerUser().getAccount(),
+              rsrc.getLabel(),
+              input));
       bu.execute();
     }
 
@@ -124,16 +142,19 @@
   }
 
   private class Op implements BatchUpdateOp {
+    private final ProjectState projectState;
     private final Account account;
     private final String label;
     private final DeleteVoteInput input;
+
     private ChangeMessage changeMessage;
     private Change change;
     private PatchSet ps;
     private Map<String, Short> newApprovals = new HashMap<>();
     private Map<String, Short> oldApprovals = new HashMap<>();
 
-    private Op(Account account, String label, DeleteVoteInput input) {
+    private Op(ProjectState projectState, Account account, String label, DeleteVoteInput input) {
+      this.projectState = projectState;
       this.account = account;
       this.label = label;
       this.input = input;
@@ -141,25 +162,36 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, AuthException, ResourceNotFoundException {
-      ChangeControl ctl = ctx.getControl();
-      change = ctl.getChange();
+        throws OrmException, AuthException, ResourceNotFoundException, IOException,
+            PermissionBackendException, NoSuchProjectException {
+      change = ctx.getChange();
       PatchSet.Id psId = change.currentPatchSetId();
-      ps = psUtil.current(db.get(), ctl.getNotes());
+      ps = psUtil.current(db.get(), ctx.getNotes());
 
       boolean found = false;
-      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
 
       for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(ctx.getDb(), ctl, psId, account.getId())) {
+          approvalsUtil.byPatchSetUser(
+              ctx.getDb(),
+              ctx.getNotes(),
+              ctx.getUser(),
+              psId,
+              account.getId(),
+              ctx.getRevWalk(),
+              ctx.getRepoView().getConfig())) {
         if (labelTypes.byLabel(a.getLabelId()) == null) {
           continue; // Ignore undefined labels.
         } else if (!a.getLabel().equals(label)) {
           // Populate map for non-matching labels, needed by VoteDeleted.
           newApprovals.put(a.getLabel(), a.getValue());
           continue;
-        } else if (!ctl.canRemoveReviewer(a)) {
-          throw new AuthException("delete vote not permitted");
+        } else {
+          try {
+            removeReviewerControl.checkRemoveReviewer(ctx.getNotes(), ctx.getUser(), a);
+          } catch (AuthException e) {
+            throw new AuthException("delete vote not permitted", e);
+          }
         }
         // Set the approval to 0 if vote is being removed.
         newApprovals.put(a.getLabel(), (short) 0);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java
index 827dfcd..311a25c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DownloadContent.java
@@ -18,7 +18,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -27,22 +27,24 @@
 
 public class DownloadContent implements RestReadView<FileResource> {
   private final FileContentUtil fileContentUtil;
+  private final ProjectCache projectCache;
 
   @Option(name = "--parent")
   private Integer parent;
 
   @Inject
-  DownloadContent(FileContentUtil fileContentUtil) {
+  DownloadContent(FileContentUtil fileContentUtil, ProjectCache projectCache) {
     this.fileContentUtil = fileContentUtil;
+    this.projectCache = projectCache;
   }
 
   @Override
   public BinaryResult apply(FileResource rsrc)
       throws ResourceNotFoundException, IOException, NoSuchChangeException, OrmException {
     String path = rsrc.getPatchKey().get();
-    ProjectState projectState =
-        rsrc.getRevision().getControl().getProjectControl().getProjectState();
-    ObjectId revstr = ObjectId.fromString(rsrc.getRevision().getPatchSet().getRevision().get());
-    return fileContentUtil.downloadContent(projectState, revstr, path, parent);
+    RevisionResource rev = rsrc.getRevision();
+    ObjectId revstr = ObjectId.fromString(rev.getPatchSet().getRevision().get());
+    return fileContentUtil.downloadContent(
+        projectCache.checkedGet(rev.getProject()), revstr, path, parent);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
index 781216c..0b1b15d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DraftCommentResource.java
@@ -16,11 +16,10 @@
 
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
 
 public class DraftCommentResource implements RestResource {
@@ -35,12 +34,12 @@
     this.comment = c;
   }
 
-  public ChangeControl getControl() {
-    return rev.getControl();
+  public CurrentUser getUser() {
+    return rev.getUser();
   }
 
   public Change getChange() {
-    return getControl().getChange();
+    return rev.getChange();
   }
 
   public PatchSet getPatchSet() {
@@ -54,8 +53,4 @@
   String getId() {
     return comment.key.uuid;
   }
-
-  Account.Id getAuthorId() {
-    return getControl().getUser().getAccountId();
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
index 2e8fc2d..8e3ee9f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/EmailReviewComments.java
@@ -50,6 +50,24 @@
   private static final Logger log = LoggerFactory.getLogger(EmailReviewComments.class);
 
   public interface Factory {
+    // TODO(dborowitz/wyatta): Rationalize these arguments so HTML and text templates are operating
+    // on the same set of inputs.
+    /**
+     * @param notify setting for handling notification.
+     * @param accountsToNotify detailed map of accounts to notify.
+     * @param notes change notes.
+     * @param patchSet patch set corresponding to the top-level op
+     * @param user user the email should come from.
+     * @param message used by text template only: the full ChangeMessage that will go in the
+     *     database. The contents of this message typically include the "Patch set N" header and "(M
+     *     comments)".
+     * @param comments inline comments.
+     * @param patchSetComment used by HTML template only: some quasi-human-generated text. The
+     *     contents should *not* include a "Patch set N" header or "(M comments)" footer, as these
+     *     will be added automatically in soy in a structured way.
+     * @param labels labels applied as part of this review operation.
+     * @return handle for sending email.
+     */
     EmailReviewComments create(
         NotifyHandling notify,
         ListMultimap<RecipientType, Account.Id> accountsToNotify,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
index cda2c11..00b7a88 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileContentUtil.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.PatchScript.FileMode;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Patch;
@@ -33,7 +34,7 @@
 import eu.medsea.mimeutil.MimeType;
 import java.io.IOException;
 import java.io.OutputStream;
-import java.util.Random;
+import java.security.SecureRandom;
 import java.util.zip.ZipEntry;
 import java.util.zip.ZipOutputStream;
 import org.eclipse.jgit.errors.LargeObjectException;
@@ -41,7 +42,6 @@
 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.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -56,7 +56,7 @@
   private static final String X_GIT_GITLINK = "x-git/gitlink";
   private static final int MAX_SIZE = 5 << 20;
   private static final String ZIP_TYPE = "application/zip";
-  private static final Random rng = new Random();
+  private static final SecureRandom rng = new SecureRandom();
 
   private final GitRepositoryManager repoManager;
   private final FileTypeRegistry registry;
@@ -67,44 +67,75 @@
     this.registry = ftr;
   }
 
-  public BinaryResult getContent(ProjectState project, ObjectId revstr, String path)
-      throws ResourceNotFoundException, IOException {
+  /**
+   * Get the content of a file at a specific commit or one of it's parent commits.
+   *
+   * @param project A {@code Project} that this request refers to.
+   * @param revstr An {@code ObjectId} specifying the commit.
+   * @param path A string specifying the filepath.
+   * @param parent A 1-based parent index to get the content from instead. Null if the content
+   *     should be obtained from {@code revstr} instead.
+   * @return Content of the file as {@code BinaryResult}.
+   * @throws ResourceNotFoundException
+   * @throws IOException
+   */
+  public BinaryResult getContent(
+      ProjectState project, ObjectId revstr, String path, @Nullable Integer parent)
+      throws BadRequestException, ResourceNotFoundException, IOException {
     try (Repository repo = openRepository(project);
         RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(revstr);
-      ObjectReader reader = rw.getObjectReader();
-      TreeWalk tw = TreeWalk.forPath(reader, path, commit.getTree());
-      if (tw == null) {
-        throw new ResourceNotFoundException();
+      if (parent != null) {
+        RevCommit revCommit = rw.parseCommit(revstr);
+        if (revCommit == null) {
+          throw new ResourceNotFoundException("commit not found");
+        }
+        if (parent > revCommit.getParentCount()) {
+          throw new BadRequestException("invalid parent");
+        }
+        revstr = rw.parseCommit(revstr).getParent(Integer.max(0, parent - 1)).toObjectId();
       }
-
-      org.eclipse.jgit.lib.FileMode mode = tw.getFileMode(0);
-      ObjectId id = tw.getObjectId(0);
-      if (mode == org.eclipse.jgit.lib.FileMode.GITLINK) {
-        return BinaryResult.create(id.name()).setContentType(X_GIT_GITLINK).base64();
-      }
-
-      ObjectLoader obj = repo.open(id, OBJ_BLOB);
-      byte[] raw;
-      try {
-        raw = obj.getCachedBytes(MAX_SIZE);
-      } catch (LargeObjectException e) {
-        raw = null;
-      }
-
-      String type;
-      if (mode == org.eclipse.jgit.lib.FileMode.SYMLINK) {
-        type = X_GIT_SYMLINK;
-      } else {
-        type = registry.getMimeType(path, raw).toString();
-        type = resolveContentType(project, path, FileMode.FILE, type);
-      }
-
-      return asBinaryResult(raw, obj).setContentType(type).base64();
+      return getContent(repo, project, revstr, path);
     }
   }
 
-  private static BinaryResult asBinaryResult(byte[] raw, final ObjectLoader obj) {
+  public BinaryResult getContent(
+      Repository repo, ProjectState project, ObjectId revstr, String path)
+      throws IOException, ResourceNotFoundException {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit commit = rw.parseCommit(revstr);
+      try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, commit.getTree())) {
+        if (tw == null) {
+          throw new ResourceNotFoundException();
+        }
+
+        org.eclipse.jgit.lib.FileMode mode = tw.getFileMode(0);
+        ObjectId id = tw.getObjectId(0);
+        if (mode == org.eclipse.jgit.lib.FileMode.GITLINK) {
+          return BinaryResult.create(id.name()).setContentType(X_GIT_GITLINK).base64();
+        }
+
+        ObjectLoader obj = repo.open(id, OBJ_BLOB);
+        byte[] raw;
+        try {
+          raw = obj.getCachedBytes(MAX_SIZE);
+        } catch (LargeObjectException e) {
+          raw = null;
+        }
+
+        String type;
+        if (mode == org.eclipse.jgit.lib.FileMode.SYMLINK) {
+          type = X_GIT_SYMLINK;
+        } else {
+          type = registry.getMimeType(path, raw).toString();
+          type = resolveContentType(project, path, FileMode.FILE, type);
+        }
+
+        return asBinaryResult(raw, obj).setContentType(type).base64();
+      }
+    }
+  }
+
+  private static BinaryResult asBinaryResult(byte[] raw, ObjectLoader obj) {
     if (raw != null) {
       return BinaryResult.create(raw);
     }
@@ -134,30 +165,30 @@
         }
         commit = rw.parseCommit(commit.getParent(parent - 1));
       }
-      ObjectReader reader = rw.getObjectReader();
-      TreeWalk tw = TreeWalk.forPath(reader, path, commit.getTree());
-      if (tw == null) {
-        throw new ResourceNotFoundException();
-      }
+      try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), path, commit.getTree())) {
+        if (tw == null) {
+          throw new ResourceNotFoundException();
+        }
 
-      int mode = tw.getFileMode(0).getObjectType();
-      if (mode != Constants.OBJ_BLOB) {
-        throw new ResourceNotFoundException();
-      }
+        int mode = tw.getFileMode(0).getObjectType();
+        if (mode != Constants.OBJ_BLOB) {
+          throw new ResourceNotFoundException();
+        }
 
-      ObjectId id = tw.getObjectId(0);
-      ObjectLoader obj = repo.open(id, OBJ_BLOB);
-      byte[] raw;
-      try {
-        raw = obj.getCachedBytes(MAX_SIZE);
-      } catch (LargeObjectException e) {
-        raw = null;
-      }
+        ObjectId id = tw.getObjectId(0);
+        ObjectLoader obj = repo.open(id, OBJ_BLOB);
+        byte[] raw;
+        try {
+          raw = obj.getCachedBytes(MAX_SIZE);
+        } catch (LargeObjectException e) {
+          raw = null;
+        }
 
-      MimeType contentType = registry.getMimeType(path, raw);
-      return registry.isSafeInline(contentType)
-          ? wrapBlob(path, obj, raw, contentType, suffix)
-          : zipBlob(path, obj, commit, suffix);
+        MimeType contentType = registry.getMimeType(path, raw);
+        return registry.isSafeInline(contentType)
+            ? wrapBlob(path, obj, raw, contentType, suffix)
+            : zipBlob(path, obj, commit, suffix);
+      }
     }
   }
 
@@ -174,7 +205,7 @@
 
   @SuppressWarnings("resource")
   private BinaryResult zipBlob(
-      final String path, final ObjectLoader obj, RevCommit commit, @Nullable final String suffix) {
+      final String path, ObjectLoader obj, RevCommit commit, @Nullable final String suffix) {
     final String commitName = commit.getName();
     final long when = commit.getCommitTime() * 1000L;
     return new BinaryResult() {
@@ -235,14 +266,13 @@
     }
   }
 
-  @SuppressWarnings("deprecation") // Use Hashing.md5 for compatibility.
   private static String randSuffix() {
     // Produce a random suffix that is difficult (or nearly impossible)
     // for an attacker to guess in advance. This reduces the risk that
     // an attacker could upload a *.class file and have us send a ZIP
     // that can be invoked through an applet tag in the victim's browser.
     //
-    Hasher h = Hashing.md5().newHasher();
+    Hasher h = Hashing.murmur3_128().newHasher();
     byte[] buf = new byte[8];
 
     NB.encodeInt64(buf, 0, TimeUtil.nowMs());
@@ -284,6 +314,6 @@
 
   private Repository openRepository(ProjectState project)
       throws RepositoryNotFoundException, IOException {
-    return repoManager.openRepository(project.getProject().getNameKey());
+    return repoManager.openRepository(project.getNameKey());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
index 60a4daf..6ccd460 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FileInfoJson.java
@@ -48,9 +48,14 @@
 
   Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, @Nullable PatchSet base)
       throws PatchListNotAvailableException {
+    ObjectId objectId = ObjectId.fromString(revision.get());
+    return toFileInfoMap(change, objectId, base);
+  }
+
+  Map<String, FileInfo> toFileInfoMap(Change change, ObjectId objectId, @Nullable PatchSet base)
+      throws PatchListNotAvailableException {
     ObjectId a = (base == null) ? null : ObjectId.fromString(base.getRevision().get());
-    ObjectId b = ObjectId.fromString(revision.get());
-    return toFileInfoMap(change, new PatchListKey(a, b, Whitespace.IGNORE_NONE));
+    return toFileInfoMap(change, PatchListKey.againstCommit(a, objectId, Whitespace.IGNORE_NONE));
   }
 
   Map<String, FileInfo> toFileInfoMap(Change change, RevId revision, int parent)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
index 040b6de..c167e31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Files.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.change;
 
 import com.google.common.collect.Lists;
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -22,10 +24,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.CacheControl;
 import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -38,7 +40,10 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -90,7 +95,7 @@
     return new FileResource(rev, id.get());
   }
 
-  public static final class ListFiles implements RestReadView<RevisionResource> {
+  public static final class ListFiles implements ETagView<RevisionResource> {
     private static final Logger log = LoggerFactory.getLogger(ListFiles.class);
 
     @Option(name = "--base", metaVar = "revision-id")
@@ -142,7 +147,8 @@
     @Override
     public Response<?> apply(RevisionResource resource)
         throws AuthException, BadRequestException, ResourceNotFoundException, OrmException,
-            RepositoryNotFoundException, IOException, PatchListNotAvailableException {
+            RepositoryNotFoundException, IOException, PatchListNotAvailableException,
+            PermissionBackendException {
       checkOptions();
       if (reviewed) {
         return Response.ok(reviewed(resource));
@@ -237,6 +243,8 @@
 
         try {
           return copy(res.files(), res.patchSetId(), resource, userId);
+        } catch (PatchListObjectTooLargeException e) {
+          log.warn("Cannot copy patch review flags: " + e.getMessage());
         } catch (IOException | PatchListNotAvailableException e) {
           log.warn("Cannot copy patch review flags", e);
         }
@@ -313,6 +321,11 @@
       }
     }
 
+    public ListFiles setQuery(String query) {
+      this.query = query;
+      return this;
+    }
+
     public ListFiles setBase(String base) {
       this.base = base;
       return this;
@@ -322,5 +335,15 @@
       this.parentNum = parentNum;
       return this;
     }
+
+    @Override
+    public String getETag(RevisionResource resource) {
+      Hasher h = Hashing.murmur3_128().newHasher();
+      resource.prepareETag(h, resource.getUser());
+      // File list comes from the PatchListCache, so any change to the key or value should
+      // invalidate ETag.
+      h.putLong(PatchListKey.serialVersionUID);
+      return h.hash().toString();
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java
new file mode 100644
index 0000000..08e2785
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/FixResource.java
@@ -0,0 +1,42 @@
+// 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.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.inject.TypeLiteral;
+import java.util.List;
+
+public class FixResource implements RestResource {
+  public static final TypeLiteral<RestView<FixResource>> FIX_KIND =
+      new TypeLiteral<RestView<FixResource>>() {};
+
+  private final List<FixReplacement> fixReplacements;
+  private final RevisionResource revisionResource;
+
+  public FixResource(RevisionResource revisionResource, List<FixReplacement> fixReplacements) {
+    this.fixReplacements = fixReplacements;
+    this.revisionResource = revisionResource;
+  }
+
+  public List<FixReplacement> getFixReplacements() {
+    return fixReplacements;
+  }
+
+  public RevisionResource getRevisionResource() {
+    return revisionResource;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.java
new file mode 100644
index 0000000..af9f60a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Fixes.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.server.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.FixSuggestion;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Objects;
+
+@Singleton
+public class Fixes implements ChildCollection<RevisionResource, FixResource> {
+
+  private final DynamicMap<RestView<FixResource>> views;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  Fixes(DynamicMap<RestView<FixResource>> views, CommentsUtil commentsUtil) {
+    this.views = views;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public RestView<RevisionResource> list() throws ResourceNotFoundException {
+    throw new ResourceNotFoundException();
+  }
+
+  @Override
+  public FixResource parse(RevisionResource revisionResource, IdString id)
+      throws ResourceNotFoundException, OrmException {
+    String fixId = id.get();
+    ChangeNotes changeNotes = revisionResource.getNotes();
+
+    List<RobotComment> robotComments =
+        commentsUtil.robotCommentsByPatchSet(changeNotes, revisionResource.getPatchSet().getId());
+    for (RobotComment robotComment : robotComments) {
+      for (FixSuggestion fixSuggestion : robotComment.fixSuggestions) {
+        if (Objects.equals(fixId, fixSuggestion.fixId)) {
+          return new FixResource(revisionResource, fixSuggestion.replacements);
+        }
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  @Override
+  public DynamicMap<RestView<FixResource>> views() {
+    return views;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
index 371127b..7269a60 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetArchive.java
@@ -58,7 +58,7 @@
       throw new MethodNotAllowedException("zip format is disabled");
     }
     boolean close = true;
-    final Repository repo = repoManager.openRepository(rsrc.getControl().getProject().getNameKey());
+    final Repository repo = repoManager.openRepository(rsrc.getProject());
     try {
       final RevCommit commit;
       String name;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java
index 1dcdbb8..4810ca0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetBlame.java
@@ -30,8 +30,8 @@
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.patch.AutoMerger;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gitiles.blame.BlameCache;
-import com.google.gitiles.blame.Region;
+import com.google.gitiles.blame.cache.BlameCache;
+import com.google.gitiles.blame.cache.Region;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
index a874699..694379e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetCommit.java
@@ -33,7 +33,6 @@
   private final GitRepositoryManager repoManager;
   private final ChangeJson.Factory json;
 
-  @Option(name = "--links", usage = "Include weblinks")
   private boolean addLinks;
 
   @Inject
@@ -42,6 +41,12 @@
     this.json = json;
   }
 
+  @Option(name = "--links", usage = "Include weblinks")
+  public GetCommit setAddLinks(boolean addLinks) {
+    this.addLinks = addLinks;
+    return this;
+  }
+
   @Override
   public Response<CommitInfo> apply(RevisionResource rsrc) throws IOException {
     Project.NameKey p = rsrc.getChange().getProject();
@@ -50,7 +55,7 @@
       String rev = rsrc.getPatchSet().getRevision().get();
       RevCommit commit = rw.parseCommit(ObjectId.fromString(rev));
       rw.parseBody(commit);
-      CommitInfo info = json.noOptions().toCommit(rsrc.getControl(), rw, commit, addLinks, true);
+      CommitInfo info = json.noOptions().toCommit(rsrc.getProject(), rw, commit, addLinks, true);
       Response<CommitInfo> r = Response.ok(info);
       if (rsrc.isCacheable()) {
         r.caching(CacheControl.PRIVATE(7, TimeUnit.DAYS));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
index abb9e66..f6b24b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetContent.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -27,39 +28,45 @@
 import com.google.gerrit.server.patch.ComparisonType;
 import com.google.gerrit.server.patch.Text;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
 
-@Singleton
 public class GetContent implements RestReadView<FileResource> {
   private final Provider<ReviewDb> db;
   private final GitRepositoryManager gitManager;
   private final PatchSetUtil psUtil;
   private final FileContentUtil fileContentUtil;
+  private final ProjectCache projectCache;
+
+  @Option(name = "--parent")
+  private Integer parent;
 
   @Inject
   GetContent(
       Provider<ReviewDb> db,
       GitRepositoryManager gitManager,
       PatchSetUtil psUtil,
-      FileContentUtil fileContentUtil) {
+      FileContentUtil fileContentUtil,
+      ProjectCache projectCache) {
     this.db = db;
     this.gitManager = gitManager;
     this.psUtil = psUtil;
     this.fileContentUtil = fileContentUtil;
+    this.projectCache = projectCache;
   }
 
   @Override
   public BinaryResult apply(FileResource rsrc)
-      throws ResourceNotFoundException, IOException, NoSuchChangeException, OrmException {
+      throws ResourceNotFoundException, IOException, BadRequestException, OrmException {
     String path = rsrc.getPatchKey().get();
     if (Patch.COMMIT_MSG.equals(path)) {
       String msg = getMessage(rsrc.getRevision().getChangeResource().getNotes());
@@ -73,9 +80,10 @@
           .base64();
     }
     return fileContentUtil.getContent(
-        rsrc.getRevision().getControl().getProjectControl().getProjectState(),
+        projectCache.checkedGet(rsrc.getRevision().getProject()),
         ObjectId.fromString(rsrc.getRevision().getPatchSet().getRevision().get()),
-        path);
+        path,
+        parent);
   }
 
   private String getMessage(ChangeNotes notes) throws OrmException, IOException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
index df583fd..25902b9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDiff.java
@@ -45,7 +45,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.git.LargeObjectException;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchScriptFactory;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
@@ -54,6 +56,7 @@
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.ReplaceEdit;
@@ -120,7 +123,7 @@
   @Override
   public Response<DiffInfo> apply(FileResource resource)
       throws ResourceConflictException, ResourceNotFoundException, OrmException, AuthException,
-          InvalidChangeOperationException, IOException {
+          InvalidChangeOperationException, IOException, PermissionBackendException {
     DiffPreferencesInfo prefs = new DiffPreferencesInfo();
     if (whitespace != null) {
       prefs.ignoreWhitespace = whitespace;
@@ -134,33 +137,18 @@
 
     PatchScriptFactory psf;
     PatchSet basePatchSet = null;
+    PatchSet.Id pId = resource.getPatchKey().getParentKey();
+    String fileName = resource.getPatchKey().getFileName();
+    ChangeNotes notes = resource.getRevision().getNotes();
     if (base != null) {
       RevisionResource baseResource =
           revisions.parse(resource.getRevision().getChangeResource(), IdString.fromDecoded(base));
       basePatchSet = baseResource.getPatchSet();
-      psf =
-          patchScriptFactoryFactory.create(
-              resource.getRevision().getControl(),
-              resource.getPatchKey().getFileName(),
-              basePatchSet.getId(),
-              resource.getPatchKey().getParentKey(),
-              prefs);
+      psf = patchScriptFactoryFactory.create(notes, fileName, basePatchSet.getId(), pId, prefs);
     } else if (parentNum > 0) {
-      psf =
-          patchScriptFactoryFactory.create(
-              resource.getRevision().getControl(),
-              resource.getPatchKey().getFileName(),
-              parentNum - 1,
-              resource.getPatchKey().getParentKey(),
-              prefs);
+      psf = patchScriptFactoryFactory.create(notes, fileName, parentNum - 1, pId, prefs);
     } else {
-      psf =
-          patchScriptFactoryFactory.create(
-              resource.getRevision().getControl(),
-              resource.getPatchKey().getFileName(),
-              null,
-              resource.getPatchKey().getParentKey(),
-              prefs);
+      psf = patchScriptFactoryFactory.create(notes, fileName, null, pId, prefs);
     }
 
     try {
@@ -168,6 +156,7 @@
       psf.setLoadComments(context != DiffPreferencesInfo.WHOLE_FILE_CONTEXT);
       PatchScript ps = psf.call();
       Content content = new Content(ps);
+      Set<Edit> editsDueToRebase = ps.getEditsDueToRebase();
       for (Edit edit : ps.getEdits()) {
         if (edit.getType() == Edit.Type.EMPTY) {
           continue;
@@ -190,7 +179,8 @@
           case REPLACE:
             List<Edit> internalEdit =
                 edit instanceof ReplaceEdit ? ((ReplaceEdit) edit).getInternalEdits() : null;
-            content.addDiff(edit.getEndA(), edit.getEndB(), internalEdit);
+            boolean dueToRebase = editsDueToRebase.contains(edit);
+            content.addDiff(edit.getEndA(), edit.getEndB(), internalEdit, dueToRebase);
             break;
           case EMPTY:
           default:
@@ -210,7 +200,7 @@
 
       List<DiffWebLinkInfo> links =
           webLinks.getDiffLinks(
-              state.getProject().getName(),
+              state.getName(),
               resource.getPatchKey().getParentKey().getParentKey().get(),
               basePatchSet != null ? basePatchSet.getId().get() : null,
               revA,
@@ -367,7 +357,7 @@
       }
     }
 
-    void addDiff(int endA, int endB, List<Edit> internalEdit) {
+    void addDiff(int endA, int endB, List<Edit> internalEdit, boolean dueToRebase) {
       int lenA = endA - nextA;
       int lenB = endB - nextB;
       checkState(lenA > 0 || lenB > 0);
@@ -403,6 +393,7 @@
           }
         }
       }
+      e.dueToRebase = dueToRebase ? true : null;
     }
 
     private ContentEntry entry() {
@@ -432,7 +423,7 @@
     }
 
     @Override
-    public final int parseArguments(final Parameters params) throws CmdLineException {
+    public final int parseArguments(Parameters params) throws CmdLineException {
       final String value = params.getParameter(0);
       short context;
       if ("all".equalsIgnoreCase(value)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java
index 4ea1c02..c285734 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetHashtags.java
@@ -19,7 +19,6 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Singleton;
 import java.io.IOException;
@@ -31,8 +30,7 @@
   @Override
   public Response<Set<String>> apply(ChangeResource req)
       throws AuthException, OrmException, IOException, BadRequestException {
-    ChangeControl control = req.getControl();
-    ChangeNotes notes = control.getNotes().load();
+    ChangeNotes notes = req.getNotes().load();
     Set<String> hashtags = notes.getHashtags();
     if (hashtags == null) {
       hashtags = Collections.emptySet();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java
index 9d40df4..88677d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetMergeList.java
@@ -80,7 +80,7 @@
       List<CommitInfo> result = new ArrayList<>(commits.size());
       ChangeJson changeJson = json.noOptions();
       for (RevCommit c : commits) {
-        result.add(changeJson.toCommit(rsrc.getControl(), rw, c, addLinks, true));
+        result.add(changeJson.toCommit(rsrc.getProject(), rw, c, addLinks, true));
       }
       return createResponse(rsrc, result);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java
index eaa3a28..76114ac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPastAssignees.java
@@ -40,7 +40,7 @@
   @Override
   public Response<List<AccountInfo>> apply(ChangeResource rsrc) throws OrmException {
 
-    Set<Account.Id> pastAssignees = rsrc.getControl().getNotes().load().getPastAssignees();
+    Set<Account.Id> pastAssignees = rsrc.getNotes().load().getPastAssignees();
     if (pastAssignees == null) {
       return Response.ok(Collections.emptyList());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
index 2275e06..b59c17c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPatch.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -62,8 +61,7 @@
   @Override
   public BinaryResult apply(RevisionResource rsrc)
       throws ResourceConflictException, IOException, ResourceNotFoundException {
-    Project.NameKey project = rsrc.getControl().getProject().getNameKey();
-    final Repository repo = repoManager.openRepository(project);
+    final Repository repo = repoManager.openRepository(rsrc.getProject());
     boolean close = true;
     try {
       final RevWalk rw = new RevWalk(repo);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPureRevert.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPureRevert.java
new file mode 100644
index 0000000..9270463
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetPureRevert.java
@@ -0,0 +1,153 @@
+// 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.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.common.PureRevertInfo;
+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.RestReadView;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffFormatter;
+import org.eclipse.jgit.errors.InvalidObjectIdException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.merge.ThreeWayMerger;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.kohsuke.args4j.Option;
+
+public class GetPureRevert implements RestReadView<ChangeResource> {
+  private final MergeUtil.Factory mergeUtilFactory;
+  private final GitRepositoryManager repoManager;
+  private final ProjectCache projectCache;
+  private final ChangeNotes.Factory notesFactory;
+  private final Provider<ReviewDb> dbProvider;
+  private final PatchSetUtil psUtil;
+
+  @Option(
+      name = "--claimed-original",
+      aliases = {"-o"},
+      usage = "SHA1 (40 digit hex) of the original commit")
+  @Nullable
+  private String claimedOriginal;
+
+  @Inject
+  GetPureRevert(
+      MergeUtil.Factory mergeUtilFactory,
+      GitRepositoryManager repoManager,
+      ProjectCache projectCache,
+      ChangeNotes.Factory notesFactory,
+      Provider<ReviewDb> dbProvider,
+      PatchSetUtil psUtil) {
+    this.mergeUtilFactory = mergeUtilFactory;
+    this.repoManager = repoManager;
+    this.projectCache = projectCache;
+    this.notesFactory = notesFactory;
+    this.dbProvider = dbProvider;
+    this.psUtil = psUtil;
+  }
+
+  @Override
+  public PureRevertInfo apply(ChangeResource rsrc)
+      throws ResourceConflictException, IOException, BadRequestException, OrmException,
+          AuthException {
+    PatchSet currentPatchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
+    if (currentPatchSet == null) {
+      throw new ResourceConflictException("current revision is missing");
+    }
+    return getPureRevert(rsrc.getNotes());
+  }
+
+  public PureRevertInfo getPureRevert(ChangeNotes notes)
+      throws OrmException, IOException, BadRequestException, ResourceConflictException {
+    PatchSet currentPatchSet = psUtil.current(dbProvider.get(), notes);
+    if (currentPatchSet == null) {
+      throw new ResourceConflictException("current revision is missing");
+    }
+
+    if (claimedOriginal == null) {
+      if (notes.getChange().getRevertOf() == null) {
+        throw new BadRequestException("no ID was provided and change isn't a revert");
+      }
+      PatchSet ps =
+          psUtil.current(
+              dbProvider.get(),
+              notesFactory.createChecked(
+                  dbProvider.get(), notes.getProjectName(), notes.getChange().getRevertOf()));
+      claimedOriginal = ps.getRevision().get();
+    }
+
+    try (Repository repo = repoManager.openRepository(notes.getProjectName());
+        ObjectInserter oi = repo.newObjectInserter();
+        RevWalk rw = new RevWalk(repo)) {
+      RevCommit claimedOriginalCommit;
+      try {
+        claimedOriginalCommit = rw.parseCommit(ObjectId.fromString(claimedOriginal));
+      } catch (InvalidObjectIdException | MissingObjectException e) {
+        throw new BadRequestException("invalid object ID");
+      }
+      if (claimedOriginalCommit.getParentCount() == 0) {
+        throw new BadRequestException("can't check against initial commit");
+      }
+      RevCommit claimedRevertCommit =
+          rw.parseCommit(ObjectId.fromString(currentPatchSet.getRevision().get()));
+      if (claimedRevertCommit.getParentCount() == 0) {
+        throw new BadRequestException("claimed revert has no parents");
+      }
+      // Rebase claimed revert onto claimed original
+      ThreeWayMerger merger =
+          mergeUtilFactory
+              .create(projectCache.checkedGet(notes.getProjectName()))
+              .newThreeWayMerger(oi, repo.getConfig());
+      merger.setBase(claimedRevertCommit.getParent(0));
+      boolean success = merger.merge(claimedRevertCommit, claimedOriginalCommit);
+      if (!success || merger.getResultTreeId() == null) {
+        // Merge conflict during rebase
+        return new PureRevertInfo(false);
+      }
+
+      // Any differences between claimed original's parent and the rebase result indicate that the
+      // claimedRevert is not a pure revert but made content changes
+      try (DiffFormatter df = new DiffFormatter(new ByteArrayOutputStream())) {
+        df.setRepository(repo);
+        List<DiffEntry> entries =
+            df.scan(claimedOriginalCommit.getParent(0), merger.getResultTreeId());
+        return new PureRevertInfo(entries.isEmpty());
+      }
+    }
+  }
+
+  public GetPureRevert setClaimedOriginal(String claimedOriginal) {
+    this.claimedOriginal = claimedOriginal;
+    return this;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
index 10c7a5a..44f65e1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRelated.java
@@ -21,11 +21,14 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommonConverters;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RelatedChangesSorter.PatchSetData;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -62,13 +65,15 @@
 
   @Override
   public RelatedInfo apply(RevisionResource rsrc)
-      throws RepositoryNotFoundException, IOException, OrmException {
+      throws RepositoryNotFoundException, IOException, OrmException, NoSuchProjectException,
+          PermissionBackendException {
     RelatedInfo relatedInfo = new RelatedInfo();
     relatedInfo.changes = getRelated(rsrc);
     return relatedInfo;
   }
 
-  private List<ChangeAndCommit> getRelated(RevisionResource rsrc) throws OrmException, IOException {
+  private List<ChangeAndCommit> getRelated(RevisionResource rsrc)
+      throws OrmException, IOException, PermissionBackendException {
     Set<String> groups = getAllGroups(rsrc.getNotes());
     if (groups.isEmpty()) {
       return Collections.emptyList();
@@ -92,7 +97,7 @@
 
     reloadChangeIfStale(cds, basePs);
 
-    for (PatchSetData d : sorter.sort(cds, basePs)) {
+    for (PatchSetData d : sorter.sort(cds, basePs, rsrc.getUser())) {
       PatchSet ps = d.patchSet();
       RevCommit commit;
       if (isEdit && ps.getId().equals(basePs.getId())) {
@@ -102,7 +107,7 @@
       } else {
         commit = d.commit();
       }
-      result.add(new ChangeAndCommit(d.data().change(), ps, commit));
+      result.add(new ChangeAndCommit(rsrc.getProject(), d.data().change(), ps, commit));
     }
 
     if (result.size() == 1) {
@@ -137,6 +142,7 @@
   }
 
   public static class ChangeAndCommit {
+    public String project;
     public String changeId;
     public CommitInfo commit;
     public Integer _changeNumber;
@@ -146,7 +152,10 @@
 
     public ChangeAndCommit() {}
 
-    ChangeAndCommit(@Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
+    ChangeAndCommit(
+        Project.NameKey project, @Nullable Change change, @Nullable PatchSet ps, RevCommit c) {
+      this.project = project.get();
+
       if (change != null) {
         changeId = change.getKey().get();
         _changeNumber = change.getChangeId();
@@ -171,6 +180,7 @@
     @Override
     public String toString() {
       return MoreObjects.toStringHelper(this)
+          .add("project", project)
           .add("changeId", changeId)
           .add("commit", toString(commit))
           .add("_changeNumber", _changeNumber)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
index aa0b339..db9af1d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetReviewer.java
@@ -16,6 +16,7 @@
 
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -31,7 +32,8 @@
   }
 
   @Override
-  public List<ReviewerInfo> apply(ReviewerResource rsrc) throws OrmException {
+  public List<ReviewerInfo> apply(ReviewerResource rsrc)
+      throws OrmException, PermissionBackendException {
     return json.format(rsrc);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
index 4972576..2a7bd4b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRevisionActions.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 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.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
@@ -62,20 +63,19 @@
   }
 
   @Override
-  @SuppressWarnings("deprecation") // Use Hashing.md5 for compatibility.
   public String getETag(RevisionResource rsrc) {
-    Hasher h = Hashing.md5().newHasher();
-    CurrentUser user = rsrc.getControl().getUser();
+    Hasher h = Hashing.murmur3_128().newHasher();
+    CurrentUser user = rsrc.getUser();
     try {
       rsrc.getChangeResource().prepareETag(h, user);
       h.putBoolean(Submit.wholeTopicEnabled(config));
       ReviewDb db = dbProvider.get();
       ChangeSet cs = mergeSuperSet.get().completeChangeSet(db, rsrc.getChange(), user);
       for (ChangeData cd : cs.changes()) {
-        changeResourceFactory.create(cd.changeControl()).prepareETag(h, user);
+        changeResourceFactory.create(cd.notes(), user).prepareETag(h, user);
       }
       h.putBoolean(cs.furtherHiddenChanges());
-    } catch (IOException | OrmException e) {
+    } catch (IOException | OrmException | PermissionBackendException e) {
       throw new OrmRuntimeException(e);
     }
     return h.hash().toString();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java
index e9b0af2..42b56b6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/HashtagsUtil.java
@@ -23,6 +23,18 @@
 import java.util.regex.Pattern;
 
 public class HashtagsUtil {
+  public static class InvalidHashtagException extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    static InvalidHashtagException hashtagsMayNotContainCommas() {
+      return new InvalidHashtagException("hashtags may not contain commas");
+    }
+
+    InvalidHashtagException(String message) {
+      super(message);
+    }
+  }
+
   private static final CharMatcher LEADER = CharMatcher.whitespace().or(CharMatcher.is('#'));
   private static final String PATTERN = "(?:\\s|\\A)#[\\p{L}[0-9]-_]+";
 
@@ -43,14 +55,14 @@
     return result;
   }
 
-  static Set<String> extractTags(Set<String> input) throws IllegalArgumentException {
+  static Set<String> extractTags(Set<String> input) throws InvalidHashtagException {
     if (input == null) {
       return Collections.emptySet();
     }
     HashSet<String> result = new HashSet<>();
     for (String hashtag : input) {
       if (hashtag.contains(",")) {
-        throw new IllegalArgumentException("Hashtags may not contain commas");
+        throw InvalidHashtagException.hashtagsMayNotContainCommas();
       }
       hashtag = cleanupHashtag(hashtag);
       if (!hashtag.isEmpty()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
new file mode 100644
index 0000000..46dabdf
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Ignore.java
@@ -0,0 +1,83 @@
+// 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.change;
+
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.StarredChangesUtil.MutuallyExclusiveLabelsException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Ignore
+    implements RestModifyView<ChangeResource, Ignore.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Ignore.class);
+
+  public static class Input {}
+
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Ignore(StarredChangesUtil stars) {
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Ignore")
+        .setTitle("Ignore the change")
+        .setVisible(canIgnore(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, OrmException, IllegalLabelException {
+    try {
+      if (rsrc.isUserOwner()) {
+        throw new BadRequestException("cannot ignore own change");
+      }
+
+      if (!isIgnored(rsrc)) {
+        stars.ignore(rsrc);
+      }
+      return Response.ok("");
+    } catch (MutuallyExclusiveLabelsException e) {
+      throw new ResourceConflictException(e.getMessage());
+    }
+  }
+
+  private boolean canIgnore(ChangeResource rsrc) {
+    return !rsrc.isUserOwner() && !isIgnored(rsrc);
+  }
+
+  private boolean isIgnored(ChangeResource rsrc) {
+    try {
+      return stars.isIgnored(rsrc);
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java
index 99b5245..658c91c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/IncludedInResolver.java
@@ -42,8 +42,7 @@
 
   private static final Logger log = LoggerFactory.getLogger(IncludedInResolver.class);
 
-  public static Result resolve(final Repository repo, final RevWalk rw, final RevCommit commit)
-      throws IOException {
+  public static Result resolve(Repository repo, RevWalk rw, RevCommit commit) throws IOException {
     RevFlag flag = newFlag(rw);
     try {
       return new IncludedInResolver(repo, rw, commit, flag).resolve();
@@ -52,9 +51,12 @@
     }
   }
 
-  public static boolean includedInOne(
-      final Repository repo, final RevWalk rw, final RevCommit commit, final Collection<Ref> refs)
+  public static boolean includedInAny(
+      final Repository repo, RevWalk rw, RevCommit commit, Collection<Ref> refs)
       throws IOException {
+    if (refs.isEmpty()) {
+      return false;
+    }
     RevFlag flag = newFlag(rw);
     try {
       return new IncludedInResolver(repo, rw, commit, flag).includedInOne(refs);
@@ -100,7 +102,7 @@
     return detail;
   }
 
-  private boolean includedInOne(final Collection<Ref> refs) throws IOException {
+  private boolean includedInOne(Collection<Ref> refs) throws IOException {
     parseCommits(refs);
     List<RevCommit> before = new ArrayList<>();
     List<RevCommit> after = new ArrayList<>();
@@ -112,7 +114,7 @@
   }
 
   /** Resolves which tip refs include the target commit. */
-  private Set<String> includedIn(final Collection<RevCommit> tips, int limit)
+  private Set<String> includedIn(Collection<RevCommit> tips, int limit)
       throws IOException, MissingObjectException, IncorrectObjectTypeException {
     Set<String> result = new HashSet<>();
     for (RevCommit tip : tips) {
@@ -149,7 +151,7 @@
    * @param before
    * @param after
    */
-  private void partition(final List<RevCommit> before, final List<RevCommit> after) {
+  private void partition(List<RevCommit> before, List<RevCommit> after) {
     int insertionPoint =
         Collections.binarySearch(
             tipsByCommitTime,
@@ -187,7 +189,7 @@
   }
 
   /** Parse commit of ref and store the relation between ref and commit. */
-  private void parseCommits(final Collection<Ref> refs) throws IOException {
+  private void parseCommits(Collection<Ref> refs) throws IOException {
     if (commitToRef != null) {
       return;
     }
@@ -219,7 +221,7 @@
     sortOlderFirst(tipsByCommitTime);
   }
 
-  private void sortOlderFirst(final List<RevCommit> tips) {
+  private void sortOlderFirst(List<RevCommit> tips) {
     Collections.sort(
         tips,
         new Comparator<RevCommit>() {
@@ -236,7 +238,7 @@
 
     public Result() {}
 
-    public void setBranches(final List<String> b) {
+    public void setBranches(List<String> b) {
       Collections.sort(b);
       branches = b;
     }
@@ -245,7 +247,7 @@
       return branches;
     }
 
-    public void setTags(final List<String> t) {
+    public void setTags(List<String> t) {
       Collections.sort(t);
       tags = t;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
index 9257445..7c4d158 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Index.java
@@ -16,11 +16,16 @@
 
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.change.Index.Input;
 import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.project.ChangeControl;
+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.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -28,25 +33,33 @@
 import java.io.IOException;
 
 @Singleton
-public class Index implements RestModifyView<ChangeResource, Input> {
+public class Index extends RetryingRestModifyView<ChangeResource, Input, Response<?>> {
   public static class Input {}
 
   private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final ChangeIndexer indexer;
 
   @Inject
-  Index(Provider<ReviewDb> db, ChangeIndexer indexer) {
+  Index(
+      Provider<ReviewDb> db,
+      RetryHelper retryHelper,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      ChangeIndexer indexer) {
+    super(retryHelper);
     this.db = db;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.indexer = indexer;
   }
 
   @Override
-  public Response<?> apply(ChangeResource rsrc, Input input)
-      throws IOException, AuthException, OrmException {
-    ChangeControl ctl = rsrc.getControl();
-    if (!ctl.isOwner() && !ctl.getUser().getCapabilities().canMaintainServer()) {
-      throw new AuthException("Only change owner or server maintainer can reindex");
-    }
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws IOException, AuthException, OrmException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.MAINTAIN_SERVER);
     indexer.index(db.get(), rsrc.getChange());
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
index 942c3b4..0048657 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
@@ -49,7 +49,7 @@
   @Override
   public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
       throws AuthException, OrmException {
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getControl());
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
     return commentJson
         .get()
         .setFillAccounts(true)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
index 2bf7aa0..02713de 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
@@ -50,13 +50,12 @@
   @Override
   public Map<String, List<CommentInfo>> apply(ChangeResource rsrc)
       throws AuthException, OrmException {
-    if (!rsrc.getControl().getUser().isIdentifiedUser()) {
+    if (!rsrc.getUser().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getControl());
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
     List<Comment> drafts =
-        commentsUtil.draftByChangeAuthor(
-            db.get(), cd.notes(), rsrc.getControl().getUser().getAccountId());
+        commentsUtil.draftByChangeAuthor(db.get(), cd.notes(), rsrc.getUser().getAccountId());
     return commentJson
         .get()
         .setFillAccounts(false)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeRobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeRobotComments.java
index 881c6f53..fff7f82 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeRobotComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeRobotComments.java
@@ -47,7 +47,7 @@
   @Override
   public Map<String, List<RobotCommentInfo>> apply(ChangeResource rsrc)
       throws AuthException, OrmException {
-    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getControl());
+    ChangeData cd = changeDataFactory.create(db.get(), rsrc.getNotes());
     return commentJson
         .get()
         .setFillAccounts(true)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
index 27ec89d..385e3b9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListReviewers.java
@@ -19,6 +19,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -28,7 +30,7 @@
 import java.util.Map;
 
 @Singleton
-class ListReviewers implements RestReadView<ChangeResource> {
+public class ListReviewers implements RestReadView<ChangeResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ApprovalsUtil approvalsUtil;
   private final ReviewerJson json;
@@ -47,12 +49,18 @@
   }
 
   @Override
-  public List<ReviewerInfo> apply(ChangeResource rsrc) throws OrmException {
-    Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
+  public List<ReviewerInfo> apply(ChangeResource rsrc)
+      throws OrmException, PermissionBackendException {
+    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
     for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
-      if (!reviewers.containsKey(accountId)) {
-        reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
+      if (!reviewers.containsKey(accountId.toString())) {
+        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
+      }
+    }
+    for (Address adr : rsrc.getNotes().getReviewersByEmail().all()) {
+      if (!reviewers.containsKey(adr.toString())) {
+        reviewers.put(adr.toString(), new ReviewerResource(rsrc, adr));
       }
     }
     return json.format(reviewers.values());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
index d0c8ca0..6d9dc79 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionReviewers.java
@@ -20,6 +20,8 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -49,16 +51,21 @@
 
   @Override
   public List<ReviewerInfo> apply(RevisionResource rsrc)
-      throws OrmException, MethodNotAllowedException {
+      throws OrmException, MethodNotAllowedException, PermissionBackendException {
     if (!rsrc.isCurrent()) {
       throw new MethodNotAllowedException("Cannot list reviewers on non-current patch set");
     }
 
-    Map<Account.Id, ReviewerResource> reviewers = new LinkedHashMap<>();
+    Map<String, ReviewerResource> reviewers = new LinkedHashMap<>();
     ReviewDb db = dbProvider.get();
     for (Account.Id accountId : approvalsUtil.getReviewers(db, rsrc.getNotes()).all()) {
-      if (!reviewers.containsKey(accountId)) {
-        reviewers.put(accountId, resourceFactory.create(rsrc, accountId));
+      if (!reviewers.containsKey(accountId.toString())) {
+        reviewers.put(accountId.toString(), resourceFactory.create(rsrc, accountId));
+      }
+    }
+    for (Address address : rsrc.getNotes().getReviewersByEmail().all()) {
+      if (!reviewers.containsKey(address.toString())) {
+        reviewers.put(address.toString(), new ReviewerResource(rsrc, address));
       }
     }
     return json.format(reviewers.values());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsReviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsReviewed.java
new file mode 100644
index 0000000..265b2b0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsReviewed.java
@@ -0,0 +1,78 @@
+// 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.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class MarkAsReviewed
+    implements RestModifyView<ChangeResource, MarkAsReviewed.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(MarkAsReviewed.class);
+
+  public static class Input {}
+
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  MarkAsReviewed(
+      Provider<ReviewDb> dbProvider,
+      ChangeData.Factory changeDataFactory,
+      StarredChangesUtil stars) {
+    this.dbProvider = dbProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Mark Reviewed")
+        .setTitle("Mark the change as reviewed to unhighlight it in the dashboard")
+        .setVisible(!isReviewed(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws RestApiException, OrmException, IllegalLabelException {
+    stars.markAsReviewed(rsrc);
+    return Response.ok("");
+  }
+
+  private boolean isReviewed(ChangeResource rsrc) {
+    try {
+      return changeDataFactory
+          .create(dbProvider.get(), rsrc.getNotes())
+          .isReviewedBy(rsrc.getUser().asIdentifiedUser().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check if change is reviewed", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsUnreviewed.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsUnreviewed.java
new file mode 100644
index 0000000..6de84ee
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MarkAsUnreviewed.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.server.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class MarkAsUnreviewed
+    implements RestModifyView<ChangeResource, MarkAsUnreviewed.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(MarkAsUnreviewed.class);
+
+  public static class Input {}
+
+  private final Provider<ReviewDb> dbProvider;
+  private final ChangeData.Factory changeDataFactory;
+  private final StarredChangesUtil stars;
+
+  @Inject
+  MarkAsUnreviewed(
+      Provider<ReviewDb> dbProvider,
+      ChangeData.Factory changeDataFactory,
+      StarredChangesUtil stars) {
+    this.dbProvider = dbProvider;
+    this.changeDataFactory = changeDataFactory;
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Mark Unreviewed")
+        .setTitle("Mark the change as unreviewed to highlight it in the dashboard")
+        .setVisible(isReviewed(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws OrmException, IllegalLabelException {
+    stars.markAsUnreviewed(rsrc);
+    return Response.ok("");
+  }
+
+  private boolean isReviewed(ChangeResource rsrc) {
+    try {
+      return changeDataFactory
+          .create(dbProvider.get(), rsrc.getNotes())
+          .isReviewedBy(rsrc.getUser().asIdentifiedUser().getAccountId());
+    } catch (OrmException e) {
+      log.error("failed to check if change is reviewed", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
index 70d9b96..a8cd31a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/MergeabilityCacheImpl.java
@@ -31,9 +31,7 @@
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
-import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.strategy.SubmitDryRun;
-import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -45,7 +43,6 @@
 import java.util.Arrays;
 import java.util.Objects;
 import java.util.Set;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -173,31 +170,6 @@
     }
   }
 
-  private class Loader implements Callable<Boolean> {
-    private final EntryKey key;
-    private final Branch.NameKey dest;
-    private final Repository repo;
-
-    Loader(EntryKey key, Branch.NameKey dest, Repository repo) {
-      this.key = key;
-      this.dest = dest;
-      this.repo = repo;
-    }
-
-    @Override
-    public Boolean call() throws NoSuchProjectException, IntegrationException, IOException {
-      if (key.into.equals(ObjectId.zeroId())) {
-        return true; // Assume yes on new branch.
-      }
-      try (CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-        Set<RevCommit> accepted = SubmitDryRun.getAlreadyAccepted(repo, rw);
-        accepted.add(rw.parseCommit(key.into));
-        accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
-        return submitDryRun.run(key.submitType, repo, rw, dest, key.into, key.commit, accepted);
-      }
-    }
-  }
-
   public static class MergeabilityWeigher implements Weigher<EntryKey, Boolean> {
     @Override
     public int weigh(EntryKey k, Boolean v) {
@@ -229,7 +201,20 @@
     ObjectId into = intoRef != null ? intoRef.getObjectId() : ObjectId.zeroId();
     EntryKey key = new EntryKey(commit, into, submitType, mergeStrategy);
     try {
-      return cache.get(key, new Loader(key, dest, repo));
+      return cache.get(
+          key,
+          () -> {
+            if (key.into.equals(ObjectId.zeroId())) {
+              return true; // Assume yes on new branch.
+            }
+            try (CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
+              Set<RevCommit> accepted = SubmitDryRun.getAlreadyAccepted(repo, rw);
+              accepted.add(rw.parseCommit(key.into));
+              accepted.addAll(Arrays.asList(rw.parseCommit(key.commit).getParents()));
+              return submitDryRun.run(
+                  key.submitType, repo, rw, dest, key.into, key.commit, accepted);
+            }
+          });
     } catch (ExecutionException | UncheckedExecutionException e) {
       log.error(
           "Error checking mergeability of {} into {} ({})",
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
index f585e14..a8a297e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Mergeable.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.BranchOrderSection;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
@@ -65,6 +67,7 @@
   private final Provider<ReviewDb> db;
   private final ChangeIndexer indexer;
   private final MergeabilityCache cache;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   @Inject
   Mergeable(
@@ -74,7 +77,8 @@
       ChangeData.Factory changeDataFactory,
       Provider<ReviewDb> db,
       ChangeIndexer indexer,
-      MergeabilityCache cache) {
+      MergeabilityCache cache,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
     this.gitManager = gitManager;
     this.projectCache = projectCache;
     this.mergeUtilFactory = mergeUtilFactory;
@@ -82,6 +86,7 @@
     this.db = db;
     this.indexer = indexer;
     this.cache = cache;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
   }
 
   public void setOtherBranches(boolean otherBranches) {
@@ -97,14 +102,14 @@
     MergeableInfo result = new MergeableInfo();
 
     if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + Submit.status(change));
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (!ps.getId().equals(change.currentPatchSetId())) {
       // Only the current revision is mergeable. Others always fail.
       return result;
     }
 
-    ChangeData cd = changeDataFactory.create(db.get(), resource.getControl());
-    result.submitType = getSubmitType(cd, ps);
+    ChangeData cd = changeDataFactory.create(db.get(), resource.getNotes());
+    result.submitType = getSubmitType(resource.getUser(), cd, ps);
 
     try (Repository git = gitManager.openRepository(change.getProject())) {
       ObjectId commit = toId(ps);
@@ -136,8 +141,10 @@
     return result;
   }
 
-  private SubmitType getSubmitType(ChangeData cd, PatchSet patchSet) throws OrmException {
-    SubmitTypeRecord rec = new SubmitRuleEvaluator(cd).setPatchSet(patchSet).getSubmitType();
+  private SubmitType getSubmitType(CurrentUser user, ChangeData cd, PatchSet patchSet)
+      throws OrmException {
+    SubmitTypeRecord rec =
+        submitRuleEvaluatorFactory.create(user, cd).setPatchSet(patchSet).getSubmitType();
     if (rec.status != SubmitTypeRecord.Status.OK) {
       throw new OrmException("Submit type rule failed: " + rec);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index aca6ef1..b4f71af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.change.CommentResource.COMMENT_KIND;
 import static com.google.gerrit.server.change.DraftCommentResource.DRAFT_COMMENT_KIND;
 import static com.google.gerrit.server.change.FileResource.FILE_KIND;
+import static com.google.gerrit.server.change.FixResource.FIX_KIND;
 import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
 import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
@@ -40,12 +41,14 @@
     bind(DraftComments.class);
     bind(Comments.class);
     bind(RobotComments.class);
+    bind(Fixes.class);
     bind(Files.class);
     bind(Votes.class);
 
     DynamicMap.mapOf(binder(), CHANGE_KIND);
     DynamicMap.mapOf(binder(), COMMENT_KIND);
     DynamicMap.mapOf(binder(), ROBOT_COMMENT_KIND);
+    DynamicMap.mapOf(binder(), FIX_KIND);
     DynamicMap.mapOf(binder(), DRAFT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), FILE_KIND);
     DynamicMap.mapOf(binder(), REVIEWER_KIND);
@@ -67,13 +70,13 @@
     get(CHANGE_KIND, "robotcomments").to(ListChangeRobotComments.class);
     get(CHANGE_KIND, "drafts").to(ListChangeDrafts.class);
     get(CHANGE_KIND, "check").to(Check.class);
+    get(CHANGE_KIND, "pure_revert").to(GetPureRevert.class);
     post(CHANGE_KIND, "check").to(Check.class);
     put(CHANGE_KIND, "topic").to(PutTopic.class);
     delete(CHANGE_KIND, "topic").to(PutTopic.class);
     delete(CHANGE_KIND).to(DeleteChange.class);
     post(CHANGE_KIND, "abandon").to(Abandon.class);
     post(CHANGE_KIND, "hashtags").to(PostHashtags.class);
-    post(CHANGE_KIND, "publish").to(PublishDraftPatchSet.CurrentRevision.class);
     post(CHANGE_KIND, "restore").to(Restore.class);
     post(CHANGE_KIND, "revert").to(Revert.class);
     post(CHANGE_KIND, "submit").to(Submit.CurrentRevision.class);
@@ -82,6 +85,16 @@
     post(CHANGE_KIND, "index").to(Index.class);
     post(CHANGE_KIND, "rebuild.notedb").to(Rebuild.class);
     post(CHANGE_KIND, "move").to(Move.class);
+    post(CHANGE_KIND, "private").to(PostPrivate.class);
+    post(CHANGE_KIND, "private.delete").to(DeletePrivateByPost.class);
+    delete(CHANGE_KIND, "private").to(DeletePrivate.class);
+    put(CHANGE_KIND, "ignore").to(Ignore.class);
+    put(CHANGE_KIND, "unignore").to(Unignore.class);
+    put(CHANGE_KIND, "reviewed").to(MarkAsReviewed.class);
+    put(CHANGE_KIND, "unreviewed").to(MarkAsUnreviewed.class);
+    post(CHANGE_KIND, "wip").to(SetWorkInProgress.class);
+    post(CHANGE_KIND, "ready").to(SetReadyForReview.class);
+    put(CHANGE_KIND, "message").to(PutMessage.class);
 
     post(CHANGE_KIND, "reviewers").to(PostReviewers.class);
     get(CHANGE_KIND, "suggest_reviewers").to(SuggestChangeReviewers.class);
@@ -97,9 +110,7 @@
     get(REVISION_KIND, "actions").to(GetRevisionActions.class);
     post(REVISION_KIND, "cherrypick").to(CherryPick.class);
     get(REVISION_KIND, "commit").to(GetCommit.class);
-    delete(REVISION_KIND).to(DeleteDraftPatchSet.class);
     get(REVISION_KIND, "mergeable").to(Mergeable.class);
-    post(REVISION_KIND, "publish").to(PublishDraftPatchSet.class);
     get(REVISION_KIND, "related").to(GetRelated.class);
     get(REVISION_KIND, "review").to(GetReview.class);
     post(REVISION_KIND, "review").to(PostReview.class);
@@ -125,9 +136,13 @@
 
     child(REVISION_KIND, "comments").to(Comments.class);
     get(COMMENT_KIND).to(GetComment.class);
+    delete(COMMENT_KIND).to(DeleteComment.class);
+    post(COMMENT_KIND, "delete").to(DeleteComment.class);
 
     child(REVISION_KIND, "robotcomments").to(RobotComments.class);
     get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
+    child(REVISION_KIND, "fixes").to(Fixes.class);
+    post(FIX_KIND, "apply").to(ApplyFix.class);
 
     child(REVISION_KIND, "files").to(Files.class);
     put(FILE_KIND, "reviewed").to(PutReviewed.class);
@@ -152,12 +167,17 @@
     factory(ChangeEdits.Create.Factory.class);
     factory(ChangeEdits.DeleteFile.Factory.class);
     factory(ChangeInserter.Factory.class);
+    factory(ChangeResource.Factory.class);
+    factory(DeleteReviewerByEmailOp.Factory.class);
+    factory(DeleteReviewerOp.Factory.class);
     factory(EmailReviewComments.Factory.class);
     factory(PatchSetInserter.Factory.class);
+    factory(PostReviewersOp.Factory.class);
     factory(RebaseChangeOp.Factory.class);
     factory(ReviewerResource.Factory.class);
     factory(SetAssigneeOp.Factory.class);
     factory(SetHashtagsOp.Factory.class);
-    factory(ChangeResource.Factory.class);
+    factory(SetPrivateOp.Factory.class);
+    factory(WorkInProgressOp.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
index c3445d0c..9690af2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
@@ -14,91 +14,138 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.permissions.ChangePermission.ABANDON;
+import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
 import static com.google.gerrit.server.query.change.ChangeData.asChanges;
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.extensions.api.changes.MoveInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 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.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 @Singleton
-public class Move implements RestModifyView<ChangeResource, MoveInput> {
+public class Move extends RetryingRestModifyView<ChangeResource, MoveInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
+  private final PermissionBackend permissionBackend;
   private final Provider<ReviewDb> dbProvider;
   private final ChangeJson.Factory json;
   private final GitRepositoryManager repoManager;
   private final Provider<InternalChangeQuery> queryProvider;
   private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final PatchSetUtil psUtil;
+  private final ApprovalsUtil approvalsUtil;
+  private final ProjectCache projectCache;
+  private final Provider<CurrentUser> userProvider;
 
   @Inject
   Move(
+      PermissionBackend permissionBackend,
       Provider<ReviewDb> dbProvider,
       ChangeJson.Factory json,
       GitRepositoryManager repoManager,
       Provider<InternalChangeQuery> queryProvider,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory,
-      PatchSetUtil psUtil) {
+      RetryHelper retryHelper,
+      PatchSetUtil psUtil,
+      ApprovalsUtil approvalsUtil,
+      ProjectCache projectCache,
+      Provider<CurrentUser> userProvider) {
+    super(retryHelper);
+    this.permissionBackend = permissionBackend;
     this.dbProvider = dbProvider;
     this.json = json;
     this.repoManager = repoManager;
     this.queryProvider = queryProvider;
     this.cmUtil = cmUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.psUtil = psUtil;
+    this.approvalsUtil = approvalsUtil;
+    this.projectCache = projectCache;
+    this.userProvider = userProvider;
   }
 
   @Override
-  public ChangeInfo apply(ChangeResource req, MoveInput input)
-      throws RestApiException, OrmException, UpdateException {
-    ChangeControl control = req.getControl();
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, MoveInput input)
+      throws RestApiException, OrmException, UpdateException, PermissionBackendException {
+    Change change = rsrc.getChange();
+    Project.NameKey project = rsrc.getProject();
+    IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
+    if (input.destinationBranch == null) {
+      throw new BadRequestException("destination branch is required");
+    }
     input.destinationBranch = RefNames.fullName(input.destinationBranch);
-    if (!control.canMoveTo(input.destinationBranch, dbProvider.get())) {
-      throw new AuthException("Move not permitted");
+
+    if (change.getStatus().isClosed()) {
+      throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
+    }
+
+    Branch.NameKey newDest = new Branch.NameKey(project, input.destinationBranch);
+    if (change.getDest().equals(newDest)) {
+      throw new ResourceConflictException("Change is already destined for the specified branch");
+    }
+
+    // Move requires abandoning this change, and creating a new change.
+    try {
+      rsrc.permissions().database(dbProvider).check(ABANDON);
+      permissionBackend.user(caller).database(dbProvider).ref(newDest).check(CREATE_CHANGE);
+    } catch (AuthException denied) {
+      throw new AuthException("move not permitted", denied);
     }
 
     Op op = new Op(input);
     try (BatchUpdate u =
-        batchUpdateFactory.create(
-            dbProvider.get(), req.getChange().getProject(), control.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getChange().getId(), op);
+        updateFactory.create(dbProvider.get(), project, caller, TimeUtil.nowTs())) {
+      u.addOp(change.getId(), op);
       u.execute();
     }
-
     return json.noOptions().format(op.getChange());
   }
 
@@ -119,10 +166,10 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, ResourceConflictException, RepositoryNotFoundException, IOException {
+        throws OrmException, ResourceConflictException, IOException {
       change = ctx.getChange();
-      if (change.getStatus() != Status.NEW && change.getStatus() != Status.DRAFT) {
-        throw new ResourceConflictException("Change is " + status(change));
+      if (change.getStatus() != Status.NEW) {
+        throw new ResourceConflictException("Change is " + ChangeUtil.status(change));
       }
 
       Project.NameKey projectKey = change.getProject();
@@ -169,10 +216,13 @@
         throw new ResourceConflictException("Patch set is not current");
       }
 
-      ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+      PatchSet.Id psId = change.currentPatchSetId();
+      ChangeUpdate update = ctx.getUpdate(psId);
       update.setBranch(newDestKey.get());
       change.setDest(newDestKey);
 
+      updateApprovals(ctx, update, psId, projectKey);
+
       StringBuilder msgBuf = new StringBuilder();
       msgBuf.append("Change destination moved from ");
       msgBuf.append(changePrevDest.getShortName());
@@ -188,9 +238,62 @@
 
       return true;
     }
+
+    /**
+     * We have a long discussion about how to deal with its votes after moving a change from one
+     * branch to another. In the end, we think only keeping the veto votes is the best way since
+     * it's simple for us and less confusing for our users. See the discussion in the following
+     * proposal: https://gerrit-review.googlesource.com/c/gerrit/+/129171
+     */
+    private void updateApprovals(
+        ChangeContext ctx, ChangeUpdate update, PatchSet.Id psId, Project.NameKey project)
+        throws IOException, OrmException {
+      List<PatchSetApproval> approvals = new ArrayList<>();
+      for (PatchSetApproval psa :
+          approvalsUtil.byPatchSet(
+              ctx.getDb(),
+              ctx.getNotes(),
+              userProvider.get(),
+              psId,
+              ctx.getRevWalk(),
+              ctx.getRepoView().getConfig())) {
+        ProjectState projectState = projectCache.checkedGet(project);
+        LabelType type =
+            projectState.getLabelTypes(ctx.getNotes(), ctx.getUser()).byLabel(psa.getLabelId());
+        // Only keep veto votes, defined as votes where:
+        // 1- the label function allows minimum values to block submission.
+        // 2- the vote holds the minimum value.
+        if (type == null || (type.isMaxNegative(psa) && type.getFunction().isBlock())) {
+          continue;
+        }
+
+        // Remove votes from NoteDb.
+        update.removeApprovalFor(psa.getAccountId(), psa.getLabel());
+        approvals.add(
+            new PatchSetApproval(
+                new PatchSetApproval.Key(psId, psa.getAccountId(), new LabelId(psa.getLabel())),
+                (short) 0,
+                ctx.getWhen()));
+      }
+      // Remove votes from ReviewDb.
+      ctx.getDb().patchSetApprovals().upsert(approvals);
+    }
   }
 
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
+  @Override
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    return new UiAction.Description()
+        .setLabel("Move Change")
+        .setTitle("Move change to a different branch")
+        .setVisible(
+            and(
+                change.getStatus().isOpen(),
+                and(
+                    permissionBackend
+                        .user(rsrc.getUser())
+                        .ref(change.getDest())
+                        .testCond(CREATE_CHANGE),
+                    rsrc.permissions().database(dbProvider).testCond(ABANDON))));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/NotifyUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/NotifyUtil.java
index 8516615..c29faee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/NotifyUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/NotifyUtil.java
@@ -25,25 +25,23 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class NotifyUtil {
-  private final Provider<ReviewDb> dbProvider;
   private final AccountResolver accountResolver;
 
   @Inject
-  NotifyUtil(Provider<ReviewDb> dbProvider, AccountResolver accountResolver) {
-    this.dbProvider = dbProvider;
+  NotifyUtil(AccountResolver accountResolver) {
     this.accountResolver = accountResolver;
   }
 
@@ -76,7 +74,7 @@
 
   public ListMultimap<RecipientType, Account.Id> resolveAccounts(
       @Nullable Map<RecipientType, NotifyInfo> notifyDetails)
-      throws OrmException, BadRequestException {
+      throws OrmException, BadRequestException, IOException, ConfigInvalidException {
     if (isNullOrEmpty(notifyDetails)) {
       return ImmutableListMultimap.of();
     }
@@ -88,19 +86,19 @@
         if (m == null) {
           m = MultimapBuilder.hashKeys().arrayListValues().build();
         }
-        m.putAll(e.getKey(), find(dbProvider.get(), accounts));
+        m.putAll(e.getKey(), find(accounts));
       }
     }
 
     return m != null ? m : ImmutableListMultimap.of();
   }
 
-  private List<Account.Id> find(ReviewDb db, List<String> nameOrEmails)
-      throws OrmException, BadRequestException {
+  private List<Account.Id> find(List<String> nameOrEmails)
+      throws OrmException, BadRequestException, IOException, ConfigInvalidException {
     List<String> missing = new ArrayList<>(nameOrEmails.size());
     List<Account.Id> r = new ArrayList<>(nameOrEmails.size());
     for (String nameOrEmail : nameOrEmails) {
-      Account a = accountResolver.find(db, nameOrEmail);
+      Account a = accountResolver.find(nameOrEmail);
       if (a != null) {
         r.add(a.getId());
       } else {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index 7cf62a0..d298730 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 
+import com.google.common.base.Preconditions;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -34,6 +35,7 @@
 import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -41,22 +43,25 @@
 import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.project.ChangeControl;
+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.ProjectCache;
 import com.google.gerrit.server.ssh.NoSshInfo;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
 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 java.util.Collections;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -65,13 +70,15 @@
   private static final Logger log = LoggerFactory.getLogger(PatchSetInserter.class);
 
   public interface Factory {
-    PatchSetInserter create(ChangeControl ctl, PatchSet.Id psId, RevCommit commit);
+    PatchSetInserter create(ChangeNotes notes, PatchSet.Id psId, ObjectId commitId);
   }
 
   // Injected fields.
+  private final PermissionBackend permissionBackend;
   private final PatchSetInfoFactory patchSetInfoFactory;
   private final CommitValidators.Factory commitValidatorsFactory;
   private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final ProjectCache projectCache;
   private final RevisionCreated revisionCreated;
   private final ApprovalsUtil approvalsUtil;
   private final ApprovalCopier approvalCopier;
@@ -80,18 +87,17 @@
 
   // Assisted-injected fields.
   private final PatchSet.Id psId;
-  private final RevCommit commit;
+  private final ObjectId commitId;
   // Read prior to running the batch update, so must only be used during
-  // updateRepo; updateChange and later must use the control from the
+  // updateRepo; updateChange and later must use the notes from the
   // ChangeContext.
-  private final ChangeControl origCtl;
+  private final ChangeNotes origNotes;
 
   // Fields exposed as setters.
   private String message;
   private String description;
-  private CommitValidators.Policy validatePolicy = CommitValidators.Policy.GERRIT;
+  private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
-  private boolean draft;
   private List<String> groups = Collections.emptyList();
   private boolean fireRevisionCreated = true;
   private NotifyHandling notify = NotifyHandling.ALL;
@@ -106,8 +112,9 @@
   private ChangeMessage changeMessage;
   private ReviewerSet oldReviewers;
 
-  @AssistedInject
+  @Inject
   public PatchSetInserter(
+      PermissionBackend permissionBackend,
       ApprovalsUtil approvalsUtil,
       ApprovalCopier approvalCopier,
       ChangeMessagesUtil cmUtil,
@@ -116,9 +123,11 @@
       ReplacePatchSetSender.Factory replacePatchSetFactory,
       PatchSetUtil psUtil,
       RevisionCreated revisionCreated,
-      @Assisted ChangeControl ctl,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
       @Assisted PatchSet.Id psId,
-      @Assisted RevCommit commit) {
+      @Assisted ObjectId commitId) {
+    this.permissionBackend = permissionBackend;
     this.approvalsUtil = approvalsUtil;
     this.approvalCopier = approvalCopier;
     this.cmUtil = cmUtil;
@@ -127,10 +136,11 @@
     this.replacePatchSetFactory = replacePatchSetFactory;
     this.psUtil = psUtil;
     this.revisionCreated = revisionCreated;
+    this.projectCache = projectCache;
 
-    this.origCtl = ctl;
+    this.origNotes = notes;
     this.psId = psId;
-    this.commit = commit;
+    this.commitId = commitId.copy();
   }
 
   public PatchSet.Id getPatchSetId() {
@@ -147,8 +157,8 @@
     return this;
   }
 
-  public PatchSetInserter setValidatePolicy(CommitValidators.Policy validate) {
-    this.validatePolicy = checkNotNull(validate);
+  public PatchSetInserter setValidate(boolean validate) {
+    this.validate = validate;
     return this;
   }
 
@@ -157,11 +167,6 @@
     return this;
   }
 
-  public PatchSetInserter setDraft(boolean draft) {
-    this.draft = draft;
-    return this;
-  }
-
   public PatchSetInserter setGroups(List<String> groups) {
     checkNotNull(groups, "groups may not be null");
     this.groups = groups;
@@ -174,7 +179,7 @@
   }
 
   public PatchSetInserter setNotify(NotifyHandling notify) {
-    this.notify = notify;
+    this.notify = Preconditions.checkNotNull(notify);
     return this;
   }
 
@@ -206,18 +211,16 @@
 
   @Override
   public void updateRepo(RepoContext ctx)
-      throws AuthException, ResourceConflictException, IOException, OrmException {
+      throws AuthException, ResourceConflictException, IOException, OrmException,
+          PermissionBackendException {
     validate(ctx);
-    ctx.addRefUpdate(
-        new ReceiveCommand(
-            ObjectId.zeroId(), commit, getPatchSetId().toRefName(), ReceiveCommand.Type.CREATE));
+    ctx.addRefUpdate(ObjectId.zeroId(), commitId, getPatchSetId().toRefName());
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx)
       throws ResourceConflictException, OrmException, IOException {
     ReviewDb db = ctx.getDb();
-    ChangeControl ctl = ctx.getControl();
 
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(psId);
@@ -227,7 +230,7 @@
       throw new ResourceConflictException(
           String.format(
               "Cannot create new patch set of change %s because it is %s",
-              change.getId(), change.getStatus().name().toLowerCase()));
+              change.getId(), ChangeUtil.status(change)));
     }
 
     List<String> newGroups = groups;
@@ -243,14 +246,13 @@
             ctx.getRevWalk(),
             ctx.getUpdate(psId),
             psId,
-            commit,
-            draft,
+            commitId,
             newGroups,
             null,
             description);
 
     if (notify != NotifyHandling.NONE) {
-      oldReviewers = approvalsUtil.getReviewers(db, ctl.getNotes());
+      oldReviewers = approvalsUtil.getReviewers(db, ctx.getNotes());
     }
 
     if (message != null) {
@@ -260,17 +262,24 @@
               ctx.getUser(),
               ctx.getWhen(),
               message,
-              ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
+              ChangeMessagesUtil.uploadedPatchSetTag(change.isWorkInProgress()));
       changeMessage.setMessage(message);
     }
 
-    patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
-    if (change.getStatus() != Change.Status.DRAFT && !allowClosed) {
+    patchSetInfo =
+        patchSetInfoFactory.get(ctx.getRevWalk(), ctx.getRevWalk().parseCommit(commitId), psId);
+    if (!allowClosed) {
       change.setStatus(Change.Status.NEW);
     }
     change.setCurrentPatchSet(patchSetInfo);
     if (copyApprovals) {
-      approvalCopier.copy(db, ctl, patchSet);
+      approvalCopier.copyInReviewDb(
+          db,
+          ctx.getNotes(),
+          ctx.getUser(),
+          patchSet,
+          ctx.getRevWalk(),
+          ctx.getRepoView().getConfig());
     }
     if (changeMessage != null) {
       cmUtil.addChangeMessage(db, update, changeMessage);
@@ -302,29 +311,40 @@
   }
 
   private void validate(RepoContext ctx)
-      throws AuthException, ResourceConflictException, IOException, OrmException {
-    if (checkAddPatchSetPermission && !origCtl.canAddPatchSet(ctx.getDb())) {
-      throw new AuthException("cannot add patch set");
+      throws AuthException, ResourceConflictException, IOException, PermissionBackendException {
+    if (checkAddPatchSetPermission) {
+      permissionBackend
+          .user(ctx.getUser())
+          .database(ctx.getDb())
+          .change(origNotes)
+          .check(ChangePermission.ADD_PATCH_SET);
     }
-    if (validatePolicy == CommitValidators.Policy.NONE) {
+    if (!validate) {
       return;
     }
 
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(ctx.getUser()).ref(origNotes.getChange().getDest());
+
     String refName = getPatchSetId().toRefName();
-    CommitReceivedEvent event =
+    try (CommitReceivedEvent event =
         new CommitReceivedEvent(
             new ReceiveCommand(
                 ObjectId.zeroId(),
-                commit.getId(),
+                commitId,
                 refName.substring(0, refName.lastIndexOf('/') + 1) + "new"),
-            origCtl.getProjectControl().getProject(),
-            origCtl.getRefControl().getRefName(),
-            commit,
-            ctx.getIdentifiedUser());
-
-    try {
+            projectCache.checkedGet(origNotes.getProjectName()).getProject(),
+            origNotes.getChange().getDest().get(),
+            ctx.getRevWalk().getObjectReader(),
+            commitId,
+            ctx.getIdentifiedUser())) {
       commitValidatorsFactory
-          .create(validatePolicy, origCtl.getRefControl(), new NoSshInfo(), ctx.getRepository())
+          .forGerritCommits(
+              perm,
+              origNotes.getChange().getDest(),
+              ctx.getIdentifiedUser(),
+              new NoSshInfo(),
+              ctx.getRevWalk())
           .validate(event);
     } catch (CommitValidationException e) {
       throw new ResourceConflictException(e.getFullMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
index 5aa41b1..1ff0fdd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostHashtags.java
@@ -19,10 +19,13 @@
 import com.google.gerrit.extensions.api.changes.HashtagsInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -30,27 +33,29 @@
 
 @Singleton
 public class PostHashtags
-    implements RestModifyView<ChangeResource, HashtagsInput>, UiAction<ChangeResource> {
+    extends RetryingRestModifyView<
+        ChangeResource, HashtagsInput, Response<ImmutableSortedSet<String>>>
+    implements UiAction<ChangeResource> {
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final SetHashtagsOp.Factory hashtagsFactory;
 
   @Inject
   PostHashtags(
-      Provider<ReviewDb> db,
-      BatchUpdate.Factory batchUpdateFactory,
-      SetHashtagsOp.Factory hashtagsFactory) {
+      Provider<ReviewDb> db, RetryHelper retryHelper, SetHashtagsOp.Factory hashtagsFactory) {
+    super(retryHelper);
     this.db = db;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.hashtagsFactory = hashtagsFactory;
   }
 
   @Override
-  public Response<ImmutableSortedSet<String>> apply(ChangeResource req, HashtagsInput input)
-      throws RestApiException, UpdateException {
+  protected Response<ImmutableSortedSet<String>> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, HashtagsInput input)
+      throws RestApiException, UpdateException, PermissionBackendException {
+    req.permissions().check(ChangePermission.EDIT_HASHTAGS);
+
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db.get(), req.getChange().getProject(), req.getControl().getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(
+            db.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       SetHashtagsOp op = hashtagsFactory.create(input);
       bu.addOp(req.getId(), op);
       bu.execute();
@@ -59,9 +64,9 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Hashtags")
-        .setVisible(resource.getControl().canEditHashtags());
+        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_HASHTAGS));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostPrivate.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostPrivate.java
new file mode 100644
index 0000000..a084d9e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostPrivate.java
@@ -0,0 +1,109 @@
+// 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.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeMessagesUtil;
+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.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class PostPrivate
+    extends RetryingRestModifyView<ChangeResource, SetPrivateOp.Input, Response<String>>
+    implements UiAction<ChangeResource> {
+  private final ChangeMessagesUtil cmUtil;
+  private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
+  private final SetPrivateOp.Factory setPrivateOpFactory;
+  private final boolean disablePrivateChanges;
+
+  @Inject
+  PostPrivate(
+      Provider<ReviewDb> dbProvider,
+      RetryHelper retryHelper,
+      ChangeMessagesUtil cmUtil,
+      PermissionBackend permissionBackend,
+      SetPrivateOp.Factory setPrivateOpFactory,
+      @GerritServerConfig Config config) {
+    super(retryHelper);
+    this.dbProvider = dbProvider;
+    this.cmUtil = cmUtil;
+    this.permissionBackend = permissionBackend;
+    this.setPrivateOpFactory = setPrivateOpFactory;
+    this.disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
+  }
+
+  @Override
+  public Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, SetPrivateOp.Input input)
+      throws RestApiException, UpdateException {
+    if (disablePrivateChanges) {
+      throw new MethodNotAllowedException("private changes are disabled");
+    }
+
+    if (!canSetPrivate(rsrc).value()) {
+      throw new AuthException("not allowed to mark private");
+    }
+
+    if (rsrc.getChange().isPrivate()) {
+      return Response.ok("");
+    }
+
+    SetPrivateOp op = setPrivateOpFactory.create(cmUtil, true, input);
+    try (BatchUpdate u =
+        updateFactory.create(
+            dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      u.addOp(rsrc.getId(), op).execute();
+    }
+
+    return Response.created("");
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
+    return new UiAction.Description()
+        .setLabel("Mark private")
+        .setTitle("Mark change as private")
+        .setVisible(and(!disablePrivateChanges && !change.isPrivate(), canSetPrivate(rsrc)));
+  }
+
+  private BooleanCondition canSetPrivate(ChangeResource rsrc) {
+    PermissionBackend.WithUser user = permissionBackend.user(rsrc.getUser());
+    return or(
+        rsrc.isUserOwner() && rsrc.getChange().getStatus() != Change.Status.MERGED,
+        user.testCond(GlobalPermission.ADMINISTRATE_SERVER));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index 0d83020..8c32039 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -18,7 +18,9 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.groupingBy;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -27,7 +29,6 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
@@ -38,8 +39,6 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
@@ -50,6 +49,8 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
+import com.google.gerrit.extensions.client.Comment.Range;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.AccountInfo;
@@ -61,7 +62,6 @@
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Account;
@@ -81,28 +81,47 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.patch.PatchListKey;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+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.query.change.ChangeData;
 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.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.LabelVote;
+import com.google.gson.Gson;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -113,17 +132,26 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.OptionalInt;
 import java.util.Set;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
-public class PostReview implements RestModifyView<RevisionResource, ReviewInput> {
-  private static final Logger log = LoggerFactory.getLogger(PostReview.class);
+public class PostReview
+    extends RetryingRestModifyView<RevisionResource, ReviewInput, Response<ReviewResult>> {
+  public static final String ERROR_ADDING_REVIEWER = "error adding reviewer";
+  public static final String ERROR_WIP_READY_MUTUALLY_EXCLUSIVE =
+      "work_in_progress and ready are mutually exclusive";
+
+  public static final String START_REVIEW_MESSAGE = "This change is ready for review.";
+
+  private static final Gson GSON = OutputFormat.JSON_COMPACT.newGson();
+  private static final int DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES = 1024 * 1024;
 
   private final Provider<ReviewDb> db;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final ChangesCollection changes;
+  private final ChangeResource.Factory changeResourceFactory;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
@@ -136,12 +164,18 @@
   private final PostReviewers postReviewers;
   private final NotesMigration migration;
   private final NotifyUtil notifyUtil;
+  private final Config gerritConfig;
+  private final WorkInProgressOp.Factory workInProgressOpFactory;
+  private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+  private final ProjectControl.GenericFactory projectControlFactory;
+  private final boolean strictLabels;
 
   @Inject
   PostReview(
       Provider<ReviewDb> db,
-      BatchUpdate.Factory batchUpdateFactory,
-      ChangesCollection changes,
+      RetryHelper retryHelper,
+      ChangeResource.Factory changeResourceFactory,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
       ChangeMessagesUtil cmUtil,
@@ -153,10 +187,15 @@
       CommentAdded commentAdded,
       PostReviewers postReviewers,
       NotesMigration migration,
-      NotifyUtil notifyUtil) {
+      NotifyUtil notifyUtil,
+      @GerritServerConfig Config gerritConfig,
+      WorkInProgressOp.Factory workInProgressOpFactory,
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      ProjectControl.GenericFactory projectControlFactory) {
+    super(retryHelper);
     this.db = db;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.changes = changes;
+    this.changeResourceFactory = changeResourceFactory;
     this.changeDataFactory = changeDataFactory;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
@@ -169,28 +208,42 @@
     this.postReviewers = postReviewers;
     this.migration = migration;
     this.notifyUtil = notifyUtil;
+    this.gerritConfig = gerritConfig;
+    this.workInProgressOpFactory = workInProgressOpFactory;
+    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.projectControlFactory = projectControlFactory;
+    this.strictLabels = gerritConfig.getBoolean("change", "strictLabels", false);
   }
 
   @Override
-  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input)
-      throws RestApiException, UpdateException, OrmException, IOException {
-    return apply(revision, input, TimeUtil.nowTs());
+  protected Response<ReviewResult> applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input)
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException, ConfigInvalidException, PatchListNotAvailableException,
+          NoSuchProjectException {
+    return apply(updateFactory, revision, input, TimeUtil.nowTs());
   }
 
-  public Response<ReviewResult> apply(RevisionResource revision, ReviewInput input, Timestamp ts)
-      throws RestApiException, UpdateException, OrmException, IOException {
+  public Response<ReviewResult> apply(
+      BatchUpdate.Factory updateFactory, RevisionResource revision, ReviewInput input, Timestamp ts)
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException, ConfigInvalidException, PatchListNotAvailableException,
+          NoSuchProjectException {
     // Respect timestamp, but truncate at change created-on time.
     ts = Ordering.natural().max(ts, revision.getChange().getCreatedOn());
     if (revision.getEdit().isPresent()) {
       throw new ResourceConflictException("cannot post review on edit");
     }
+    ProjectState projectState = projectCache.checkedGet(revision.getProject());
+    LabelTypes labelTypes = projectState.getLabelTypes(revision.getNotes(), revision.getUser());
     if (input.onBehalfOf != null) {
-      revision = onBehalfOf(revision, input);
+      revision = onBehalfOf(revision, labelTypes, input);
     } else if (input.drafts == null) {
       input.drafts = DraftHandling.DELETE;
     }
     if (input.labels != null) {
-      checkLabels(revision, input.strictLabels, input.labels);
+      checkLabels(revision, labelTypes, input.labels);
     }
     if (input.comments != null) {
       cleanUpComments(input.comments);
@@ -203,9 +256,9 @@
       checkRobotComments(revision, input.robotComments);
     }
 
+    NotifyHandling reviewerNotify = input.notify;
     if (input.notify == null) {
-      log.warn("notify = null; assuming notify = NONE");
-      input.notify = NotifyHandling.NONE;
+      input.notify = defaultNotify(revision.getChange(), input);
     }
 
     ListMultimap<RecipientType, Account.Id> accountsToNotify =
@@ -239,13 +292,13 @@
     ReviewResult output = new ReviewResult();
     output.reviewers = reviewerJsonResults;
     if (hasError || confirm) {
+      output.error = ERROR_ADDING_REVIEWER;
       return Response.withStatusCode(SC_BAD_REQUEST, output);
     }
     output.labels = input.labels;
 
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db.get(), revision.getChange().getProject(), revision.getUser(), ts)) {
+        updateFactory.create(db.get(), revision.getChange().getProject(), revision.getUser(), ts)) {
       Account.Id id = revision.getUser().getAccountId();
       boolean ccOrReviewer = false;
       if (input.labels != null && !input.labels.isEmpty()) {
@@ -291,40 +344,101 @@
         bu.addOp(revision.getChange().getId(), selfAddition.op);
       }
 
+      // Add WorkInProgressOp if requested.
+      if (input.ready || input.workInProgress) {
+        if (input.ready && input.workInProgress) {
+          output.error = ERROR_WIP_READY_MUTUALLY_EXCLUSIVE;
+          return Response.withStatusCode(SC_BAD_REQUEST, output);
+        }
+
+        WorkInProgressOp.checkPermissions(
+            permissionBackend,
+            revision.getUser(),
+            revision.getChange(),
+            projectControlFactory.controlFor(revision.getProject(), revision.getUser()));
+
+        if (input.ready) {
+          output.ready = true;
+        }
+
+        // Suppress notifications in WorkInProgressOp, we'll take care of
+        // them in this endpoint.
+        WorkInProgressOp.Input wipIn = new WorkInProgressOp.Input();
+        wipIn.notify = NotifyHandling.NONE;
+        bu.addOp(
+            revision.getChange().getId(),
+            workInProgressOpFactory.create(input.workInProgress, wipIn));
+      }
+
+      // Add the review op.
       bu.addOp(
           revision.getChange().getId(),
-          new Op(revision.getPatchSet().getId(), input, accountsToNotify, reviewerResults));
+          new Op(projectState, revision.getPatchSet().getId(), input, accountsToNotify));
+
       bu.execute();
 
       for (PostReviewers.Addition reviewerResult : reviewerResults) {
         reviewerResult.gatherResults();
       }
 
-      emailReviewers(revision.getChange(), reviewerResults, input.notify, accountsToNotify);
+      emailReviewers(revision.getChange(), reviewerResults, reviewerNotify, accountsToNotify);
     }
 
     return Response.ok(output);
   }
 
+  private NotifyHandling defaultNotify(Change c, ReviewInput in) {
+    boolean workInProgress = c.isWorkInProgress();
+    if (in.workInProgress) {
+      workInProgress = true;
+    }
+    if (in.ready) {
+      workInProgress = false;
+    }
+
+    if (ChangeMessagesUtil.isAutogenerated(in.tag)) {
+      // Autogenerated comments default to lower notify levels.
+      return workInProgress ? NotifyHandling.OWNER : NotifyHandling.OWNER_REVIEWERS;
+    }
+
+    if (workInProgress && !c.hasReviewStarted()) {
+      // If review hasn't started we want to minimize recipients, no matter who
+      // the author is.
+      return NotifyHandling.OWNER;
+    }
+
+    return NotifyHandling.ALL;
+  }
+
   private void emailReviewers(
       Change change,
       List<PostReviewers.Addition> reviewerAdditions,
-      NotifyHandling notify,
+      @Nullable NotifyHandling notify,
       ListMultimap<RecipientType, Account.Id> accountsToNotify) {
     List<Account.Id> to = new ArrayList<>();
     List<Account.Id> cc = new ArrayList<>();
+    List<Address> toByEmail = new ArrayList<>();
+    List<Address> ccByEmail = new ArrayList<>();
     for (PostReviewers.Addition addition : reviewerAdditions) {
-      if (addition.op.state == ReviewerState.REVIEWER) {
-        to.addAll(addition.op.reviewers.keySet());
-      } else if (addition.op.state == ReviewerState.CC) {
-        cc.addAll(addition.op.reviewers.keySet());
+      if (addition.state == ReviewerState.REVIEWER) {
+        to.addAll(addition.reviewers);
+        toByEmail.addAll(addition.reviewersByEmail);
+      } else if (addition.state == ReviewerState.CC) {
+        cc.addAll(addition.reviewers);
+        ccByEmail.addAll(addition.reviewersByEmail);
       }
     }
-    postReviewers.emailReviewers(change, to, cc, notify, accountsToNotify);
+    if (reviewerAdditions.size() > 0) {
+      reviewerAdditions
+          .get(0)
+          .op
+          .emailReviewers(change, to, cc, toByEmail, ccByEmail, notify, accountsToNotify);
+    }
   }
 
-  private RevisionResource onBehalfOf(RevisionResource rev, ReviewInput in)
-      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException {
+  private RevisionResource onBehalfOf(RevisionResource rev, LabelTypes labelTypes, ReviewInput in)
+      throws BadRequestException, AuthException, UnprocessableEntityException, OrmException,
+          PermissionBackendException, IOException, ConfigInvalidException {
     if (in.labels == null || in.labels.isEmpty()) {
       throw new AuthException(
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
@@ -336,29 +450,30 @@
       throw new AuthException("not allowed to modify other user's drafts");
     }
 
-    ChangeControl caller = rev.getControl();
+    CurrentUser caller = rev.getUser();
+    PermissionBackend.ForChange perm = rev.permissions().database(db);
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-      LabelType type = caller.getLabelTypes().byLabel(ent.getKey());
-      if (type == null && in.strictLabels) {
-        throw new BadRequestException(
-            String.format("label \"%s\" is not a configured label", ent.getKey()));
-      } else if (type == null) {
+      LabelType type = labelTypes.byLabel(ent.getKey());
+      if (type == null) {
+        if (strictLabels) {
+          throw new BadRequestException(
+              String.format("label \"%s\" is not a configured label", ent.getKey()));
+        }
         itr.remove();
         continue;
       }
 
-      if (caller.getUser().isInternalUser()) {
-        continue;
-      }
-
-      PermissionRange r = caller.getRange(Permission.forLabelAs(type.getName()));
-      if (r == null || r.isEmpty() || !r.contains(ent.getValue())) {
-        throw new AuthException(
-            String.format(
-                "not permitted to modify label \"%s\" on behalf of \"%s\"",
-                ent.getKey(), in.onBehalfOf));
+      if (!caller.isInternalUser()) {
+        try {
+          perm.check(new LabelPermission.WithValue(ON_BEHALF_OF, type, ent.getValue()));
+        } catch (AuthException e) {
+          throw new AuthException(
+              String.format(
+                  "not permitted to modify label \"%s\" on behalf of \"%s\"",
+                  type.getName(), in.onBehalfOf));
+        }
       }
     }
     if (in.labels.isEmpty()) {
@@ -366,27 +481,27 @@
           String.format("label required to post review on behalf of \"%s\"", in.onBehalfOf));
     }
 
-    ChangeControl target =
-        caller.forUser(accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
-    if (!target.getRefControl().isVisible()) {
+    IdentifiedUser reviewer = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    try {
+      perm.user(reviewer).check(ChangePermission.READ);
+    } catch (AuthException e) {
       throw new UnprocessableEntityException(
-          String.format(
-              "on_behalf_of account %s cannot see destination ref",
-              target.getUser().getAccountId()));
+          String.format("on_behalf_of account %s cannot see change", reviewer.getAccountId()));
     }
-    return new RevisionResource(changes.parse(target), rev.getPatchSet());
+
+    return new RevisionResource(
+        changeResourceFactory.create(rev.getNotes(), reviewer), rev.getPatchSet());
   }
 
-  private void checkLabels(RevisionResource revision, boolean strict, Map<String, Short> labels)
-      throws BadRequestException, AuthException {
-    ChangeControl ctl = revision.getControl();
+  private void checkLabels(RevisionResource rsrc, LabelTypes labelTypes, Map<String, Short> labels)
+      throws BadRequestException, AuthException, PermissionBackendException {
+    PermissionBackend.ForChange perm = rsrc.permissions();
     Iterator<Map.Entry<String, Short>> itr = labels.entrySet().iterator();
     while (itr.hasNext()) {
       Map.Entry<String, Short> ent = itr.next();
-
-      LabelType lt = revision.getControl().getLabelTypes().byLabel(ent.getKey());
+      LabelType lt = labelTypes.byLabel(ent.getKey());
       if (lt == null) {
-        if (strict) {
+        if (strictLabels) {
           throw new BadRequestException(
               String.format("label \"%s\" is not a configured label", ent.getKey()));
         }
@@ -401,7 +516,7 @@
       }
 
       if (lt.getValue(ent.getValue()) == null) {
-        if (strict) {
+        if (strictLabels) {
           throw new BadRequestException(
               String.format("label \"%s\": %d is not a valid value", ent.getKey(), ent.getValue()));
         }
@@ -409,23 +524,18 @@
         continue;
       }
 
-      String name = lt.getName();
-      PermissionRange range = ctl.getRange(Permission.forLabel(name));
-      if (range == null || !range.contains(ent.getValue())) {
-        if (strict) {
-          throw new AuthException(
-              String.format(
-                  "Applying label \"%s\": %d is restricted", ent.getKey(), ent.getValue()));
-        } else if (range == null || range.isEmpty()) {
-          ent.setValue((short) 0);
-        } else {
-          ent.setValue((short) range.squash(ent.getValue()));
-        }
+      short val = ent.getValue();
+      try {
+        perm.check(new LabelPermission.WithValue(lt, val));
+      } catch (AuthException e) {
+        throw new AuthException(
+            String.format("Applying label \"%s\": %d is restricted", lt.getName(), val));
       }
     }
   }
 
-  private <T extends CommentInput> void cleanUpComments(Map<String, List<T>> commentsPerPath) {
+  private static <T extends CommentInput> void cleanUpComments(
+      Map<String, List<T>> commentsPerPath) {
     Iterator<List<T>> mapValueIterator = commentsPerPath.values().iterator();
     while (mapValueIterator.hasNext()) {
       List<T> comments = mapValueIterator.next();
@@ -441,7 +551,7 @@
     }
   }
 
-  private <T extends CommentInput> void cleanUpComments(List<T> comments) {
+  private static <T extends CommentInput> void cleanUpComments(List<T> comments) {
     Iterator<T> commentsIterator = comments.iterator();
     while (commentsIterator.hasNext()) {
       T comment = commentsIterator.next();
@@ -459,11 +569,11 @@
 
   private <T extends CommentInput> void checkComments(
       RevisionResource revision, Map<String, List<T>> commentsPerPath)
-      throws OrmException, BadRequestException {
+      throws BadRequestException, PatchListNotAvailableException {
     Set<String> revisionFilePaths = getAffectedFilePaths(revision);
     for (Map.Entry<String, List<T>> entry : commentsPerPath.entrySet()) {
       String path = entry.getKey();
-      PatchSet.Id patchSetId = revision.getChange().currentPatchSetId();
+      PatchSet.Id patchSetId = revision.getPatchSet().getId();
       ensurePathRefersToAvailableOrMagicFile(path, revisionFilePaths, patchSetId);
 
       List<T> comments = entry.getValue();
@@ -475,12 +585,17 @@
     }
   }
 
-  private Set<String> getAffectedFilePaths(RevisionResource revision) throws OrmException {
-    ChangeData changeData = changeDataFactory.create(db.get(), revision.getControl());
-    return new HashSet<>(changeData.filePaths(revision.getPatchSet()));
+  private Set<String> getAffectedFilePaths(RevisionResource revision)
+      throws PatchListNotAvailableException {
+    ObjectId newId = ObjectId.fromString(revision.getPatchSet().getRevision().get());
+    DiffSummaryKey key =
+        DiffSummaryKey.fromPatchListKey(
+            PatchListKey.againstDefaultBase(newId, Whitespace.IGNORE_NONE));
+    DiffSummary ds = patchListCache.getDiffSummary(key, revision.getProject());
+    return new HashSet<>(ds.getPaths());
   }
 
-  private void ensurePathRefersToAvailableOrMagicFile(
+  private static void ensurePathRefersToAvailableOrMagicFile(
       String path, Set<String> availableFilePaths, PatchSet.Id patchSetId)
       throws BadRequestException {
     if (!availableFilePaths.contains(path) && !Patch.isMagic(path)) {
@@ -489,14 +604,15 @@
     }
   }
 
-  private void ensureLineIsNonNegative(Integer line, String path) throws BadRequestException {
+  private static void ensureLineIsNonNegative(Integer line, String path)
+      throws BadRequestException {
     if (line != null && line < 0) {
       throw new BadRequestException(
           String.format("negative line number %d not allowed on %s", line, path));
     }
   }
 
-  private <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
+  private static <T extends CommentInput> void ensureCommentNotOnMagicFilesOfAutoMerge(
       String path, T comment) throws BadRequestException {
     if (Patch.isMagic(path) && comment.side == Side.PARENT && comment.parent == null) {
       throw new BadRequestException(String.format("cannot comment on %s on auto-merge", path));
@@ -505,11 +621,12 @@
 
   private void checkRobotComments(
       RevisionResource revision, Map<String, List<RobotCommentInput>> in)
-      throws BadRequestException, OrmException {
+      throws BadRequestException, PatchListNotAvailableException {
     cleanUpComments(in);
     for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
       String commentPath = e.getKey();
       for (RobotCommentInput c : e.getValue()) {
+        ensureSizeOfJsonInputIsWithinBounds(c);
         ensureRobotIdIsSet(c.robotId, commentPath);
         ensureRobotRunIdIsSet(c.robotRunId, commentPath);
         ensureFixSuggestionsAreAddable(c.fixSuggestions, commentPath);
@@ -518,14 +635,41 @@
     checkComments(revision, in);
   }
 
-  private void ensureRobotIdIsSet(String robotId, String commentPath) throws BadRequestException {
+  private void ensureSizeOfJsonInputIsWithinBounds(RobotCommentInput robotCommentInput)
+      throws BadRequestException {
+    OptionalInt robotCommentSizeLimit = getRobotCommentSizeLimit();
+    if (robotCommentSizeLimit.isPresent()) {
+      int sizeLimit = robotCommentSizeLimit.getAsInt();
+      byte[] robotCommentBytes = GSON.toJson(robotCommentInput).getBytes(StandardCharsets.UTF_8);
+      int robotCommentSize = robotCommentBytes.length;
+      if (robotCommentSize > sizeLimit) {
+        throw new BadRequestException(
+            String.format(
+                "Size %d (bytes) of robot comment is greater than limit %d (bytes)",
+                robotCommentSize, sizeLimit));
+      }
+    }
+  }
+
+  private OptionalInt getRobotCommentSizeLimit() {
+    int robotCommentSizeLimit =
+        gerritConfig.getInt(
+            "change", "robotCommentSizeLimit", DEFAULT_ROBOT_COMMENT_SIZE_LIMIT_IN_BYTES);
+    if (robotCommentSizeLimit <= 0) {
+      return OptionalInt.empty();
+    }
+    return OptionalInt.of(robotCommentSizeLimit);
+  }
+
+  private static void ensureRobotIdIsSet(String robotId, String commentPath)
+      throws BadRequestException {
     if (robotId == null) {
       throw new BadRequestException(
           String.format("robotId is missing for robot comment on %s", commentPath));
     }
   }
 
-  private void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
+  private static void ensureRobotRunIdIsSet(String robotRunId, String commentPath)
       throws BadRequestException {
     if (robotRunId == null) {
       throw new BadRequestException(
@@ -533,7 +677,7 @@
     }
   }
 
-  private void ensureFixSuggestionsAreAddable(
+  private static void ensureFixSuggestionsAreAddable(
       List<FixSuggestionInfo> fixSuggestionInfos, String commentPath) throws BadRequestException {
     if (fixSuggestionInfos == null) {
       return;
@@ -545,7 +689,7 @@
     }
   }
 
-  private void ensureDescriptionIsSet(String commentPath, String description)
+  private static void ensureDescriptionIsSet(String commentPath, String description)
       throws BadRequestException {
     if (description == null) {
       throw new BadRequestException(
@@ -555,20 +699,25 @@
     }
   }
 
-  private void ensureFixReplacementsAreAddable(
+  private static void ensureFixReplacementsAreAddable(
       String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
     ensureReplacementsArePresent(commentPath, fixReplacementInfos);
 
     for (FixReplacementInfo fixReplacementInfo : fixReplacementInfos) {
       ensureReplacementPathIsSet(commentPath, fixReplacementInfo.path);
-      ensureReplacementPathRefersToFileOfComment(commentPath, fixReplacementInfo.path);
       ensureRangeIsSet(commentPath, fixReplacementInfo.range);
       ensureRangeIsValid(commentPath, fixReplacementInfo.range);
       ensureReplacementStringIsSet(commentPath, fixReplacementInfo.replacement);
     }
+
+    Map<String, List<FixReplacementInfo>> replacementsPerFilePath =
+        fixReplacementInfos.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
+    for (List<FixReplacementInfo> sameFileReplacements : replacementsPerFilePath.values()) {
+      ensureRangesDoNotOverlap(commentPath, sameFileReplacements);
+    }
   }
 
-  private void ensureReplacementsArePresent(
+  private static void ensureReplacementsArePresent(
       String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
     if (fixReplacementInfos == null || fixReplacementInfos.isEmpty()) {
       throw new BadRequestException(
@@ -579,7 +728,7 @@
     }
   }
 
-  private void ensureReplacementPathIsSet(String commentPath, String replacementPath)
+  private static void ensureReplacementPathIsSet(String commentPath, String replacementPath)
       throws BadRequestException {
     if (replacementPath == null) {
       throw new BadRequestException(
@@ -589,20 +738,7 @@
     }
   }
 
-  private void ensureReplacementPathRefersToFileOfComment(
-      String commentPath, String replacementPath) throws BadRequestException {
-    if (!Objects.equals(commentPath, replacementPath)) {
-      throw new BadRequestException(
-          String.format(
-              "Replacements may only be "
-                  + "specified for the file %s on which the robot comment was added",
-              commentPath));
-    }
-  }
-
-  private void ensureRangeIsSet(
-      String commentPath, com.google.gerrit.extensions.client.Comment.Range range)
-      throws BadRequestException {
+  private static void ensureRangeIsSet(String commentPath, Range range) throws BadRequestException {
     if (range == null) {
       throw new BadRequestException(
           String.format(
@@ -610,8 +746,7 @@
     }
   }
 
-  private void ensureRangeIsValid(
-      String commentPath, com.google.gerrit.extensions.client.Comment.Range range)
+  private static void ensureRangeIsValid(String commentPath, Range range)
       throws BadRequestException {
     if (range == null) {
       return;
@@ -628,7 +763,7 @@
     }
   }
 
-  private void ensureReplacementStringIsSet(String commentPath, String replacement)
+  private static void ensureReplacementStringIsSet(String commentPath, String replacement)
       throws BadRequestException {
     if (replacement == null) {
       throw new BadRequestException(
@@ -639,6 +774,28 @@
     }
   }
 
+  private static void ensureRangesDoNotOverlap(
+      String commentPath, List<FixReplacementInfo> fixReplacementInfos) throws BadRequestException {
+    List<Range> sortedRanges =
+        fixReplacementInfos
+            .stream()
+            .map(fixReplacementInfo -> fixReplacementInfo.range)
+            .sorted()
+            .collect(toList());
+
+    int previousEndLine = 0;
+    int previousOffset = -1;
+    for (Range range : sortedRanges) {
+      if (range.startLine < previousEndLine
+          || (range.startLine == previousEndLine && range.startCharacter < previousOffset)) {
+        throw new BadRequestException(
+            String.format("Replacements overlap for the robot comment on %s", commentPath));
+      }
+      previousEndLine = range.endLine;
+      previousOffset = range.endCharacter;
+    }
+  }
+
   /** Used to compare Comments with CommentInput comments. */
   @AutoValue
   abstract static class CommentSetEntry {
@@ -653,14 +810,13 @@
           filename, patchSetId, line, side, message, range);
     }
 
-    @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
     public static CommentSetEntry create(Comment comment) {
       return create(
           comment.key.filename,
           comment.key.patchSetId,
           comment.lineNbr,
           Side.fromShort(comment.side),
-          Hashing.sha1().hashString(comment.message, UTF_8),
+          Hashing.murmur3_128().hashString(comment.message, UTF_8),
           comment.range);
     }
 
@@ -680,10 +836,10 @@
   }
 
   private class Op implements BatchUpdateOp {
+    private final ProjectState projectState;
     private final PatchSet.Id psId;
     private final ReviewInput in;
     private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-    private final List<PostReviewers.Addition> reviewerResults;
 
     private IdentifiedUser user;
     private ChangeNotes notes;
@@ -695,26 +851,27 @@
     private Map<String, Short> oldApprovals = new HashMap<>();
 
     private Op(
+        ProjectState projectState,
         PatchSet.Id psId,
         ReviewInput in,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify,
-        List<PostReviewers.Addition> reviewerResults) {
+        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+      this.projectState = projectState;
       this.psId = psId;
       this.in = in;
       this.accountsToNotify = checkNotNull(accountsToNotify);
-      this.reviewerResults = reviewerResults;
     }
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, ResourceConflictException, UnprocessableEntityException {
+        throws OrmException, ResourceConflictException, UnprocessableEntityException, IOException,
+            PatchListNotAvailableException {
       user = ctx.getIdentifiedUser();
       notes = ctx.getNotes();
       ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       boolean dirty = false;
       dirty |= insertComments(ctx);
       dirty |= insertRobotComments(ctx);
-      dirty |= updateLabels(ctx);
+      dirty |= updateLabels(projectState, ctx);
       dirty |= insertMessage(ctx);
       return dirty;
     }
@@ -749,7 +906,7 @@
     }
 
     private boolean insertComments(ChangeContext ctx)
-        throws OrmException, UnprocessableEntityException {
+        throws OrmException, UnprocessableEntityException, PatchListNotAvailableException {
       Map<String, List<CommentInput>> map = in.comments;
       if (map == null) {
         map = Collections.emptyMap();
@@ -802,12 +959,9 @@
           toDel.addAll(drafts.values());
           break;
         case PUBLISH:
-          for (Comment e : drafts.values()) {
-            toPublish.add(publishComment(ctx, e, ps));
-          }
-          break;
         case PUBLISH_ALL_REVISIONS:
-          publishAllRevisions(ctx, drafts, toPublish);
+          commentsUtil.publish(ctx, psId, drafts.values(), in.tag);
+          comments.addAll(drafts.values());
           break;
       }
       ChangeUpdate u = ctx.getUpdate(psId);
@@ -817,7 +971,8 @@
       return !toDel.isEmpty() || !toPublish.isEmpty();
     }
 
-    private boolean insertRobotComments(ChangeContext ctx) throws OrmException {
+    private boolean insertRobotComments(ChangeContext ctx)
+        throws OrmException, PatchListNotAvailableException {
       if (in.robotComments == null) {
         return false;
       }
@@ -828,7 +983,8 @@
       return !newRobotComments.isEmpty();
     }
 
-    private List<RobotComment> getNewRobotComments(ChangeContext ctx) throws OrmException {
+    private List<RobotComment> getNewRobotComments(ChangeContext ctx)
+        throws OrmException, PatchListNotAvailableException {
       List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
 
       Set<CommentSetEntry> existingIds =
@@ -848,7 +1004,8 @@
     }
 
     private RobotComment createRobotCommentFromInput(
-        ChangeContext ctx, String path, RobotCommentInput robotCommentInput) throws OrmException {
+        ChangeContext ctx, String path, RobotCommentInput robotCommentInput)
+        throws PatchListNotAvailableException {
       RobotComment robotComment =
           commentsUtil.newRobotComment(
               ctx,
@@ -940,37 +1097,6 @@
       return labels;
     }
 
-    private Comment publishComment(ChangeContext ctx, Comment c, PatchSet ps) throws OrmException {
-      c.writtenOn = ctx.getWhen();
-      c.tag = in.tag;
-      // Draft may have been created by a different real user; copy the current
-      // real user. (Only applies to X-Gerrit-RunAs, since modifying drafts via
-      // on_behalf_of is not allowed.)
-      ctx.getUser().updateRealAccountId(c::setRealAuthor);
-      setCommentRevId(c, patchListCache, ctx.getChange(), checkNotNull(ps));
-      return c;
-    }
-
-    private void publishAllRevisions(
-        ChangeContext ctx, Map<String, Comment> drafts, List<Comment> ups) throws OrmException {
-      boolean needOtherPatchSets = false;
-      for (Comment c : drafts.values()) {
-        if (c.key.patchSetId != psId.get()) {
-          needOtherPatchSets = true;
-          break;
-        }
-      }
-      Map<PatchSet.Id, PatchSet> patchSets =
-          needOtherPatchSets
-              ? psUtil.byChangeAsMap(ctx.getDb(), ctx.getNotes())
-              : ImmutableMap.of(psId, ps);
-      for (Comment e : drafts.values()) {
-        ups.add(
-            publishComment(
-                ctx, e, patchSets.get(new PatchSet.Id(ctx.getChange().getId(), e.key.patchSetId))));
-      }
-    }
-
     private Map<String, Short> getAllApprovals(
         LabelTypes labelTypes, Map<String, Short> current, Map<String, Short> input) {
       Map<String, Short> allApprovals = new HashMap<>();
@@ -1006,17 +1132,7 @@
       if (ctx.getAccountId().equals(ctx.getChange().getOwner())) {
         return true;
       }
-      for (PostReviewers.Addition addition : reviewerResults) {
-        if (addition.op.addedReviewers == null) {
-          continue;
-        }
-        for (PatchSetApproval psa : addition.op.addedReviewers) {
-          if (psa.getAccountId().equals(ctx.getAccountId())) {
-            return true;
-          }
-        }
-      }
-      ChangeData cd = changeDataFactory.create(db.get(), ctx.getControl());
+      ChangeData cd = changeDataFactory.create(db.get(), ctx.getNotes());
       ReviewerSet reviewers = cd.reviewers();
       if (reviewers.byState(REVIEWER).contains(ctx.getAccountId())) {
         return true;
@@ -1024,7 +1140,8 @@
       return false;
     }
 
-    private boolean updateLabels(ChangeContext ctx) throws OrmException, ResourceConflictException {
+    private boolean updateLabels(ProjectState projectState, ChangeContext ctx)
+        throws OrmException, ResourceConflictException, IOException {
       Map<String, Short> inLabels =
           MoreObjects.firstNonNull(in.labels, Collections.<String, Short>emptyMap());
 
@@ -1037,8 +1154,8 @@
 
       List<PatchSetApproval> del = new ArrayList<>();
       List<PatchSetApproval> ups = new ArrayList<>();
-      Map<String, PatchSetApproval> current = scanLabels(ctx, del);
-      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
+      Map<String, PatchSetApproval> current = scanLabels(projectState, ctx, del);
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
       Map<String, Short> allApprovals =
           getAllApprovals(labelTypes, approvalsByKey(current.values()), inLabels);
       Map<String, Short> previous =
@@ -1098,7 +1215,7 @@
         return false;
       }
 
-      forceCallerAsReviewer(ctx, current, ups, del);
+      forceCallerAsReviewer(projectState, ctx, current, ups, del);
       ctx.getDb().patchSetApprovals().delete(del);
       ctx.getDb().patchSetApprovals().upsert(ups);
       return !del.isEmpty() || !ups.isEmpty();
@@ -1176,6 +1293,7 @@
     }
 
     private void forceCallerAsReviewer(
+        ProjectState projectState,
         ChangeContext ctx,
         Map<String, PatchSetApproval> current,
         List<PatchSetApproval> ups,
@@ -1185,7 +1303,12 @@
         if (del.isEmpty()) {
           // If no existing label is being set to 0, hack in the caller
           // as a reviewer by picking the first server-wide LabelType.
-          LabelId labelId = ctx.getControl().getLabelTypes().getLabelTypes().get(0).getLabelId();
+          LabelId labelId =
+              projectState
+                  .getLabelTypes(ctx.getNotes(), ctx.getUser())
+                  .getLabelTypes()
+                  .get(0)
+                  .getLabelId();
           PatchSetApproval c = ApprovalsUtil.newApproval(psId, user, labelId, 0, ctx.getWhen());
           c.setTag(in.tag);
           c.setGranted(ctx.getWhen());
@@ -1203,13 +1326,21 @@
       ctx.getUpdate(ctx.getChange().currentPatchSetId()).putReviewer(user.getAccountId(), REVIEWER);
     }
 
-    private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, List<PatchSetApproval> del)
-        throws OrmException {
-      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
+    private Map<String, PatchSetApproval> scanLabels(
+        ProjectState projectState, ChangeContext ctx, List<PatchSetApproval> del)
+        throws OrmException, IOException {
+      LabelTypes labelTypes = projectState.getLabelTypes(ctx.getNotes(), ctx.getUser());
       Map<String, PatchSetApproval> current = new HashMap<>();
 
       for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(ctx.getDb(), ctx.getControl(), psId, user.getAccountId())) {
+          approvalsUtil.byPatchSetUser(
+              ctx.getDb(),
+              ctx.getNotes(),
+              ctx.getUser(),
+              psId,
+              user.getAccountId(),
+              ctx.getRevWalk(),
+              ctx.getRepoView().getConfig())) {
         if (a.isLegacySubmit()) {
           continue;
         }
@@ -1238,6 +1369,8 @@
       }
       if (!msg.isEmpty()) {
         buf.append("\n\n").append(msg);
+      } else if (in.ready) {
+        buf.append("\n\n" + START_REVIEW_MESSAGE);
       }
       if (buf.length() == 0) {
         return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
index debd32a..61d6020 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
@@ -14,15 +14,16 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.extensions.client.ReviewerState.CC;
 import static com.google.gerrit.extensions.client.ReviewerState.REVIEWER;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.NoSuchGroupException;
@@ -32,36 +33,39 @@
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 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.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.extensions.events.ReviewerAdded;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.project.ChangeControl;
+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.permissions.RefPermission;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.query.change.ChangeData;
 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.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -69,85 +73,83 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.text.MessageFormat;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.HashSet;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @Singleton
-public class PostReviewers implements RestModifyView<ChangeResource, AddReviewerInput> {
-  private static final Logger log = LoggerFactory.getLogger(PostReviewers.class);
+public class PostReviewers
+    extends RetryingRestModifyView<ChangeResource, AddReviewerInput, AddReviewerResult> {
 
   public static final int DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK = 10;
   public static final int DEFAULT_MAX_REVIEWERS = 20;
 
   private final AccountsCollection accounts;
   private final ReviewerResource.Factory reviewerFactory;
-  private final ApprovalsUtil approvalsUtil;
-  private final PatchSetUtil psUtil;
-  private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final PermissionBackend permissionBackend;
+
   private final GroupsCollection groupsCollection;
   private final GroupMembers.Factory groupMembersFactory;
   private final AccountLoader.Factory accountLoaderFactory;
   private final Provider<ReviewDb> dbProvider;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final Provider<IdentifiedUser> user;
+  private final ChangeData.Factory changeDataFactory;
   private final IdentifiedUser.GenericFactory identifiedUserFactory;
   private final Config cfg;
   private final ReviewerJson json;
-  private final ReviewerAdded reviewerAdded;
   private final NotesMigration migration;
-  private final AccountCache accountCache;
   private final NotifyUtil notifyUtil;
+  private final ProjectCache projectCache;
+  private final Provider<AnonymousUser> anonymousProvider;
+  private final PostReviewersOp.Factory postReviewersOpFactory;
+  private final OutgoingEmailValidator validator;
 
   @Inject
   PostReviewers(
       AccountsCollection accounts,
       ReviewerResource.Factory reviewerFactory,
-      ApprovalsUtil approvalsUtil,
-      PatchSetUtil psUtil,
-      AddReviewerSender.Factory addReviewerSenderFactory,
+      PermissionBackend permissionBackend,
       GroupsCollection groupsCollection,
       GroupMembers.Factory groupMembersFactory,
       AccountLoader.Factory accountLoaderFactory,
       Provider<ReviewDb> db,
-      BatchUpdate.Factory batchUpdateFactory,
-      Provider<IdentifiedUser> user,
+      ChangeData.Factory changeDataFactory,
+      RetryHelper retryHelper,
       IdentifiedUser.GenericFactory identifiedUserFactory,
       @GerritServerConfig Config cfg,
       ReviewerJson json,
-      ReviewerAdded reviewerAdded,
       NotesMigration migration,
-      AccountCache accountCache,
-      NotifyUtil notifyUtil) {
+      NotifyUtil notifyUtil,
+      ProjectCache projectCache,
+      Provider<AnonymousUser> anonymousProvider,
+      PostReviewersOp.Factory postReviewersOpFactory,
+      OutgoingEmailValidator validator) {
+    super(retryHelper);
     this.accounts = accounts;
     this.reviewerFactory = reviewerFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.psUtil = psUtil;
-    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.permissionBackend = permissionBackend;
     this.groupsCollection = groupsCollection;
     this.groupMembersFactory = groupMembersFactory;
     this.accountLoaderFactory = accountLoaderFactory;
     this.dbProvider = db;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.user = user;
+    this.changeDataFactory = changeDataFactory;
     this.identifiedUserFactory = identifiedUserFactory;
     this.cfg = cfg;
     this.json = json;
-    this.reviewerAdded = reviewerAdded;
     this.migration = migration;
-    this.accountCache = accountCache;
     this.notifyUtil = notifyUtil;
+    this.projectCache = projectCache;
+    this.anonymousProvider = anonymousProvider;
+    this.postReviewersOpFactory = postReviewersOpFactory;
+    this.validator = validator;
   }
 
   @Override
-  public AddReviewerResult apply(ChangeResource rsrc, AddReviewerInput input)
-      throws IOException, OrmException, RestApiException, UpdateException {
+  protected AddReviewerResult applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AddReviewerInput input)
+      throws IOException, OrmException, RestApiException, UpdateException,
+          PermissionBackendException, ConfigInvalidException {
     if (input.reviewer == null) {
       throw new BadRequestException("missing reviewer field");
     }
@@ -157,7 +159,7 @@
       return addition.result;
     }
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
+        updateFactory.create(
             dbProvider.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Change.Id id = rsrc.getChange().getId();
       bu.addOp(id, addition.op);
@@ -169,98 +171,152 @@
 
   public Addition prepareApplication(
       ChangeResource rsrc, AddReviewerInput input, boolean allowGroup)
-      throws OrmException, RestApiException, IOException {
-    IdentifiedUser user = null;
-    boolean accountFound = true;
-    boolean isExactMatch = false;
+      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
+    String reviewer = input.reviewer;
+    ReviewerState state = input.state();
+    NotifyHandling notify = input.notify;
+    ListMultimap<RecipientType, Account.Id> accountsToNotify = null;
     try {
-      user = accounts.parse(input.reviewer);
-      if (input.reviewer.equalsIgnoreCase(user.getName())
-          || input.reviewer.equals(String.valueOf(user.getAccountId()))) {
-        isExactMatch = true;
+      accountsToNotify = notifyUtil.resolveAccounts(input.notifyDetails);
+    } catch (BadRequestException e) {
+      return fail(reviewer, e.getMessage());
+    }
+    boolean confirmed = input.confirmed();
+    boolean allowByEmail = projectCache.checkedGet(rsrc.getProject()).isEnableReviewerByEmail();
+
+    Addition byAccountId =
+        addByAccountId(reviewer, rsrc, state, notify, accountsToNotify, allowGroup, allowByEmail);
+
+    Addition wholeGroup = null;
+    if (byAccountId == null || !byAccountId.exactMatchFound) {
+      wholeGroup =
+          addWholeGroup(
+              reviewer, rsrc, state, notify, accountsToNotify, confirmed, allowGroup, allowByEmail);
+      if (wholeGroup != null && wholeGroup.exactMatchFound) {
+        return wholeGroup;
       }
-    } catch (UnprocessableEntityException e) {
-      accountFound = false;
     }
 
-    if (allowGroup && !isExactMatch) {
-      try {
-        return putGroup(rsrc, input);
-      } catch (UnprocessableEntityException e2) {
-        if (!accountFound) {
-          throw new UnprocessableEntityException(
-              MessageFormat.format(
-                  ChangeMessages.get().reviewerNotFoundUserOrGroup, input.reviewer));
-        }
-      }
+    if (byAccountId != null) {
+      return byAccountId;
     }
-    if (!accountFound) {
-      throw new UnprocessableEntityException(
-          MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, input.reviewer));
+    if (wholeGroup != null) {
+      return wholeGroup;
     }
-    return putAccount(
-        input.reviewer,
-        reviewerFactory.create(rsrc, user.getAccountId()),
-        input.state(),
-        input.notify,
-        notifyUtil.resolveAccounts(input.notifyDetails));
+
+    return addByEmail(reviewer, rsrc, state, notify, accountsToNotify);
   }
 
   Addition ccCurrentUser(CurrentUser user, RevisionResource revision) {
     return new Addition(
         user.getUserName(),
         revision.getChangeResource(),
-        ImmutableMap.of(user.getAccountId(), revision.getControl()),
+        ImmutableSet.of(user.getAccountId()),
+        null,
         CC,
         NotifyHandling.NONE,
-        ImmutableListMultimap.of());
+        ImmutableListMultimap.of(),
+        true);
   }
 
-  private Addition putAccount(
+  @Nullable
+  private Addition addByAccountId(
       String reviewer,
-      ReviewerResource rsrc,
+      ChangeResource rsrc,
       ReviewerState state,
       NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify)
-      throws UnprocessableEntityException {
-    Account member = rsrc.getReviewerUser().getAccount();
-    ChangeControl control = rsrc.getReviewerControl();
-    if (isValidReviewer(member, control)) {
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws OrmException, PermissionBackendException, IOException, ConfigInvalidException {
+    IdentifiedUser user = null;
+    boolean exactMatchFound = false;
+    try {
+      user = accounts.parse(reviewer);
+      if (reviewer.equalsIgnoreCase(user.getName())
+          || reviewer.equals(String.valueOf(user.getAccountId()))) {
+        exactMatchFound = true;
+      }
+    } catch (UnprocessableEntityException | AuthException e) {
+      // AuthException won't occur since the user is authenticated at this point.
+      if (!allowGroup && !allowByEmail) {
+        // Only return failure if we aren't going to try other interpretations.
+        return fail(
+            reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
+      }
+      return null;
+    }
+
+    ReviewerResource rrsrc = reviewerFactory.create(rsrc, user.getAccountId());
+    Account member = rrsrc.getReviewerUser().getAccount();
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(rrsrc.getReviewerUser()).ref(rrsrc.getChange().getDest());
+    if (isValidReviewer(member, perm)) {
       return new Addition(
           reviewer,
-          rsrc.getChangeResource(),
-          ImmutableMap.of(member.getId(), control),
+          rsrc,
+          ImmutableSet.of(member.getId()),
+          null,
           state,
           notify,
-          accountsToNotify);
+          accountsToNotify,
+          exactMatchFound);
     }
-    if (member.isActive()) {
-      throw new UnprocessableEntityException(String.format("Change not visible to %s", reviewer));
+    if (!member.isActive()) {
+      if (allowByEmail && state == CC) {
+        return null;
+      }
+      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInactive, reviewer));
     }
-    throw new UnprocessableEntityException(String.format("Account of %s is inactive.", reviewer));
+    return fail(
+        reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
   }
 
-  private Addition putGroup(ChangeResource rsrc, AddReviewerInput input)
-      throws RestApiException, OrmException, IOException {
-    GroupDescription.Basic group = groupsCollection.parseInternal(input.reviewer);
-    if (!isLegalReviewerGroup(group.getGroupUUID())) {
-      return fail(
-          input.reviewer,
-          MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+  @Nullable
+  private Addition addWholeGroup(
+      String reviewer,
+      ChangeResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify,
+      boolean confirmed,
+      boolean allowGroup,
+      boolean allowByEmail)
+      throws OrmException, IOException, PermissionBackendException {
+    if (!allowGroup) {
+      return null;
     }
 
-    Map<Account.Id, ChangeControl> reviewers = new HashMap<>();
-    ChangeControl control = rsrc.getControl();
+    GroupDescription.Basic group = null;
+    try {
+      group = groupsCollection.parseInternal(reviewer);
+    } catch (UnprocessableEntityException e) {
+      if (!allowByEmail) {
+        return fail(
+            reviewer,
+            MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, reviewer));
+      }
+      return null;
+    }
+
+    if (!isLegalReviewerGroup(group.getGroupUUID())) {
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().groupIsNotAllowed, group.getName()));
+    }
+
+    Set<Account.Id> reviewers = new HashSet<>();
     Set<Account> members;
     try {
       members =
           groupMembersFactory
-              .create(control.getUser())
-              .listAccounts(group.getGroupUUID(), control.getProject().getNameKey());
+              .create(rsrc.getUser())
+              .listAccounts(group.getGroupUUID(), rsrc.getProject());
     } catch (NoSuchGroupException e) {
-      throw new UnprocessableEntityException(e.getMessage());
+      return fail(
+          reviewer,
+          MessageFormat.format(ChangeMessages.get().reviewerNotFoundUserOrGroup, group.getName()));
     } catch (NoSuchProjectException e) {
-      throw new BadRequestException(e.getMessage());
+      return fail(reviewer, e.getMessage());
     }
 
     // if maxAllowed is set to 0, it is allowed to add any number of
@@ -268,44 +324,73 @@
     int maxAllowed = cfg.getInt("addreviewer", "maxAllowed", DEFAULT_MAX_REVIEWERS);
     if (maxAllowed > 0 && members.size() > maxAllowed) {
       return fail(
-          input.reviewer,
+          reviewer,
           MessageFormat.format(ChangeMessages.get().groupHasTooManyMembers, group.getName()));
     }
 
     // if maxWithoutCheck is set to 0, we never ask for confirmation
     int maxWithoutConfirmation =
         cfg.getInt("addreviewer", "maxWithoutConfirmation", DEFAULT_MAX_REVIEWERS_WITHOUT_CHECK);
-    if (!input.confirmed()
-        && maxWithoutConfirmation > 0
-        && members.size() > maxWithoutConfirmation) {
+    if (!confirmed && maxWithoutConfirmation > 0 && members.size() > maxWithoutConfirmation) {
       return fail(
-          input.reviewer,
+          reviewer,
           true,
           MessageFormat.format(
               ChangeMessages.get().groupManyMembersConfirmation, group.getName(), members.size()));
     }
 
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(rsrc.getUser()).ref(rsrc.getChange().getDest());
     for (Account member : members) {
-      if (isValidReviewer(member, control)) {
-        reviewers.put(member.getId(), control);
+      if (isValidReviewer(member, perm)) {
+        reviewers.add(member.getId());
       }
     }
 
-    return new Addition(
-        input.reviewer,
-        rsrc,
-        reviewers,
-        input.state(),
-        input.notify,
-        notifyUtil.resolveAccounts(input.notifyDetails));
+    return new Addition(reviewer, rsrc, reviewers, null, state, notify, accountsToNotify, true);
   }
 
-  private boolean isValidReviewer(Account member, ChangeControl control) {
+  @Nullable
+  private Addition addByEmail(
+      String reviewer,
+      ChangeResource rsrc,
+      ReviewerState state,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify)
+      throws PermissionBackendException {
+    if (!permissionBackend
+        .user(anonymousProvider)
+        .change(rsrc.getNotes())
+        .database(dbProvider)
+        .test(ChangePermission.READ)) {
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().reviewerCantSeeChange, reviewer));
+    }
+    if (!migration.readChanges()) {
+      // addByEmail depends on NoteDb.
+      return fail(
+          reviewer, MessageFormat.format(ChangeMessages.get().reviewerNotFoundUser, reviewer));
+    }
+    Address adr = Address.tryParse(reviewer);
+    if (adr == null || !validator.isValid(adr.getEmail())) {
+      return fail(reviewer, MessageFormat.format(ChangeMessages.get().reviewerInvalid, reviewer));
+    }
+    return new Addition(
+        reviewer, rsrc, null, ImmutableList.of(adr), state, notify, accountsToNotify, true);
+  }
+
+  private boolean isValidReviewer(Account member, PermissionBackend.ForRef perm)
+      throws PermissionBackendException {
     if (member.isActive()) {
       IdentifiedUser user = identifiedUserFactory.create(member.getId());
       // Does not account for draft status as a user might want to let a
       // reviewer see a draft.
-      return control.forUser(user).isRefVisible();
+      try {
+        perm.user(user).check(RefPermission.READ);
+        return true;
+      } catch (AuthException e) {
+        return false;
+      }
     }
     return false;
   }
@@ -323,177 +408,92 @@
 
   public class Addition {
     final AddReviewerResult result;
-    final Op op;
+    final PostReviewersOp op;
+    final Set<Account.Id> reviewers;
+    final Collection<Address> reviewersByEmail;
+    final ReviewerState state;
+    final ChangeNotes notes;
+    final IdentifiedUser caller;
+    final boolean exactMatchFound;
 
-    private final Map<Account.Id, ChangeControl> reviewers;
-
-    protected Addition(String reviewer) {
-      this(reviewer, null, null, REVIEWER, null, ImmutableListMultimap.of());
+    Addition(String reviewer) {
+      result = new AddReviewerResult(reviewer);
+      op = null;
+      reviewers = ImmutableSet.of();
+      reviewersByEmail = ImmutableSet.of();
+      state = REVIEWER;
+      notes = null;
+      caller = null;
+      exactMatchFound = false;
     }
 
     protected Addition(
         String reviewer,
         ChangeResource rsrc,
-        Map<Account.Id, ChangeControl> reviewers,
+        @Nullable Set<Account.Id> reviewers,
+        @Nullable Collection<Address> reviewersByEmail,
         ReviewerState state,
-        NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+        @Nullable NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify,
+        boolean exactMatchFound) {
+      checkArgument(
+          reviewers != null || reviewersByEmail != null,
+          "must have either reviewers or reviewersByEmail");
+
       result = new AddReviewerResult(reviewer);
-      if (reviewers == null) {
-        this.reviewers = ImmutableMap.of();
-        op = null;
-        return;
-      }
-      this.reviewers = reviewers;
-      op = new Op(rsrc, reviewers, state, notify, accountsToNotify);
+      this.reviewers = reviewers == null ? ImmutableSet.of() : reviewers;
+      this.reviewersByEmail = reviewersByEmail == null ? ImmutableList.of() : reviewersByEmail;
+      this.state = state;
+      notes = rsrc.getNotes();
+      caller = rsrc.getUser().asIdentifiedUser();
+      op =
+          postReviewersOpFactory.create(
+              this.reviewers, this.reviewersByEmail, state, notify, accountsToNotify);
+      this.exactMatchFound = exactMatchFound;
     }
 
-    void gatherResults() throws OrmException {
+    void gatherResults() throws OrmException, PermissionBackendException {
+      if (notes == null || caller == null) {
+        // When notes or caller is missing this is likely just carrying an error message
+        // in the contained AddReviewerResult.
+        return;
+      }
+
+      ChangeData cd = changeDataFactory.create(dbProvider.get(), notes);
+      PermissionBackend.ForChange perm =
+          permissionBackend.user(caller).database(dbProvider).change(cd);
+
       // Generate result details and fill AccountLoader. This occurs outside
       // the Op because the accounts are in a different table.
-      if (migration.readChanges() && op.state == CC) {
-        result.ccs = Lists.newArrayListWithCapacity(op.addedCCs.size());
-        for (Account.Id accountId : op.addedCCs) {
-          result.ccs.add(json.format(new ReviewerInfo(accountId.get()), reviewers.get(accountId)));
+      PostReviewersOp.Result opResult = op.getResult();
+      if (migration.readChanges() && state == CC) {
+        result.ccs = Lists.newArrayListWithCapacity(opResult.addedCCs().size());
+        for (Account.Id accountId : opResult.addedCCs()) {
+          IdentifiedUser u = identifiedUserFactory.create(accountId);
+          result.ccs.add(json.format(caller, new ReviewerInfo(accountId.get()), perm.user(u), cd));
         }
         accountLoaderFactory.create(true).fill(result.ccs);
+        for (Address a : reviewersByEmail) {
+          result.ccs.add(new AccountInfo(a.getName(), a.getEmail()));
+        }
       } else {
-        result.reviewers = Lists.newArrayListWithCapacity(op.addedReviewers.size());
-        for (PatchSetApproval psa : op.addedReviewers) {
+        result.reviewers = Lists.newArrayListWithCapacity(opResult.addedReviewers().size());
+        for (PatchSetApproval psa : opResult.addedReviewers()) {
           // New reviewers have value 0, don't bother normalizing.
+          IdentifiedUser u = identifiedUserFactory.create(psa.getAccountId());
           result.reviewers.add(
               json.format(
+                  caller,
                   new ReviewerInfo(psa.getAccountId().get()),
-                  reviewers.get(psa.getAccountId()),
+                  perm.user(u),
+                  cd,
                   ImmutableList.of(psa)));
         }
         accountLoaderFactory.create(true).fill(result.reviewers);
-      }
-    }
-  }
-
-  public class Op implements BatchUpdateOp {
-    final Map<Account.Id, ChangeControl> reviewers;
-    final ReviewerState state;
-    final NotifyHandling notify;
-    final ListMultimap<RecipientType, Account.Id> accountsToNotify;
-    List<PatchSetApproval> addedReviewers;
-    Collection<Account.Id> addedCCs;
-
-    private final ChangeResource rsrc;
-    private PatchSet patchSet;
-
-    Op(
-        ChangeResource rsrc,
-        Map<Account.Id, ChangeControl> reviewers,
-        ReviewerState state,
-        NotifyHandling notify,
-        ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-      this.rsrc = rsrc;
-      this.reviewers = reviewers;
-      this.state = state;
-      this.notify = notify;
-      this.accountsToNotify = checkNotNull(accountsToNotify);
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws RestApiException, OrmException, IOException {
-      if (migration.readChanges() && state == CC) {
-        addedCCs =
-            approvalsUtil.addCcs(
-                ctx.getNotes(),
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-                reviewers.keySet());
-        if (addedCCs.isEmpty()) {
-          return false;
-        }
-      } else {
-        addedReviewers =
-            approvalsUtil.addReviewers(
-                ctx.getDb(),
-                ctx.getNotes(),
-                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
-                rsrc.getControl().getLabelTypes(),
-                rsrc.getChange(),
-                reviewers.keySet());
-        if (addedReviewers.isEmpty()) {
-          return false;
+        for (Address a : reviewersByEmail) {
+          result.reviewers.add(ReviewerInfo.byEmail(a.getName(), a.getEmail()));
         }
       }
-
-      patchSet = psUtil.current(dbProvider.get(), rsrc.getNotes());
-      return true;
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws Exception {
-      if (addedReviewers != null || addedCCs != null) {
-        if (addedReviewers == null) {
-          addedReviewers = new ArrayList<>();
-        }
-        if (addedCCs == null) {
-          addedCCs = new ArrayList<>();
-        }
-        emailReviewers(
-            rsrc.getChange(),
-            Lists.transform(addedReviewers, r -> r.getAccountId()),
-            addedCCs,
-            notify,
-            accountsToNotify);
-        if (!addedReviewers.isEmpty()) {
-          List<Account> reviewers =
-              Lists.transform(
-                  addedReviewers, psa -> accountCache.get(psa.getAccountId()).getAccount());
-          reviewerAdded.fire(
-              rsrc.getChange(), patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
-        }
-      }
-    }
-  }
-
-  public void emailReviewers(
-      Change change,
-      Collection<Account.Id> added,
-      Collection<Account.Id> copied,
-      NotifyHandling notify,
-      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
-    if (added.isEmpty() && copied.isEmpty()) {
-      return;
-    }
-
-    // Email the reviewers
-    //
-    // The user knows they added themselves, don't bother emailing them.
-    List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
-    Account.Id userId = user.get().getAccountId();
-    for (Account.Id id : added) {
-      if (!id.equals(userId)) {
-        toMail.add(id);
-      }
-    }
-    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
-    for (Account.Id id : copied) {
-      if (!id.equals(userId)) {
-        toCopy.add(id);
-      }
-    }
-    if (toMail.isEmpty() && toCopy.isEmpty()) {
-      return;
-    }
-
-    try {
-      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
-      if (notify != null) {
-        cm.setNotify(notify);
-      }
-      cm.setAccountsToNotify(accountsToNotify);
-      cm.setFrom(userId);
-      cm.addReviewers(toMail);
-      cm.addExtraCC(toCopy);
-      cm.send();
-    } catch (Exception err) {
-      log.error("Cannot send email to new reviewers of change " + change.getId(), err);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java
new file mode 100644
index 0000000..1309194
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewersOp.java
@@ -0,0 +1,265 @@
+// 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.change;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.extensions.client.ReviewerState.CC;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.client.ReviewerState;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.extensions.events.ReviewerAdded;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.mail.send.AddReviewerSender;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class PostReviewersOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(PostReviewersOp.class);
+
+  public interface Factory {
+    PostReviewersOp create(
+        Set<Account.Id> reviewers,
+        Collection<Address> reviewersByEmail,
+        ReviewerState state,
+        @Nullable NotifyHandling notify,
+        ListMultimap<RecipientType, Account.Id> accountsToNotify);
+  }
+
+  @AutoValue
+  public abstract static class Result {
+    public abstract ImmutableList<PatchSetApproval> addedReviewers();
+
+    public abstract ImmutableList<Account.Id> addedCCs();
+
+    static Builder builder() {
+      return new AutoValue_PostReviewersOp_Result.Builder();
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setAddedReviewers(ImmutableList<PatchSetApproval> addedReviewers);
+
+      abstract Builder setAddedCCs(ImmutableList<Account.Id> addedCCs);
+
+      abstract Result build();
+    }
+  }
+
+  private final ApprovalsUtil approvalsUtil;
+  private final PatchSetUtil psUtil;
+  private final ReviewerAdded reviewerAdded;
+  private final AccountCache accountCache;
+  private final ProjectCache projectCache;
+  private final AddReviewerSender.Factory addReviewerSenderFactory;
+  private final NotesMigration migration;
+  private final Provider<IdentifiedUser> user;
+  private final Provider<ReviewDb> dbProvider;
+  private final Set<Account.Id> reviewers;
+  private final Collection<Address> reviewersByEmail;
+  private final ReviewerState state;
+  private final NotifyHandling notify;
+  private final ListMultimap<RecipientType, Account.Id> accountsToNotify;
+
+  private List<PatchSetApproval> addedReviewers = new ArrayList<>();
+  private Collection<Account.Id> addedCCs = new ArrayList<>();
+  private Collection<Address> addedCCsByEmail = new ArrayList<>();
+  private Change change;
+  private PatchSet patchSet;
+  private Result opResult;
+
+  @Inject
+  PostReviewersOp(
+      ApprovalsUtil approvalsUtil,
+      PatchSetUtil psUtil,
+      ReviewerAdded reviewerAdded,
+      AccountCache accountCache,
+      ProjectCache projectCache,
+      AddReviewerSender.Factory addReviewerSenderFactory,
+      NotesMigration migration,
+      Provider<IdentifiedUser> user,
+      Provider<ReviewDb> dbProvider,
+      @Assisted Set<Account.Id> reviewers,
+      @Assisted Collection<Address> reviewersByEmail,
+      @Assisted ReviewerState state,
+      @Assisted @Nullable NotifyHandling notify,
+      @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    this.approvalsUtil = approvalsUtil;
+    this.psUtil = psUtil;
+    this.reviewerAdded = reviewerAdded;
+    this.accountCache = accountCache;
+    this.projectCache = projectCache;
+    this.addReviewerSenderFactory = addReviewerSenderFactory;
+    this.migration = migration;
+    this.user = user;
+    this.dbProvider = dbProvider;
+
+    this.reviewers = reviewers;
+    this.reviewersByEmail = reviewersByEmail;
+    this.state = state;
+    this.notify = notify;
+    this.accountsToNotify = accountsToNotify;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException, IOException {
+    change = ctx.getChange();
+    if (!reviewers.isEmpty()) {
+      if (migration.readChanges() && state == CC) {
+        addedCCs =
+            approvalsUtil.addCcs(
+                ctx.getNotes(), ctx.getUpdate(change.currentPatchSetId()), reviewers);
+        if (addedCCs.isEmpty()) {
+          return false;
+        }
+      } else {
+        addedReviewers =
+            approvalsUtil.addReviewers(
+                ctx.getDb(),
+                ctx.getNotes(),
+                ctx.getUpdate(ctx.getChange().currentPatchSetId()),
+                projectCache
+                    .checkedGet(change.getProject())
+                    .getLabelTypes(change.getDest(), ctx.getUser()),
+                change,
+                reviewers);
+        if (addedReviewers.isEmpty()) {
+          return false;
+        }
+      }
+    }
+
+    for (Address a : reviewersByEmail) {
+      ctx.getUpdate(ctx.getChange().currentPatchSetId())
+          .putReviewerByEmail(a, ReviewerStateInternal.fromReviewerState(state));
+    }
+
+    patchSet = psUtil.current(dbProvider.get(), ctx.getNotes());
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws Exception {
+    opResult =
+        Result.builder()
+            .setAddedReviewers(ImmutableList.copyOf(addedReviewers))
+            .setAddedCCs(ImmutableList.copyOf(addedCCs))
+            .build();
+    emailReviewers(
+        change,
+        Lists.transform(addedReviewers, r -> r.getAccountId()),
+        addedCCs == null ? ImmutableList.of() : addedCCs,
+        reviewersByEmail,
+        addedCCsByEmail,
+        notify,
+        accountsToNotify);
+    if (!addedReviewers.isEmpty()) {
+      List<Account> reviewers =
+          addedReviewers
+              .stream()
+              .map(r -> accountCache.get(r.getAccountId()).getAccount())
+              .collect(toList());
+      reviewerAdded.fire(change, patchSet, reviewers, ctx.getAccount(), ctx.getWhen());
+    }
+  }
+
+  public void emailReviewers(
+      Change change,
+      Collection<Account.Id> added,
+      Collection<Account.Id> copied,
+      Collection<Address> addedByEmail,
+      Collection<Address> copiedByEmail,
+      NotifyHandling notify,
+      ListMultimap<RecipientType, Account.Id> accountsToNotify) {
+    if (added.isEmpty() && copied.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
+      return;
+    }
+
+    // Email the reviewers
+    //
+    // The user knows they added themselves, don't bother emailing them.
+    List<Account.Id> toMail = Lists.newArrayListWithCapacity(added.size());
+    Account.Id userId = user.get().getAccountId();
+    for (Account.Id id : added) {
+      if (!id.equals(userId)) {
+        toMail.add(id);
+      }
+    }
+    List<Account.Id> toCopy = Lists.newArrayListWithCapacity(copied.size());
+    for (Account.Id id : copied) {
+      if (!id.equals(userId)) {
+        toCopy.add(id);
+      }
+    }
+    if (toMail.isEmpty() && toCopy.isEmpty() && addedByEmail.isEmpty() && copiedByEmail.isEmpty()) {
+      return;
+    }
+
+    try {
+      AddReviewerSender cm = addReviewerSenderFactory.create(change.getProject(), change.getId());
+      // Default to silent operation on WIP changes.
+      NotifyHandling defaultNotifyHandling =
+          change.isWorkInProgress() ? NotifyHandling.NONE : NotifyHandling.ALL;
+      cm.setNotify(MoreObjects.firstNonNull(notify, defaultNotifyHandling));
+      cm.setAccountsToNotify(accountsToNotify);
+      cm.setFrom(userId);
+      cm.addReviewers(toMail);
+      cm.addReviewersByEmail(addedByEmail);
+      cm.addExtraCC(toCopy);
+      cm.addExtraCCByEmail(copiedByEmail);
+      cm.send();
+    } catch (Exception err) {
+      log.error("Cannot send email to new reviewers of change " + change.getId(), err);
+    }
+  }
+
+  public Result getResult() {
+    checkState(opResult != null, "Batch update wasn't executed yet");
+    return opResult;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
index f4356db..3c83f81 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
@@ -25,15 +25,18 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 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.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.LimitedByteArrayOutputStream.LimitExceededException;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.MergeOp;
 import com.google.gerrit.server.git.MergeOpRepoManager;
 import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -42,9 +45,11 @@
 import java.io.OutputStream;
 import java.util.Collection;
 import org.apache.commons.compress.archivers.ArchiveOutputStream;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.storage.pack.PackConfig;
 import org.eclipse.jgit.transport.BundleWriter;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.kohsuke.args4j.Option;
@@ -77,7 +82,9 @@
   }
 
   @Override
-  public BinaryResult apply(RevisionResource rsrc) throws OrmException, RestApiException {
+  public BinaryResult apply(RevisionResource rsrc)
+      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (Strings.isNullOrEmpty(format)) {
       throw new BadRequestException("format is not specified");
     }
@@ -94,10 +101,9 @@
 
     Change change = rsrc.getChange();
     if (!change.getStatus().isOpen()) {
-      throw new PreconditionFailedException("change is " + Submit.status(change));
+      throw new PreconditionFailedException("change is " + ChangeUtil.status(change));
     }
-    ChangeControl control = rsrc.getControl();
-    if (!control.getUser().isIdentifiedUser()) {
+    if (!rsrc.getUser().isIdentifiedUser()) {
       throw new MethodNotAllowedException("Anonymous users cannot submit");
     }
 
@@ -105,10 +111,10 @@
   }
 
   private BinaryResult getBundles(RevisionResource rsrc, ArchiveFormat f)
-      throws OrmException, RestApiException {
+      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     ReviewDb db = dbProvider.get();
-    ChangeControl control = rsrc.getControl();
-    IdentifiedUser caller = control.getUser().asIdentifiedUser();
+    IdentifiedUser caller = rsrc.getUser().asIdentifiedUser();
     Change change = rsrc.getChange();
 
     @SuppressWarnings("resource") // Returned BinaryResult takes ownership and handles closing.
@@ -120,7 +126,13 @@
           .setContentType(f.getMimeType())
           .setAttachmentName("submit-preview-" + change.getChangeId() + "." + format);
       return bin;
-    } catch (OrmException | RestApiException | RuntimeException e) {
+    } catch (OrmException
+        | RestApiException
+        | UpdateException
+        | IOException
+        | ConfigInvalidException
+        | RuntimeException
+        | PermissionBackendException e) {
       op.close();
       throw e;
     }
@@ -144,14 +156,16 @@
         MergeOpRepoManager orm = mergeOp.getMergeOpRepoManager();
         for (Project.NameKey p : mergeOp.getAllProjects()) {
           OpenRepo or = orm.getRepo(p);
-          BundleWriter bw = new BundleWriter(or.getRepo());
+          BundleWriter bw = new BundleWriter(or.getCodeReviewRevWalk().getObjectReader());
           bw.setObjectCountCallback(null);
-          bw.setPackConfig(null);
-          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates();
+          bw.setPackConfig(new PackConfig(or.getRepo()));
+          Collection<ReceiveCommand> refs = or.getUpdate().getRefUpdates().values();
           for (ReceiveCommand r : refs) {
             bw.include(r.getRefName(), r.getNewId());
             ObjectId oldId = r.getOldId();
-            if (!oldId.equals(ObjectId.zeroId())) {
+            if (!oldId.equals(ObjectId.zeroId())
+                // Probably the client doesn't already have NoteDb data.
+                && !RefNames.isNoteDbMetaRef(r.getRefName())) {
               bw.assume(or.getCodeReviewRevWalk().parseCommit(oldId));
             }
           }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
index 0e72979..c4e2f3b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishChangeEdit.java
@@ -14,27 +14,30 @@
 
 package com.google.gerrit.server.change;
 
-import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.api.changes.PublishChangeEditInput;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsPost;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class PublishChangeEdit
@@ -62,33 +65,38 @@
     throw new NotImplementedException();
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public Publish post(ChangeResource parent) throws RestApiException {
     return publish;
   }
 
   @Singleton
-  public static class Publish implements RestModifyView<ChangeResource, PublishChangeEditInput> {
+  public static class Publish
+      extends RetryingRestModifyView<ChangeResource, PublishChangeEditInput, Response<?>> {
 
     private final ChangeEditUtil editUtil;
     private final NotifyUtil notifyUtil;
+    private final ContributorAgreementsChecker contributorAgreementsChecker;
 
     @Inject
-    Publish(ChangeEditUtil editUtil, NotifyUtil notifyUtil) {
+    Publish(
+        RetryHelper retryHelper,
+        ChangeEditUtil editUtil,
+        NotifyUtil notifyUtil,
+        ContributorAgreementsChecker contributorAgreementsChecker) {
+      super(retryHelper);
       this.editUtil = editUtil;
       this.notifyUtil = notifyUtil;
+      this.contributorAgreementsChecker = contributorAgreementsChecker;
     }
 
     @Override
-    public Response<?> apply(ChangeResource rsrc, PublishChangeEditInput in)
-        throws IOException, OrmException, RestApiException, UpdateException {
-      Capable r = rsrc.getControl().getProjectControl().canPushToAtLeastOneRef();
-      if (r != Capable.OK) {
-        throw new AuthException(r.getMessage());
-      }
-
-      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getChange());
+    protected Response<?> applyImpl(
+        BatchUpdate.Factory updateFactory, ChangeResource rsrc, PublishChangeEditInput in)
+        throws IOException, OrmException, RestApiException, UpdateException, ConfigInvalidException,
+            NoSuchProjectException {
+      contributorAgreementsChecker.check(rsrc.getProject(), rsrc.getUser());
+      Optional<ChangeEdit> edit = editUtil.byChange(rsrc.getNotes(), rsrc.getUser());
       if (!edit.isPresent()) {
         throw new ResourceConflictException(
             String.format("no edit exists for change %s", rsrc.getChange().getChangeId()));
@@ -96,7 +104,13 @@
       if (in == null) {
         in = new PublishChangeEditInput();
       }
-      editUtil.publish(edit.get(), in.notify, notifyUtil.resolveAccounts(in.notifyDetails));
+      editUtil.publish(
+          updateFactory,
+          rsrc.getNotes(),
+          rsrc.getUser(),
+          edit.get(),
+          in.notify,
+          notifyUtil.resolveAccounts(in.notifyDetails));
       return Response.none();
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
deleted file mode 100644
index 4cbeaf63c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
+++ /dev/null
@@ -1,269 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.change;
-
-import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
-
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.webui.UiAction;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.change.PublishDraftPatchSet.Input;
-import com.google.gerrit.server.extensions.events.DraftPublished;
-import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.send.CreateChangeSender;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-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.Context;
-import com.google.gerrit.server.update.UpdateException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.List;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.revwalk.FooterLine;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class PublishDraftPatchSet
-    implements RestModifyView<RevisionResource, Input>, UiAction<RevisionResource> {
-  private static final Logger log = LoggerFactory.getLogger(PublishDraftPatchSet.class);
-
-  public static class Input {}
-
-  private final AccountResolver accountResolver;
-  private final ApprovalsUtil approvalsUtil;
-  private final BatchUpdate.Factory updateFactory;
-  private final CreateChangeSender.Factory createChangeSenderFactory;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final PatchSetUtil psUtil;
-  private final Provider<ReviewDb> dbProvider;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
-  private final DraftPublished draftPublished;
-
-  @Inject
-  public PublishDraftPatchSet(
-      AccountResolver accountResolver,
-      ApprovalsUtil approvalsUtil,
-      BatchUpdate.Factory updateFactory,
-      CreateChangeSender.Factory createChangeSenderFactory,
-      PatchSetInfoFactory patchSetInfoFactory,
-      PatchSetUtil psUtil,
-      Provider<ReviewDb> dbProvider,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
-      DraftPublished draftPublished) {
-    this.accountResolver = accountResolver;
-    this.approvalsUtil = approvalsUtil;
-    this.updateFactory = updateFactory;
-    this.createChangeSenderFactory = createChangeSenderFactory;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.psUtil = psUtil;
-    this.dbProvider = dbProvider;
-    this.replacePatchSetFactory = replacePatchSetFactory;
-    this.draftPublished = draftPublished;
-  }
-
-  @Override
-  public Response<?> apply(RevisionResource rsrc, Input input)
-      throws RestApiException, UpdateException {
-    return apply(rsrc.getUser(), rsrc.getChange(), rsrc.getPatchSet().getId(), rsrc.getPatchSet());
-  }
-
-  private Response<?> apply(CurrentUser u, Change c, PatchSet.Id psId, PatchSet ps)
-      throws RestApiException, UpdateException {
-    try (BatchUpdate bu =
-        updateFactory.create(dbProvider.get(), c.getProject(), u, TimeUtil.nowTs())) {
-      bu.addOp(c.getId(), new Op(psId, ps));
-      bu.execute();
-    }
-    return Response.none();
-  }
-
-  @Override
-  public UiAction.Description getDescription(RevisionResource rsrc) {
-    try {
-      return new UiAction.Description()
-          .setLabel("Publish")
-          .setTitle(String.format("Publish revision %d", rsrc.getPatchSet().getPatchSetId()))
-          .setVisible(
-              rsrc.getPatchSet().isDraft() && rsrc.getControl().canPublish(dbProvider.get()));
-    } catch (OrmException e) {
-      throw new IllegalStateException(e);
-    }
-  }
-
-  public static class CurrentRevision implements RestModifyView<ChangeResource, Input> {
-    private final PublishDraftPatchSet publish;
-
-    @Inject
-    CurrentRevision(PublishDraftPatchSet publish) {
-      this.publish = publish;
-    }
-
-    @Override
-    public Response<?> apply(ChangeResource rsrc, Input input)
-        throws RestApiException, UpdateException {
-      return publish.apply(
-          rsrc.getControl().getUser(),
-          rsrc.getChange(),
-          rsrc.getChange().currentPatchSetId(),
-          null);
-    }
-  }
-
-  private class Op implements BatchUpdateOp {
-    private final PatchSet.Id psId;
-
-    private PatchSet patchSet;
-    private Change change;
-    private boolean wasDraftChange;
-    private PatchSetInfo patchSetInfo;
-    private MailRecipients recipients;
-
-    private Op(PatchSet.Id psId, @Nullable PatchSet patchSet) {
-      this.psId = psId;
-      this.patchSet = patchSet;
-    }
-
-    @Override
-    public boolean updateChange(ChangeContext ctx)
-        throws RestApiException, OrmException, IOException {
-      if (!ctx.getControl().canPublish(ctx.getDb())) {
-        throw new AuthException("Cannot publish this draft patch set");
-      }
-      if (patchSet == null) {
-        patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-        if (patchSet == null) {
-          throw new ResourceNotFoundException(psId.toString());
-        }
-      }
-      saveChange(ctx);
-      savePatchSet(ctx);
-      addReviewers(ctx);
-      return true;
-    }
-
-    private void saveChange(ChangeContext ctx) {
-      change = ctx.getChange();
-      ChangeUpdate update = ctx.getUpdate(psId);
-      wasDraftChange = change.getStatus() == Change.Status.DRAFT;
-      if (wasDraftChange) {
-        change.setStatus(Change.Status.NEW);
-        update.setStatus(change.getStatus());
-      }
-    }
-
-    private void savePatchSet(ChangeContext ctx) throws RestApiException, OrmException {
-      if (!patchSet.isDraft()) {
-        throw new ResourceConflictException("Patch set is not a draft");
-      }
-      psUtil.publish(ctx.getDb(), ctx.getUpdate(psId), patchSet);
-    }
-
-    private void addReviewers(ChangeContext ctx) throws OrmException, IOException {
-      LabelTypes labelTypes = ctx.getControl().getLabelTypes();
-      Collection<Account.Id> oldReviewers =
-          approvalsUtil.getReviewers(ctx.getDb(), ctx.getNotes()).all();
-      RevCommit commit =
-          ctx.getRevWalk().parseCommit(ObjectId.fromString(patchSet.getRevision().get()));
-      patchSetInfo = patchSetInfoFactory.get(ctx.getRevWalk(), commit, psId);
-
-      List<FooterLine> footerLines = commit.getFooterLines();
-      recipients =
-          getRecipientsFromFooters(ctx.getDb(), accountResolver, patchSet.isDraft(), footerLines);
-      recipients.remove(ctx.getAccountId());
-      approvalsUtil.addReviewers(
-          ctx.getDb(),
-          ctx.getUpdate(psId),
-          labelTypes,
-          change,
-          patchSet,
-          patchSetInfo,
-          recipients.getReviewers(),
-          oldReviewers);
-    }
-
-    @Override
-    public void postUpdate(Context ctx) throws OrmException {
-      draftPublished.fire(change, patchSet, ctx.getAccount(), ctx.getWhen());
-      if (patchSet.isDraft() && change.getStatus() == Change.Status.DRAFT) {
-        // Skip emails if the patch set is still a draft.
-        return;
-      }
-      try {
-        if (wasDraftChange) {
-          sendCreateChange(ctx);
-        } else {
-          sendReplacePatchSet(ctx);
-        }
-      } catch (EmailException e) {
-        log.error("Cannot send email for publishing draft " + psId, e);
-      }
-    }
-
-    private void sendCreateChange(Context ctx) throws EmailException {
-      CreateChangeSender cm = createChangeSenderFactory.create(ctx.getProject(), change.getId());
-      cm.setFrom(ctx.getAccountId());
-      cm.setPatchSet(patchSet, patchSetInfo);
-      cm.addReviewers(recipients.getReviewers());
-      cm.addExtraCC(recipients.getCcOnly());
-      cm.send();
-    }
-
-    private void sendReplacePatchSet(Context ctx) throws EmailException {
-      ChangeMessage msg =
-          ChangeMessagesUtil.newMessage(
-              psId,
-              ctx.getUser(),
-              ctx.getWhen(),
-              "Uploaded patch set " + psId.get() + ".",
-              ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
-      ReplacePatchSetSender cm = replacePatchSetFactory.create(ctx.getProject(), change.getId());
-      cm.setFrom(ctx.getAccountId());
-      cm.setPatchSet(patchSet, patchSetInfo);
-      cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
-      cm.addReviewers(recipients.getReviewers());
-      cm.addExtraCC(recipients.getCcOnly());
-      cm.send();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
index e64abaa..d53c85c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
@@ -22,74 +23,91 @@
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.change.PostReviewers.Addition;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
-public class PutAssignee
-    implements RestModifyView<ChangeResource, AssigneeInput>, UiAction<ChangeResource> {
+public class PutAssignee extends RetryingRestModifyView<ChangeResource, AssigneeInput, AccountInfo>
+    implements UiAction<ChangeResource> {
 
+  private final AccountsCollection accounts;
   private final SetAssigneeOp.Factory assigneeFactory;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final Provider<ReviewDb> db;
   private final PostReviewers postReviewers;
   private final AccountLoader.Factory accountLoaderFactory;
 
   @Inject
   PutAssignee(
+      AccountsCollection accounts,
       SetAssigneeOp.Factory assigneeFactory,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       Provider<ReviewDb> db,
       PostReviewers postReviewers,
       AccountLoader.Factory accountLoaderFactory) {
+    super(retryHelper);
+    this.accounts = accounts;
     this.assigneeFactory = assigneeFactory;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.db = db;
     this.postReviewers = postReviewers;
     this.accountLoaderFactory = accountLoaderFactory;
   }
 
   @Override
-  public Response<AccountInfo> apply(ChangeResource rsrc, AssigneeInput input)
-      throws RestApiException, UpdateException, OrmException, IOException {
-    if (!rsrc.getControl().canEditAssignee()) {
-      throw new AuthException("Changing Assignee not permitted");
-    }
-    if (input.assignee == null || input.assignee.trim().isEmpty()) {
+  protected AccountInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, AssigneeInput input)
+      throws RestApiException, UpdateException, OrmException, IOException,
+          PermissionBackendException, ConfigInvalidException {
+    rsrc.permissions().check(ChangePermission.EDIT_ASSIGNEE);
+
+    input.assignee = Strings.nullToEmpty(input.assignee).trim();
+    if (input.assignee.isEmpty()) {
       throw new BadRequestException("missing assignee field");
     }
 
+    IdentifiedUser assignee = accounts.parse(input.assignee);
+    if (!assignee.getAccount().isActive()) {
+      throw new UnprocessableEntityException(input.assignee + " is not active");
+    }
+    try {
+      rsrc.permissions().database(db).user(assignee).check(ChangePermission.READ);
+    } catch (AuthException e) {
+      throw new AuthException("read not permitted for " + input.assignee);
+    }
+
     try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db.get(),
-            rsrc.getChange().getProject(),
-            rsrc.getControl().getUser(),
-            TimeUtil.nowTs())) {
-      SetAssigneeOp op = assigneeFactory.create(input.assignee);
+        updateFactory.create(
+            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      SetAssigneeOp op = assigneeFactory.create(assignee);
       bu.addOp(rsrc.getId(), op);
 
       PostReviewers.Addition reviewersAddition = addAssigneeAsCC(rsrc, input.assignee);
       bu.addOp(rsrc.getId(), reviewersAddition.op);
 
       bu.execute();
-      return Response.ok(accountLoaderFactory.create(true).fillOne(op.getNewAssignee()));
+      return accountLoaderFactory.create(true).fillOne(assignee.getAccountId());
     }
   }
 
   private Addition addAssigneeAsCC(ChangeResource rsrc, String assignee)
-      throws OrmException, RestApiException, IOException {
+      throws OrmException, IOException, PermissionBackendException, ConfigInvalidException {
     AddReviewerInput reviewerInput = new AddReviewerInput();
     reviewerInput.reviewer = assignee;
     reviewerInput.state = ReviewerState.CC;
@@ -99,9 +117,9 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Assignee")
-        .setVisible(resource.getControl().canEditAssignee());
+        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_ASSIGNEE));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
index 3c2633e..4c9cf23 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDescription.java
@@ -16,11 +16,9 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -28,10 +26,13 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 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.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,10 +42,10 @@
 
 @Singleton
 public class PutDescription
-    implements RestModifyView<RevisionResource, PutDescription.Input>, UiAction<RevisionResource> {
+    extends RetryingRestModifyView<RevisionResource, PutDescription.Input, Response<String>>
+    implements UiAction<RevisionResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final PatchSetUtil psUtil;
 
   public static class Input {
@@ -55,25 +56,24 @@
   PutDescription(
       Provider<ReviewDb> dbProvider,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       PatchSetUtil psUtil) {
+    super(retryHelper);
     this.dbProvider = dbProvider;
     this.cmUtil = cmUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.psUtil = psUtil;
   }
 
   @Override
-  public Response<String> apply(RevisionResource rsrc, Input input)
-      throws UpdateException, RestApiException {
-    ChangeControl ctl = rsrc.getControl();
-    if (!ctl.canEditDescription()) {
-      throw new AuthException("changing description not permitted");
-    }
+  protected Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, Input input)
+      throws UpdateException, RestApiException, PermissionBackendException {
+    rsrc.permissions().check(ChangePermission.EDIT_DESCRIPTION);
+
     Op op = new Op(input != null ? input : new Input(), rsrc.getPatchSet().getId());
     try (BatchUpdate u =
-        batchUpdateFactory.create(
-            dbProvider.get(), rsrc.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(
+            dbProvider.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       u.addOp(rsrc.getChange().getId(), op);
       u.execute();
     }
@@ -129,6 +129,6 @@
   public UiAction.Description getDescription(RevisionResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Description")
-        .setVisible(rsrc.getControl().canEditDescription());
+        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_DESCRIPTION));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
index b289da8..ca170df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
@@ -23,7 +23,6 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
@@ -33,9 +32,12 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 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.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -46,13 +48,13 @@
 import java.util.Optional;
 
 @Singleton
-public class PutDraftComment implements RestModifyView<DraftCommentResource, DraftInput> {
+public class PutDraftComment
+    extends RetryingRestModifyView<DraftCommentResource, DraftInput, Response<CommentInfo>> {
 
   private final Provider<ReviewDb> db;
   private final DeleteDraftComment delete;
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
-  private final BatchUpdate.Factory updateFactory;
   private final Provider<CommentJson> commentJson;
   private final PatchListCache patchListCache;
 
@@ -62,23 +64,24 @@
       DeleteDraftComment delete,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       Provider<CommentJson> commentJson,
       PatchListCache patchListCache) {
+    super(retryHelper);
     this.db = db;
     this.delete = delete;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
-    this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.patchListCache = patchListCache;
   }
 
   @Override
-  public Response<CommentInfo> apply(DraftCommentResource rsrc, DraftInput in)
+  protected Response<CommentInfo> applyImpl(
+      BatchUpdate.Factory updateFactory, DraftCommentResource rsrc, DraftInput in)
       throws RestApiException, UpdateException, OrmException {
     if (in == null || in.message == null || in.message.trim().isEmpty()) {
-      return delete.apply(rsrc, null);
+      return delete.applyImpl(updateFactory, rsrc, null);
     } else if (in.id != null && !rsrc.getId().equals(in.id)) {
       throw new BadRequestException("id must match URL");
     } else if (in.line != null && in.line < 0) {
@@ -89,10 +92,7 @@
 
     try (BatchUpdate bu =
         updateFactory.create(
-            db.get(),
-            rsrc.getChange().getProject(),
-            rsrc.getControl().getUser(),
-            TimeUtil.nowTs())) {
+            db.get(), rsrc.getChange().getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
       Op op = new Op(rsrc.getComment().key, in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
@@ -113,8 +113,10 @@
     }
 
     @Override
-    public boolean updateChange(ChangeContext ctx) throws ResourceNotFoundException, OrmException {
-      Optional<Comment> maybeComment = commentsUtil.get(ctx.getDb(), ctx.getNotes(), key);
+    public boolean updateChange(ChangeContext ctx)
+        throws ResourceNotFoundException, OrmException, PatchListNotAvailableException {
+      Optional<Comment> maybeComment =
+          commentsUtil.getDraft(ctx.getDb(), ctx.getNotes(), ctx.getIdentifiedUser(), key);
       if (!maybeComment.isPresent()) {
         // Disappeared out from under us. Can't easily fall back to insert,
         // because the input might be missing required fields. Just give up.
@@ -146,7 +148,7 @@
           update,
           Status.DRAFT,
           Collections.singleton(update(comment, in, ctx.getWhen())));
-      ctx.bumpLastUpdatedOn(false);
+      ctx.dontBumpLastUpdatedOn();
       return true;
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutMessage.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutMessage.java
new file mode 100644
index 0000000..a577004
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutMessage.java
@@ -0,0 +1,214 @@
+// 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.change;
+
+import com.google.gerrit.common.FooterConstants;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.CommitMessageInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.edit.UnchangedCommitMessageException;
+import com.google.gerrit.server.git.GitRepositoryManager;
+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.ProjectCache;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.CommitMessageUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.List;
+import java.util.TimeZone;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class PutMessage
+    extends RetryingRestModifyView<ChangeResource, CommitMessageInput, Response<?>> {
+
+  private final GitRepositoryManager repositoryManager;
+  private final Provider<CurrentUser> currentUserProvider;
+  private final Provider<ReviewDb> db;
+  private final TimeZone tz;
+  private final PatchSetInserter.Factory psInserterFactory;
+  private final PermissionBackend permissionBackend;
+  private final PatchSetUtil psUtil;
+  private final NotifyUtil notifyUtil;
+  private final ProjectCache projectCache;
+
+  @Inject
+  PutMessage(
+      RetryHelper retryHelper,
+      GitRepositoryManager repositoryManager,
+      Provider<CurrentUser> currentUserProvider,
+      Provider<ReviewDb> db,
+      PatchSetInserter.Factory psInserterFactory,
+      PermissionBackend permissionBackend,
+      @GerritPersonIdent PersonIdent gerritIdent,
+      PatchSetUtil psUtil,
+      NotifyUtil notifyUtil,
+      ProjectCache projectCache) {
+    super(retryHelper);
+    this.repositoryManager = repositoryManager;
+    this.currentUserProvider = currentUserProvider;
+    this.db = db;
+    this.psInserterFactory = psInserterFactory;
+    this.tz = gerritIdent.getTimeZone();
+    this.permissionBackend = permissionBackend;
+    this.psUtil = psUtil;
+    this.notifyUtil = notifyUtil;
+    this.projectCache = projectCache;
+  }
+
+  @Override
+  protected Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource resource, CommitMessageInput input)
+      throws IOException, UnchangedCommitMessageException, RestApiException, UpdateException,
+          PermissionBackendException, OrmException, ConfigInvalidException {
+    PatchSet ps = psUtil.current(db.get(), resource.getNotes());
+    if (ps == null) {
+      throw new ResourceConflictException("current revision is missing");
+    }
+
+    if (input == null) {
+      throw new BadRequestException("input cannot be null");
+    }
+    String sanitizedCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(input.message);
+
+    ensureCanEditCommitMessage(resource.getNotes());
+    ensureChangeIdIsCorrect(
+        projectCache.checkedGet(resource.getProject()).isRequireChangeID(),
+        resource.getChange().getKey().get(),
+        sanitizedCommitMessage);
+
+    NotifyHandling notify = input.notify;
+    if (notify == null) {
+      notify = resource.getChange().isWorkInProgress() ? NotifyHandling.OWNER : NotifyHandling.ALL;
+    }
+
+    try (Repository repository = repositoryManager.openRepository(resource.getProject());
+        RevWalk revWalk = new RevWalk(repository);
+        ObjectInserter objectInserter = repository.newObjectInserter()) {
+      RevCommit patchSetCommit = revWalk.parseCommit(ObjectId.fromString(ps.getRevision().get()));
+
+      String currentCommitMessage = patchSetCommit.getFullMessage();
+      if (input.message.equals(currentCommitMessage)) {
+        throw new ResourceConflictException("new and existing commit message are the same");
+      }
+
+      Timestamp ts = TimeUtil.nowTs();
+      try (BatchUpdate bu =
+          updateFactory.create(
+              db.get(), resource.getChange().getProject(), currentUserProvider.get(), ts)) {
+        // Ensure that BatchUpdate will update the same repo
+        bu.setRepository(repository, new RevWalk(objectInserter.newReader()), objectInserter);
+
+        PatchSet.Id psId = ChangeUtil.nextPatchSetId(repository, ps.getId());
+        ObjectId newCommit =
+            createCommit(objectInserter, patchSetCommit, sanitizedCommitMessage, ts);
+        PatchSetInserter inserter = psInserterFactory.create(resource.getNotes(), psId, newCommit);
+        inserter.setMessage(
+            String.format("Patch Set %s: Commit message was updated.", psId.getId()));
+        inserter.setDescription("Edit commit message");
+        inserter.setNotify(notify);
+        inserter.setAccountsToNotify(notifyUtil.resolveAccounts(input.notifyDetails));
+        bu.addOp(resource.getChange().getId(), inserter);
+        bu.execute();
+      }
+    }
+    return Response.ok("ok");
+  }
+
+  private ObjectId createCommit(
+      ObjectInserter objectInserter,
+      RevCommit basePatchSetCommit,
+      String commitMessage,
+      Timestamp timestamp)
+      throws IOException {
+    CommitBuilder builder = new CommitBuilder();
+    builder.setTreeId(basePatchSetCommit.getTree());
+    builder.setParentIds(basePatchSetCommit.getParents());
+    builder.setAuthor(basePatchSetCommit.getAuthorIdent());
+    builder.setCommitter(
+        currentUserProvider.get().asIdentifiedUser().newCommitterIdent(timestamp, tz));
+    builder.setMessage(commitMessage);
+    ObjectId newCommitId = objectInserter.insert(builder);
+    objectInserter.flush();
+    return newCommitId;
+  }
+
+  private void ensureCanEditCommitMessage(ChangeNotes changeNotes)
+      throws AuthException, PermissionBackendException {
+    if (!currentUserProvider.get().isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    try {
+      permissionBackend
+          .user(currentUserProvider.get())
+          .database(db.get())
+          .change(changeNotes)
+          .check(ChangePermission.ADD_PATCH_SET);
+    } catch (AuthException denied) {
+      throw new AuthException("modifying commit message not permitted", denied);
+    }
+  }
+
+  private static void ensureChangeIdIsCorrect(
+      boolean requireChangeId, String currentChangeId, String newCommitMessage)
+      throws ResourceConflictException, BadRequestException {
+    RevCommit revCommit =
+        RevCommit.parse(
+            Constants.encode("tree " + ObjectId.zeroId().name() + "\n\n" + newCommitMessage));
+
+    // Check that the commit message without footers is not empty
+    CommitMessageUtil.checkAndSanitizeCommitMessage(revCommit.getShortMessage());
+
+    List<String> changeIdFooters = revCommit.getFooterLines(FooterConstants.CHANGE_ID);
+    if (requireChangeId && changeIdFooters.isEmpty()) {
+      throw new ResourceConflictException("missing Change-Id footer");
+    }
+    if (!changeIdFooters.isEmpty() && !changeIdFooters.get(0).equals(currentChangeId)) {
+      throw new ResourceConflictException("wrong Change-Id footer");
+    }
+    if (changeIdFooters.size() > 1) {
+      throw new ResourceConflictException("multiple Change-Id footers");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
index 783ab9d..8b5608b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
@@ -16,24 +16,27 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.PutTopic.Input;
 import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 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.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -41,10 +44,10 @@
 import com.google.inject.Singleton;
 
 @Singleton
-public class PutTopic implements RestModifyView<ChangeResource, Input>, UiAction<ChangeResource> {
+public class PutTopic extends RetryingRestModifyView<ChangeResource, Input, Response<String>>
+    implements UiAction<ChangeResource> {
   private final Provider<ReviewDb> dbProvider;
   private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final TopicEdited topicEdited;
 
   public static class Input {
@@ -55,32 +58,35 @@
   PutTopic(
       Provider<ReviewDb> dbProvider,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       TopicEdited topicEdited) {
+    super(retryHelper);
     this.dbProvider = dbProvider;
     this.cmUtil = cmUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.topicEdited = topicEdited;
   }
 
   @Override
-  public Response<String> apply(ChangeResource req, Input input)
-      throws UpdateException, RestApiException {
-    ChangeControl ctl = req.getControl();
-    if (!ctl.canEditTopicName()) {
-      throw new AuthException("changing topic not permitted");
+  protected Response<String> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, Input input)
+      throws UpdateException, RestApiException, PermissionBackendException {
+    req.permissions().check(ChangePermission.EDIT_TOPIC_NAME);
+
+    if (input != null
+        && input.topic != null
+        && input.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+      throw new BadRequestException(
+          String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
     }
 
     Op op = new Op(input != null ? input : new Input());
     try (BatchUpdate u =
-        batchUpdateFactory.create(
-            dbProvider.get(), req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(
+            dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op);
       u.execute();
     }
-    return Strings.isNullOrEmpty(op.newTopicName)
-        ? Response.<String>none()
-        : Response.ok(op.newTopicName);
+    return Strings.isNullOrEmpty(op.newTopicName) ? Response.none() : Response.ok(op.newTopicName);
   }
 
   private class Op implements BatchUpdateOp {
@@ -129,9 +135,9 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Edit Topic")
-        .setVisible(resource.getControl().canEditTopicName());
+        .setVisible(rsrc.permissions().testCond(ChangePermission.EDIT_TOPIC_NAME));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
index 93e1e4e..59ab190 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebase.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.TimeUtil;
@@ -21,6 +23,7 @@
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.client.ListChangesOption;
 import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -31,13 +34,17 @@
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.project.ChangeControl;
+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.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -46,6 +53,8 @@
 import java.io.IOException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -53,51 +62,54 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Rebase
+public class Rebase extends RetryingRestModifyView<RevisionResource, RebaseInput, ChangeInfo>
     implements RestModifyView<RevisionResource, RebaseInput>, UiAction<RevisionResource> {
   private static final Logger log = LoggerFactory.getLogger(Rebase.class);
   private static final ImmutableSet<ListChangesOption> OPTIONS =
       Sets.immutableEnumSet(ListChangesOption.CURRENT_REVISION, ListChangesOption.CURRENT_COMMIT);
 
-  private final BatchUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
   private final RebaseChangeOp.Factory rebaseFactory;
   private final RebaseUtil rebaseUtil;
   private final ChangeJson.Factory json;
   private final Provider<ReviewDb> dbProvider;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   public Rebase(
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       GitRepositoryManager repoManager,
       RebaseChangeOp.Factory rebaseFactory,
       RebaseUtil rebaseUtil,
       ChangeJson.Factory json,
-      Provider<ReviewDb> dbProvider) {
-    this.updateFactory = updateFactory;
+      Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend) {
+    super(retryHelper);
     this.repoManager = repoManager;
     this.rebaseFactory = rebaseFactory;
     this.rebaseUtil = rebaseUtil;
     this.json = json;
     this.dbProvider = dbProvider;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  public ChangeInfo apply(RevisionResource rsrc, RebaseInput input)
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, RevisionResource rsrc, RebaseInput input)
       throws EmailException, OrmException, UpdateException, RestApiException, IOException,
-          NoSuchChangeException {
-    ChangeControl control = rsrc.getControl();
+          NoSuchChangeException, PermissionBackendException {
+    rsrc.permissions().database(dbProvider).check(ChangePermission.REBASE);
+
     Change change = rsrc.getChange();
     try (Repository repo = repoManager.openRepository(change.getProject());
-        RevWalk rw = new RevWalk(repo);
         ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader);
         BatchUpdate bu =
             updateFactory.create(
                 dbProvider.get(), change.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
-      if (!control.canRebase(dbProvider.get())) {
-        throw new AuthException("rebase not permitted");
-      } else if (!change.getStatus().isOpen()) {
-        throw new ResourceConflictException("change is " + change.getStatus().name().toLowerCase());
+      if (!change.getStatus().isOpen()) {
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
       } else if (!hasOneParent(rw, rsrc.getPatchSet())) {
         throw new ResourceConflictException(
             "cannot rebase merge commits or commit with no ancestor");
@@ -106,43 +118,51 @@
       bu.addOp(
           change.getId(),
           rebaseFactory
-              .create(control, rsrc.getPatchSet(), findBaseRev(rw, rsrc, input))
+              .create(rsrc.getNotes(), rsrc.getPatchSet(), findBaseRev(repo, rw, rsrc, input))
               .setForceContentMerge(true)
-              .setFireRevisionCreated(true)
-              .setValidatePolicy(CommitValidators.Policy.GERRIT));
+              .setFireRevisionCreated(true));
       bu.execute();
     }
     return json.create(OPTIONS).format(change.getProject(), change.getId());
   }
 
-  private String findBaseRev(RevWalk rw, RevisionResource rsrc, RebaseInput input)
-      throws AuthException, ResourceConflictException, OrmException, IOException,
-          NoSuchChangeException {
+  private ObjectId findBaseRev(
+      Repository repo, RevWalk rw, RevisionResource rsrc, RebaseInput input)
+      throws RestApiException, OrmException, IOException, NoSuchChangeException, AuthException,
+          PermissionBackendException {
+    Branch.NameKey destRefKey = rsrc.getChange().getDest();
     if (input == null || input.base == null) {
-      return null;
+      return rebaseUtil.findBaseRevision(rsrc.getPatchSet(), destRefKey, repo, rw);
     }
 
     Change change = rsrc.getChange();
     String str = input.base.trim();
     if (str.equals("")) {
-      // remove existing dependency to other patch set
-      return change.getDest().get();
+      // Remove existing dependency to other patch set.
+      Ref destRef = repo.exactRef(destRefKey.get());
+      if (destRef == null) {
+        throw new ResourceConflictException(
+            "can't rebase onto tip of branch " + destRefKey.get() + "; branch doesn't exist");
+      }
+      return destRef.getObjectId();
     }
 
-    @SuppressWarnings("resource")
-    ReviewDb db = dbProvider.get();
     Base base = rebaseUtil.parseBase(rsrc, str);
     if (base == null) {
       throw new ResourceConflictException("base revision is missing: " + str);
     }
     PatchSet.Id baseId = base.patchSet().getId();
-    if (!base.control().isPatchVisible(base.patchSet(), db)) {
-      throw new AuthException("base revision not accessible: " + str);
-    } else if (change.getId().equals(baseId.getParentKey())) {
+    if (change.getId().equals(baseId.getParentKey())) {
       throw new ResourceConflictException("cannot rebase change onto itself");
     }
 
-    Change baseChange = base.control().getChange();
+    permissionBackend
+        .user(rsrc.getUser())
+        .database(dbProvider)
+        .change(base.notes())
+        .check(ChangePermission.READ);
+
+    Change baseChange = base.notes().getChange();
     if (!baseChange.getProject().equals(change.getProject())) {
       throw new ResourceConflictException(
           "base change is in wrong project: " + baseChange.getProject());
@@ -157,7 +177,7 @@
               + baseChange.getKey()
               + " is a descendant of the current change - recursion not allowed");
     }
-    return base.patchSet().getRevision().get();
+    return ObjectId.fromString(base.patchSet().getRevision().get());
   }
 
   private boolean isMergedInto(RevWalk rw, PatchSet base, PatchSet tip) throws IOException {
@@ -175,56 +195,55 @@
   @Override
   public UiAction.Description getDescription(RevisionResource resource) {
     PatchSet patchSet = resource.getPatchSet();
-    Branch.NameKey dest = resource.getChange().getDest();
-    boolean canRebase = false;
-    try {
-      canRebase = resource.getControl().canRebase(dbProvider.get());
-    } catch (OrmException e) {
-      log.error("Cannot check canRebase status. Assuming false.", e);
-    }
-    boolean visible =
-        resource.getChange().getStatus().isOpen() && resource.isCurrent() && canRebase;
-    boolean enabled = true;
+    Change change = resource.getChange();
+    Branch.NameKey dest = change.getDest();
+    boolean visible = change.getStatus().isOpen() && resource.isCurrent();
+    boolean enabled = false;
 
     if (visible) {
       try (Repository repo = repoManager.openRepository(dest.getParentKey());
           RevWalk rw = new RevWalk(repo)) {
         visible = hasOneParent(rw, resource.getPatchSet());
-        enabled = rebaseUtil.canRebase(patchSet, dest, repo, rw);
+        if (visible) {
+          enabled = rebaseUtil.canRebase(patchSet, dest, repo, rw);
+        }
       } catch (IOException e) {
         log.error("Failed to check if patch set can be rebased: " + resource.getPatchSet(), e);
         visible = false;
       }
     }
-    UiAction.Description descr =
-        new UiAction.Description()
-            .setLabel("Rebase")
-            .setTitle("Rebase onto tip of branch or parent change")
-            .setVisible(visible)
-            .setEnabled(enabled);
-    return descr;
+
+    BooleanCondition permissionCond =
+        resource.permissions().database(dbProvider).testCond(ChangePermission.REBASE);
+    return new UiAction.Description()
+        .setLabel("Rebase")
+        .setTitle("Rebase onto tip of branch or parent change")
+        .setVisible(and(visible, permissionCond))
+        .setEnabled(and(enabled, permissionCond));
   }
 
-  public static class CurrentRevision implements RestModifyView<ChangeResource, RebaseInput> {
+  public static class CurrentRevision
+      extends RetryingRestModifyView<ChangeResource, RebaseInput, ChangeInfo> {
     private final PatchSetUtil psUtil;
     private final Rebase rebase;
 
     @Inject
-    CurrentRevision(PatchSetUtil psUtil, Rebase rebase) {
+    CurrentRevision(RetryHelper retryHelper, PatchSetUtil psUtil, Rebase rebase) {
+      super(retryHelper);
       this.psUtil = psUtil;
       this.rebase = rebase;
     }
 
     @Override
-    public ChangeInfo apply(ChangeResource rsrc, RebaseInput input)
-        throws EmailException, OrmException, UpdateException, RestApiException, IOException {
+    protected ChangeInfo applyImpl(
+        BatchUpdate.Factory updateFactory, ChangeResource rsrc, RebaseInput input)
+        throws EmailException, OrmException, UpdateException, RestApiException, IOException,
+            PermissionBackendException {
       PatchSet ps = psUtil.current(rebase.dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
-      } else if (!rsrc.getControl().isPatchVisible(ps, rebase.dbProvider.get())) {
-        throw new AuthException("current revision not accessible");
       }
-      return rebase.apply(new RevisionResource(rsrc, ps), input);
+      return rebase.applyImpl(updateFactory, new RevisionResource(rsrc, ps), input);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
index 7b673dd..38a695a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeEdit.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.edit.ChangeEditModifier;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -61,7 +62,6 @@
     throw new NotImplementedException();
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public Rebase post(ChangeResource parent) throws RestApiException {
     return rebase;
@@ -82,10 +82,11 @@
 
     @Override
     public Response<?> apply(ChangeResource rsrc, Rebase.Input in)
-        throws AuthException, ResourceConflictException, IOException, OrmException {
+        throws AuthException, ResourceConflictException, IOException, OrmException,
+            PermissionBackendException {
       Project.NameKey project = rsrc.getProject();
       try (Repository repository = repositoryManager.openRepository(project)) {
-        editModifier.rebaseEdit(repository, rsrc.getControl());
+        editModifier.rebaseEdit(repository, rsrc.getNotes());
       } catch (InvalidChangeOperationException e) {
         throw new ResourceConflictException(e.getMessage());
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
index c03bb6f..909ea3a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseChangeOp.java
@@ -16,7 +16,6 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -24,20 +23,23 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.RebaseUtil.Base;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.update.RepoContext;
 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.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
@@ -48,8 +50,7 @@
 
 public class RebaseChangeOp implements BatchUpdateOp {
   public interface Factory {
-    RebaseChangeOp create(
-        ChangeControl ctl, PatchSet originalPatchSet, @Nullable String baseCommitish);
+    RebaseChangeOp create(ChangeNotes notes, PatchSet originalPatchSet, ObjectId baseCommitId);
   }
 
   private final PatchSetInserter.Factory patchSetInserterFactory;
@@ -57,40 +58,47 @@
   private final RebaseUtil rebaseUtil;
   private final ChangeResource.Factory changeResourceFactory;
 
-  private final ChangeControl ctl;
+  private final ChangeNotes notes;
   private final PatchSet originalPatchSet;
+  private final IdentifiedUser.GenericFactory identifiedUserFactory;
+  private final ProjectCache projectCache;
 
-  private String baseCommitish;
+  private ObjectId baseCommitId;
   private PersonIdent committerIdent;
   private boolean fireRevisionCreated = true;
-  private CommitValidators.Policy validate;
+  private boolean validate = true;
   private boolean checkAddPatchSetPermission = true;
   private boolean forceContentMerge;
   private boolean copyApprovals = true;
   private boolean detailedCommitMessage;
   private boolean postMessage = true;
+  private boolean matchAuthorToCommitterDate = false;
 
   private RevCommit rebasedCommit;
   private PatchSet.Id rebasedPatchSetId;
   private PatchSetInserter patchSetInserter;
   private PatchSet rebasedPatchSet;
 
-  @AssistedInject
+  @Inject
   RebaseChangeOp(
       PatchSetInserter.Factory patchSetInserterFactory,
       MergeUtil.Factory mergeUtilFactory,
       RebaseUtil rebaseUtil,
       ChangeResource.Factory changeResourceFactory,
-      @Assisted ChangeControl ctl,
+      IdentifiedUser.GenericFactory identifiedUserFactory,
+      ProjectCache projectCache,
+      @Assisted ChangeNotes notes,
       @Assisted PatchSet originalPatchSet,
-      @Assisted @Nullable String baseCommitish) {
+      @Assisted ObjectId baseCommitId) {
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.mergeUtilFactory = mergeUtilFactory;
     this.rebaseUtil = rebaseUtil;
     this.changeResourceFactory = changeResourceFactory;
-    this.ctl = ctl;
+    this.identifiedUserFactory = identifiedUserFactory;
+    this.projectCache = projectCache;
+    this.notes = notes;
     this.originalPatchSet = originalPatchSet;
-    this.baseCommitish = baseCommitish;
+    this.baseCommitId = baseCommitId;
   }
 
   public RebaseChangeOp setCommitterIdent(PersonIdent committerIdent) {
@@ -98,7 +106,7 @@
     return this;
   }
 
-  public RebaseChangeOp setValidatePolicy(CommitValidators.Policy validate) {
+  public RebaseChangeOp setValidate(boolean validate) {
     this.validate = validate;
     return this;
   }
@@ -133,10 +141,15 @@
     return this;
   }
 
+  public RebaseChangeOp setMatchAuthorToCommitterDate(boolean matchAuthorToCommitterDate) {
+    this.matchAuthorToCommitterDate = matchAuthorToCommitterDate;
+    return this;
+  }
+
   @Override
   public void updateRepo(RepoContext ctx)
       throws MergeConflictException, InvalidChangeOperationException, RestApiException, IOException,
-          OrmException, NoSuchChangeException {
+          OrmException, NoSuchChangeException, PermissionBackendException {
     // Ok that originalPatchSet was not read in a transaction, since we just
     // need its revision.
     RevId oldRev = originalPatchSet.getRevision();
@@ -144,50 +157,40 @@
     RevWalk rw = ctx.getRevWalk();
     RevCommit original = rw.parseCommit(ObjectId.fromString(oldRev.get()));
     rw.parseBody(original);
-
-    RevCommit baseCommit;
-    if (baseCommitish != null) {
-      baseCommit = rw.parseCommit(ctx.getRepository().resolve(baseCommitish));
-    } else {
-      baseCommit =
-          rw.parseCommit(
-              rebaseUtil.findBaseRevision(
-                  originalPatchSet,
-                  ctl.getChange().getDest(),
-                  ctx.getRepository(),
-                  ctx.getRevWalk()));
-    }
+    RevCommit baseCommit = rw.parseCommit(baseCommitId);
+    CurrentUser changeOwner = identifiedUserFactory.create(notes.getChange().getOwner());
 
     String newCommitMessage;
     if (detailedCommitMessage) {
       rw.parseBody(baseCommit);
       newCommitMessage =
           newMergeUtil()
-              .createCommitMessageOnSubmit(original, baseCommit, ctl, originalPatchSet.getId());
+              .createCommitMessageOnSubmit(
+                  original, baseCommit, notes, changeOwner, originalPatchSet.getId());
     } else {
       newCommitMessage = original.getFullMessage();
     }
 
     rebasedCommit = rebaseCommit(ctx, original, baseCommit, newCommitMessage);
-
-    RevId baseRevId =
-        new RevId((baseCommitish != null) ? baseCommitish : ObjectId.toString(baseCommit.getId()));
     Base base =
         rebaseUtil.parseBase(
-            new RevisionResource(changeResourceFactory.create(ctl), originalPatchSet),
-            baseRevId.get());
+            new RevisionResource(
+                changeResourceFactory.create(notes, changeOwner), originalPatchSet),
+            baseCommitId.name());
 
     rebasedPatchSetId =
-        ChangeUtil.nextPatchSetId(ctx.getRepository(), ctl.getChange().currentPatchSetId());
+        ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+            ctx.getRepoView().getRefs(originalPatchSet.getId().getParentKey().toRefPrefix()),
+            notes.getChange().currentPatchSetId());
     patchSetInserter =
         patchSetInserterFactory
-            .create(ctl, rebasedPatchSetId, rebasedCommit)
+            .create(notes, rebasedPatchSetId, rebasedCommit)
             .setDescription("Rebase")
-            .setDraft(originalPatchSet.isDraft())
             .setNotify(NotifyHandling.NONE)
             .setFireRevisionCreated(fireRevisionCreated)
             .setCopyApprovals(copyApprovals)
-            .setCheckAddPatchSetPermission(checkAddPatchSetPermission);
+            .setCheckAddPatchSetPermission(checkAddPatchSetPermission)
+            .setValidate(validate);
     if (postMessage) {
       patchSetInserter.setMessage(
           "Patch Set "
@@ -200,9 +203,6 @@
     if (base != null) {
       patchSetInserter.setGroups(base.patchSet().getGroups());
     }
-    if (validate != null) {
-      patchSetInserter.setValidatePolicy(validate);
-    }
     patchSetInserter.updateRepo(ctx);
   }
 
@@ -234,8 +234,8 @@
     return rebasedPatchSet;
   }
 
-  private MergeUtil newMergeUtil() {
-    ProjectState project = ctl.getProjectControl().getProjectState();
+  private MergeUtil newMergeUtil() throws IOException {
+    ProjectState project = projectCache.checkedGet(notes.getProjectName());
     return forceContentMerge
         ? mergeUtilFactory.create(project, true)
         : mergeUtilFactory.create(project);
@@ -261,11 +261,11 @@
     }
 
     ThreeWayMerger merger =
-        newMergeUtil().newThreeWayMerger(ctx.getRepository(), ctx.getInserter());
+        newMergeUtil().newThreeWayMerger(ctx.getInserter(), ctx.getRepoView().getConfig());
     merger.setBase(parentCommit);
-    merger.merge(original, base);
+    boolean success = merger.merge(original, base);
 
-    if (merger.getResultTreeId() == null) {
+    if (!success || merger.getResultTreeId() == null) {
       throw new MergeConflictException(
           "The change could not be rebased due to a conflict during merge.");
     }
@@ -280,6 +280,11 @@
     } else {
       cb.setCommitter(ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone()));
     }
+    if (matchAuthorToCommitterDate) {
+      cb.setAuthor(
+          new PersonIdent(
+              cb.getAuthor(), cb.getCommitter().getWhen(), cb.getCommitter().getTimeZone()));
+    }
     ObjectId objectId = ctx.getInserter().insert(cb);
     ctx.getInserter().flush();
     return ctx.getRevWalk().parseCommit(objectId);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
index 0f85c2f..acb2c43 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RebaseUtil.java
@@ -27,7 +27,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -77,14 +76,14 @@
 
   @AutoValue
   abstract static class Base {
-    private static Base create(ChangeControl ctl, PatchSet ps) {
-      if (ctl == null) {
+    private static Base create(ChangeNotes notes, PatchSet ps) {
+      if (notes == null) {
         return null;
       }
-      return new AutoValue_RebaseUtil_Base(ctl, ps);
+      return new AutoValue_RebaseUtil_Base(notes, ps);
     }
 
-    abstract ChangeControl control();
+    abstract ChangeNotes notes();
 
     abstract PatchSet patchSet();
   }
@@ -96,20 +95,20 @@
     PatchSet.Id basePatchSetId = PatchSet.Id.fromRef(base);
     if (basePatchSetId != null) {
       Change.Id baseChangeId = basePatchSetId.getParentKey();
-      ChangeControl baseCtl = controlFor(rsrc, baseChangeId);
-      if (baseCtl != null) {
+      ChangeNotes baseNotes = notesFor(rsrc, baseChangeId);
+      if (baseNotes != null) {
         return Base.create(
-            controlFor(rsrc, basePatchSetId.getParentKey()),
-            psUtil.get(db, baseCtl.getNotes(), basePatchSetId));
+            notesFor(rsrc, basePatchSetId.getParentKey()),
+            psUtil.get(db, baseNotes, basePatchSetId));
       }
     }
 
     // Try parsing base as a change number (assume current patch set).
     Integer baseChangeId = Ints.tryParse(base);
     if (baseChangeId != null) {
-      ChangeControl baseCtl = controlFor(rsrc, new Change.Id(baseChangeId));
-      if (baseCtl != null) {
-        return Base.create(baseCtl, psUtil.current(db, baseCtl.getNotes()));
+      ChangeNotes baseNotes = notesFor(rsrc, new Change.Id(baseChangeId));
+      if (baseNotes != null) {
+        return Base.create(baseNotes, psUtil.current(db, baseNotes));
       }
     }
 
@@ -121,19 +120,18 @@
           continue;
         }
         if (ret == null || ret.patchSet().getId().get() < ps.getId().get()) {
-          ret = Base.create(rsrc.getControl().getProjectControl().controlFor(cd.notes()), ps);
+          ret = Base.create(cd.notes(), ps);
         }
       }
     }
     return ret;
   }
 
-  private ChangeControl controlFor(RevisionResource rsrc, Change.Id id) throws OrmException {
+  private ChangeNotes notesFor(RevisionResource rsrc, Change.Id id) throws OrmException {
     if (rsrc.getChange().getId().equals(id)) {
-      return rsrc.getControl();
+      return rsrc.getNotes();
     }
-    ChangeNotes notes = notesFactory.createChecked(dbProvider.get(), rsrc.getProject(), id);
-    return rsrc.getControl().getProjectControl().controlFor(notes);
+    return notesFactory.createChecked(dbProvider.get(), rsrc.getProject(), id);
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
index 682b45f..bfa80dc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Rebuild.java
@@ -18,6 +18,7 @@
 
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -67,7 +68,8 @@
 
   @Override
   public BinaryResult apply(ChangeResource rsrc, Input input)
-      throws ResourceNotFoundException, IOException, OrmException, ConfigInvalidException {
+      throws ResourceNotFoundException, IOException, OrmException, ConfigInvalidException,
+          ResourceConflictException {
     if (!migration.commitChangeWrites()) {
       throw new ResourceNotFoundException();
     }
@@ -82,6 +84,9 @@
     // in the case of races. This should be easy enough to detect by rerunning.
     ChangeBundle reviewDbBundle =
         bundleReader.fromReviewDb(ReviewDbUtil.unwrapDb(db.get()), rsrc.getId());
+    if (reviewDbBundle == null) {
+      throw new ResourceConflictException("change is missing in ReviewDb");
+    }
     rebuild(rsrc);
     ChangeNotes notes = notesFactory.create(db.get(), rsrc.getChange().getProject(), rsrc.getId());
     ChangeBundle noteDbBundle = ChangeBundle.fromNotes(commentsUtil, notes);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java
index 4810a02..22ff2b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RelatedChangesSorter.java
@@ -24,14 +24,20 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.MultimapBuilder;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.ProjectControl;
+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.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayDeque;
@@ -53,20 +59,27 @@
 @Singleton
 class RelatedChangesSorter {
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<ReviewDb> dbProvider;
 
   @Inject
-  RelatedChangesSorter(GitRepositoryManager repoManager) {
+  RelatedChangesSorter(
+      GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      Provider<ReviewDb> dbProvider) {
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.dbProvider = dbProvider;
   }
 
-  public List<PatchSetData> sort(List<ChangeData> in, PatchSet startPs)
-      throws OrmException, IOException {
+  public List<PatchSetData> sort(List<ChangeData> in, PatchSet startPs, CurrentUser user)
+      throws OrmException, IOException, PermissionBackendException {
     checkArgument(!in.isEmpty(), "Input may not be empty");
     // Map of all patch sets, keyed by commit SHA-1.
     Map<String, PatchSetData> byId = collectById(in);
     PatchSetData start = byId.get(startPs.getRevision().get());
     checkArgument(start != null, "%s not found in %s", startPs, in);
-    ProjectControl ctl = start.data().changeControl().getProjectControl();
+    PermissionBackend.WithUser perm = permissionBackend.user(user).database(dbProvider);
 
     // Map of patch set -> immediate parent.
     ListMultimap<PatchSetData, PatchSetData> parents =
@@ -93,9 +106,9 @@
       }
     }
 
-    Collection<PatchSetData> ancestors = walkAncestors(ctl, parents, start);
+    Collection<PatchSetData> ancestors = walkAncestors(perm, parents, start);
     List<PatchSetData> descendants =
-        walkDescendants(ctl, children, start, otherPatchSetsOfStart, ancestors);
+        walkDescendants(perm, children, start, otherPatchSetsOfStart, ancestors);
     List<PatchSetData> result = new ArrayList<>(ancestors.size() + descendants.size() - 1);
     result.addAll(Lists.reverse(descendants));
     result.addAll(ancestors);
@@ -128,14 +141,16 @@
   }
 
   private static Collection<PatchSetData> walkAncestors(
-      ProjectControl ctl, ListMultimap<PatchSetData, PatchSetData> parents, PatchSetData start)
-      throws OrmException {
+      PermissionBackend.WithUser perm,
+      ListMultimap<PatchSetData, PatchSetData> parents,
+      PatchSetData start)
+      throws PermissionBackendException {
     LinkedHashSet<PatchSetData> result = new LinkedHashSet<>();
     Deque<PatchSetData> pending = new ArrayDeque<>();
     pending.add(start);
     while (!pending.isEmpty()) {
       PatchSetData psd = pending.remove();
-      if (result.contains(psd) || !isVisible(psd, ctl)) {
+      if (result.contains(psd) || !isVisible(psd, perm)) {
         continue;
       }
       result.add(psd);
@@ -145,24 +160,25 @@
   }
 
   private static List<PatchSetData> walkDescendants(
-      ProjectControl ctl,
+      PermissionBackend.WithUser perm,
       ListMultimap<PatchSetData, PatchSetData> children,
       PatchSetData start,
       List<PatchSetData> otherPatchSetsOfStart,
       Iterable<PatchSetData> ancestors)
-      throws OrmException {
+      throws PermissionBackendException {
     Set<Change.Id> alreadyEmittedChanges = new HashSet<>();
     addAllChangeIds(alreadyEmittedChanges, ancestors);
 
     // Prefer descendants found by following the original patch set passed in.
     List<PatchSetData> result =
-        walkDescendentsImpl(ctl, alreadyEmittedChanges, children, ImmutableList.of(start));
+        walkDescendentsImpl(perm, alreadyEmittedChanges, children, ImmutableList.of(start));
     addAllChangeIds(alreadyEmittedChanges, result);
 
     // Then, go back and add new indirect descendants found by following any
     // other patch sets of start. These show up after all direct descendants,
     // because we wouldn't know where in the walk to insert them.
-    result.addAll(walkDescendentsImpl(ctl, alreadyEmittedChanges, children, otherPatchSetsOfStart));
+    result.addAll(
+        walkDescendentsImpl(perm, alreadyEmittedChanges, children, otherPatchSetsOfStart));
     return result;
   }
 
@@ -174,11 +190,11 @@
   }
 
   private static List<PatchSetData> walkDescendentsImpl(
-      ProjectControl ctl,
+      PermissionBackend.WithUser perm,
       Set<Change.Id> alreadyEmittedChanges,
       ListMultimap<PatchSetData, PatchSetData> children,
       List<PatchSetData> start)
-      throws OrmException {
+      throws PermissionBackendException {
     if (start.isEmpty()) {
       return ImmutableList.of();
     }
@@ -189,7 +205,7 @@
     pending.addAll(start);
     while (!pending.isEmpty()) {
       PatchSetData psd = pending.remove();
-      if (seen.contains(psd) || !isVisible(psd, ctl)) {
+      if (seen.contains(psd) || !isVisible(psd, perm)) {
         continue;
       }
       seen.add(psd);
@@ -220,10 +236,14 @@
     return result;
   }
 
-  private static boolean isVisible(PatchSetData psd, ProjectControl ctl) throws OrmException {
-    // Reuse existing project control rather than lazily creating a new one for
-    // each ChangeData.
-    return ctl.controlFor(psd.data().notes()).isPatchVisible(psd.patchSet(), psd.data());
+  private static boolean isVisible(PatchSetData psd, PermissionBackend.WithUser perm)
+      throws PermissionBackendException {
+    try {
+      perm.change(psd.data()).check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 
   @AutoValue
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
index b6c4d02..05e8b4a2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.RestoreInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
@@ -29,16 +29,20 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
 import com.google.gerrit.server.mail.send.ReplyToChangeSender;
 import com.google.gerrit.server.mail.send.RestoredSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 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.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -48,8 +52,8 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Restore
-    implements RestModifyView<ChangeResource, RestoreInput>, UiAction<ChangeResource> {
+public class Restore extends RetryingRestModifyView<ChangeResource, RestoreInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Restore.class);
 
   private final RestoredSender.Factory restoredSenderFactory;
@@ -57,7 +61,6 @@
   private final ChangeJson.Factory json;
   private final ChangeMessagesUtil cmUtil;
   private final PatchSetUtil psUtil;
-  private final BatchUpdate.Factory batchUpdateFactory;
   private final ChangeRestored changeRestored;
 
   @Inject
@@ -67,29 +70,27 @@
       ChangeJson.Factory json,
       ChangeMessagesUtil cmUtil,
       PatchSetUtil psUtil,
-      BatchUpdate.Factory batchUpdateFactory,
+      RetryHelper retryHelper,
       ChangeRestored changeRestored) {
+    super(retryHelper);
     this.restoredSenderFactory = restoredSenderFactory;
     this.dbProvider = dbProvider;
     this.json = json;
     this.cmUtil = cmUtil;
     this.psUtil = psUtil;
-    this.batchUpdateFactory = batchUpdateFactory;
     this.changeRestored = changeRestored;
   }
 
   @Override
-  public ChangeInfo apply(ChangeResource req, RestoreInput input)
-      throws RestApiException, UpdateException, OrmException {
-    ChangeControl ctl = req.getControl();
-    if (!ctl.canRestore(dbProvider.get())) {
-      throw new AuthException("restore not permitted");
-    }
+  protected ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource req, RestoreInput input)
+      throws RestApiException, UpdateException, OrmException, PermissionBackendException {
+    req.permissions().database(dbProvider).check(ChangePermission.RESTORE);
 
     Op op = new Op(input);
     try (BatchUpdate u =
-        batchUpdateFactory.create(
-            dbProvider.get(), req.getChange().getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+        updateFactory.create(
+            dbProvider.get(), req.getChange().getProject(), req.getUser(), TimeUtil.nowTs())) {
       u.addOp(req.getId(), op).execute();
     }
     return json.noOptions().format(op.change);
@@ -110,7 +111,7 @@
     public boolean updateChange(ChangeContext ctx) throws OrmException, ResourceConflictException {
       change = ctx.getChange();
       if (change == null || change.getStatus() != Status.ABANDONED) {
-        throw new ResourceConflictException("change is " + status(change));
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
       }
       PatchSet.Id psId = change.currentPatchSetId();
       ChangeUpdate update = ctx.getUpdate(psId);
@@ -150,20 +151,13 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
-    boolean canRestore = false;
-    try {
-      canRestore = resource.getControl().canRestore(dbProvider.get());
-    } catch (OrmException e) {
-      log.error("Cannot check canRestore status. Assuming false.", e);
-    }
+  public UiAction.Description getDescription(ChangeResource rsrc) {
     return new UiAction.Description()
         .setLabel("Restore")
         .setTitle("Restore the change")
-        .setVisible(resource.getChange().getStatus() == Status.ABANDONED && canRestore);
-  }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
+        .setVisible(
+            and(
+                rsrc.getChange().getStatus() == Status.ABANDONED,
+                rsrc.permissions().database(dbProvider).testCond(ChangePermission.RESTORE)));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
index 14ba111..e46b19e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
@@ -14,42 +14,47 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+
 import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.extensions.api.changes.RevertInput;
 import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 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.RestModifyView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.extensions.events.ChangeReverted;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.validators.CommitValidators;
 import com.google.gerrit.server.mail.send.RevertedSender;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ContributorAgreementsChecker;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefControl;
+import com.google.gerrit.server.project.NoSuchProjectException;
 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.Context;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -74,15 +79,15 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class Revert
-    implements RestModifyView<ChangeResource, RevertInput>, UiAction<ChangeResource> {
+public class Revert extends RetryingRestModifyView<ChangeResource, RevertInput, ChangeInfo>
+    implements UiAction<ChangeResource> {
   private static final Logger log = LoggerFactory.getLogger(Revert.class);
 
   private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final ChangeInserter.Factory changeInserterFactory;
   private final ChangeMessagesUtil cmUtil;
-  private final BatchUpdate.Factory updateFactory;
   private final Sequences seq;
   private final PatchSetUtil psUtil;
   private final RevertedSender.Factory revertedSenderFactory;
@@ -90,26 +95,30 @@
   private final PersonIdent serverIdent;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeReverted changeReverted;
+  private final ContributorAgreementsChecker contributorAgreements;
 
   @Inject
   Revert(
       Provider<ReviewDb> db,
+      PermissionBackend permissionBackend,
       GitRepositoryManager repoManager,
       ChangeInserter.Factory changeInserterFactory,
       ChangeMessagesUtil cmUtil,
-      BatchUpdate.Factory updateFactory,
+      RetryHelper retryHelper,
       Sequences seq,
       PatchSetUtil psUtil,
       RevertedSender.Factory revertedSenderFactory,
       ChangeJson.Factory json,
       @GerritPersonIdent PersonIdent serverIdent,
       ApprovalsUtil approvalsUtil,
-      ChangeReverted changeReverted) {
+      ChangeReverted changeReverted,
+      ContributorAgreementsChecker contributorAgreements) {
+    super(retryHelper);
     this.db = db;
+    this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.changeInserterFactory = changeInserterFactory;
     this.cmUtil = cmUtil;
-    this.updateFactory = updateFactory;
     this.seq = seq;
     this.psUtil = psUtil;
     this.revertedSenderFactory = revertedSenderFactory;
@@ -117,41 +126,38 @@
     this.serverIdent = serverIdent;
     this.approvalsUtil = approvalsUtil;
     this.changeReverted = changeReverted;
+    this.contributorAgreements = contributorAgreements;
   }
 
   @Override
-  public ChangeInfo apply(ChangeResource req, RevertInput input)
-      throws IOException, OrmException, RestApiException, UpdateException, NoSuchChangeException {
-    RefControl refControl = req.getControl().getRefControl();
-    ProjectControl projectControl = req.getControl().getProjectControl();
-
-    Capable capable = projectControl.canPushToAtLeastOneRef();
-    if (capable != Capable.OK) {
-      throw new AuthException(capable.getMessage());
+  public ChangeInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, RevertInput input)
+      throws IOException, OrmException, RestApiException, UpdateException, NoSuchChangeException,
+          PermissionBackendException, NoSuchProjectException {
+    Change change = rsrc.getChange();
+    if (change.getStatus() != Change.Status.MERGED) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
 
-    Change change = req.getChange();
-    if (!refControl.canUpload()) {
-      throw new AuthException("revert not permitted");
-    } else if (change.getStatus() != Status.MERGED) {
-      throw new ResourceConflictException("change is " + status(change));
-    }
+    contributorAgreements.check(rsrc.getProject(), rsrc.getUser());
+    permissionBackend.user(rsrc.getUser()).ref(change.getDest()).check(CREATE_CHANGE);
 
-    Change.Id revertedChangeId = revert(req.getControl(), Strings.emptyToNull(input.message));
-    return json.noOptions().format(req.getProject(), revertedChangeId);
+    Change.Id revertId =
+        revert(updateFactory, rsrc.getNotes(), rsrc.getUser(), Strings.emptyToNull(input.message));
+    return json.noOptions().format(rsrc.getProject(), revertId);
   }
 
-  private Change.Id revert(ChangeControl ctl, String message)
+  private Change.Id revert(
+      BatchUpdate.Factory updateFactory, ChangeNotes notes, CurrentUser user, String message)
       throws OrmException, IOException, RestApiException, UpdateException {
-    Change.Id changeIdToRevert = ctl.getChange().getId();
-    PatchSet.Id patchSetId = ctl.getChange().currentPatchSetId();
-    PatchSet patch = psUtil.get(db.get(), ctl.getNotes(), patchSetId);
+    Change.Id changeIdToRevert = notes.getChangeId();
+    PatchSet.Id patchSetId = notes.getChange().currentPatchSetId();
+    PatchSet patch = psUtil.get(db.get(), notes, patchSetId);
     if (patch == null) {
       throw new ResourceNotFoundException(changeIdToRevert.toString());
     }
 
-    Project.NameKey project = ctl.getProject().getNameKey();
-    CurrentUser user = ctl.getUser();
+    Project.NameKey project = notes.getProjectName();
     try (Repository git = repoManager.openRepository(project);
         ObjectInserter oi = git.newObjectInserter();
         ObjectReader reader = oi.newReader();
@@ -176,7 +182,7 @@
       revertCommitBuilder.setAuthor(authorIdent);
       revertCommitBuilder.setCommitter(authorIdent);
 
-      Change changeToRevert = ctl.getChange();
+      Change changeToRevert = notes.getChange();
       if (message == null) {
         message =
             MessageFormat.format(
@@ -200,21 +206,27 @@
 
       ChangeInserter ins =
           changeInserterFactory
-              .create(changeId, revertCommit, ctl.getChange().getDest().get())
-              .setValidatePolicy(CommitValidators.Policy.GERRIT)
+              .create(changeId, revertCommit, notes.getChange().getDest().get())
               .setTopic(changeToRevert.getTopic());
       ins.setMessage("Uploaded patch set 1.");
 
+      ReviewerSet reviewerSet = approvalsUtil.getReviewers(db.get(), notes);
+
       Set<Account.Id> reviewers = new HashSet<>();
       reviewers.add(changeToRevert.getOwner());
-      reviewers.addAll(approvalsUtil.getReviewers(db.get(), ctl.getNotes()).all());
+      reviewers.addAll(reviewerSet.byState(ReviewerStateInternal.REVIEWER));
       reviewers.remove(user.getAccountId());
       ins.setReviewers(reviewers);
 
+      Set<Account.Id> ccs = new HashSet<>(reviewerSet.byState(ReviewerStateInternal.CC));
+      ccs.remove(user.getAccountId());
+      ins.setExtraCC(ccs);
+      ins.setRevertOf(changeIdToRevert);
+
       try (BatchUpdate bu = updateFactory.create(db.get(), project, user, now)) {
         bu.setRepository(git, revWalk, oi);
         bu.insertChange(ins);
-        bu.addOp(changeId, new NotifyOp(ctl.getChange(), ins));
+        bu.addOp(changeId, new NotifyOp(changeToRevert, ins));
         bu.addOp(changeToRevert.getId(), new PostRevertedMessageOp(computedChangeId));
         bu.execute();
       }
@@ -225,17 +237,18 @@
   }
 
   @Override
-  public UiAction.Description getDescription(ChangeResource resource) {
+  public UiAction.Description getDescription(ChangeResource rsrc) {
+    Change change = rsrc.getChange();
     return new UiAction.Description()
         .setLabel("Revert")
         .setTitle("Revert the change")
         .setVisible(
-            resource.getChange().getStatus() == Status.MERGED
-                && resource.getControl().getRefControl().canUpload());
-  }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
+            and(
+                change.getStatus() == Change.Status.MERGED,
+                permissionBackend
+                    .user(rsrc.getUser())
+                    .ref(change.getDest())
+                    .testCond(CREATE_CHANGE)));
   }
 
   private class NotifyOp implements BatchUpdateOp {
@@ -250,14 +263,12 @@
     @Override
     public void postUpdate(Context ctx) throws Exception {
       changeReverted.fire(change, ins.getChange(), ctx.getWhen());
-      Change.Id changeId = ins.getChange().getId();
       try {
-        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), changeId);
+        RevertedSender cm = revertedSenderFactory.create(ctx.getProject(), change.getId());
         cm.setFrom(ctx.getAccountId());
-        cm.setChangeMessage(ins.getChangeMessage().getMessage(), ctx.getWhen());
         cm.send();
       } catch (Exception err) {
-        log.error("Cannot send email for revert change " + changeId, err);
+        log.error("Cannot send email for revert change " + change.getId(), err);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
index ac7f15e..5457142 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerJson.java
@@ -20,8 +20,6 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.extensions.api.changes.ReviewerInfo;
 import com.google.gerrit.reviewdb.client.Account;
@@ -29,8 +27,11 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountLoader;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
@@ -44,30 +45,43 @@
 @Singleton
 public class ReviewerJson {
   private final Provider<ReviewDb> db;
+  private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
   private final AccountLoader.Factory accountLoaderFactory;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   @Inject
   ReviewerJson(
       Provider<ReviewDb> db,
+      PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ApprovalsUtil approvalsUtil,
-      AccountLoader.Factory accountLoaderFactory) {
+      AccountLoader.Factory accountLoaderFactory,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
     this.db = db;
+    this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
     this.accountLoaderFactory = accountLoaderFactory;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
   }
 
-  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs) throws OrmException {
+  public List<ReviewerInfo> format(Collection<ReviewerResource> rsrcs)
+      throws OrmException, PermissionBackendException {
     List<ReviewerInfo> infos = Lists.newArrayListWithCapacity(rsrcs.size());
     AccountLoader loader = accountLoaderFactory.create(true);
+    ChangeData cd = null;
     for (ReviewerResource rsrc : rsrcs) {
+      if (cd == null || !cd.getId().equals(rsrc.getChangeId())) {
+        cd = changeDataFactory.create(db.get(), rsrc.getChangeResource().getNotes());
+      }
       ReviewerInfo info =
           format(
+              rsrc.getChangeResource().getUser(),
               new ReviewerInfo(rsrc.getReviewerUser().getAccountId().get()),
-              rsrc.getReviewerControl());
+              permissionBackend.user(rsrc.getReviewerUser()).database(db).change(cd),
+              cd);
       loader.put(info);
       infos.add(info);
     }
@@ -75,50 +89,56 @@
     return infos;
   }
 
-  public List<ReviewerInfo> format(ReviewerResource rsrc) throws OrmException {
+  public List<ReviewerInfo> format(ReviewerResource rsrc)
+      throws OrmException, PermissionBackendException {
     return format(ImmutableList.<ReviewerResource>of(rsrc));
   }
 
-  public ReviewerInfo format(ReviewerInfo out, ChangeControl ctl) throws OrmException {
-    PatchSet.Id psId = ctl.getChange().currentPatchSetId();
+  public ReviewerInfo format(
+      CurrentUser user, ReviewerInfo out, PermissionBackend.ForChange perm, ChangeData cd)
+      throws OrmException, PermissionBackendException {
+    PatchSet.Id psId = cd.change().currentPatchSetId();
     return format(
+        user,
         out,
-        ctl,
-        approvalsUtil.byPatchSetUser(db.get(), ctl, psId, new Account.Id(out._accountId)));
+        perm,
+        cd,
+        approvalsUtil.byPatchSetUser(
+            db.get(), cd.notes(), perm.user(), psId, new Account.Id(out._accountId), null, null));
   }
 
   public ReviewerInfo format(
-      ReviewerInfo out, ChangeControl ctl, Iterable<PatchSetApproval> approvals)
-      throws OrmException {
-    LabelTypes labelTypes = ctl.getLabelTypes();
+      CurrentUser user,
+      ReviewerInfo out,
+      PermissionBackend.ForChange perm,
+      ChangeData cd,
+      Iterable<PatchSetApproval> approvals)
+      throws OrmException, PermissionBackendException {
+    LabelTypes labelTypes = cd.getLabelTypes();
 
-    // Don't use Maps.newTreeMap(Comparator) due to OpenJDK bug 100167.
     out.approvals = new TreeMap<>(labelTypes.nameComparator());
     for (PatchSetApproval ca : approvals) {
-      for (PermissionRange pr : ctl.getLabelRanges()) {
-        if (!pr.isEmpty()) {
-          LabelType at = labelTypes.byLabel(ca.getLabelId());
-          if (at != null) {
-            out.approvals.put(at.getName(), formatValue(ca.getValue()));
-          }
-        }
+      LabelType at = labelTypes.byLabel(ca.getLabelId());
+      if (at != null) {
+        out.approvals.put(at.getName(), formatValue(ca.getValue()));
       }
     }
 
     // Add dummy approvals for all permitted labels for the user even if they
     // do not exist in the DB.
-    ChangeData cd = changeDataFactory.create(db.get(), ctl);
     PatchSet ps = cd.currentPatchSet();
     if (ps != null) {
       for (SubmitRecord rec :
-          new SubmitRuleEvaluator(cd).setFastEvalLabels(true).setAllowDraft(true).evaluate()) {
+          submitRuleEvaluatorFactory.create(user, cd).setFastEvalLabels(true).evaluate()) {
         if (rec.labels == null) {
           continue;
         }
         for (SubmitRecord.Label label : rec.labels) {
           String name = label.label;
+          LabelType type = labelTypes.byLabel(name);
           if (!out.approvals.containsKey(name)
-              && !ctl.getRange(Permission.forLabel(name)).isEmpty()) {
+              && type != null
+              && perm.test(new LabelPermission(type))) {
             out.approvals.put(name, formatValue((short) 0));
           }
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
index 6ff4a50..47e25b04 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerResource.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.mail.Address;
 import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
@@ -36,7 +39,8 @@
 
   private final ChangeResource change;
   private final RevisionResource revision;
-  private final IdentifiedUser user;
+  @Nullable private final IdentifiedUser user;
+  @Nullable private final Address address;
 
   @AssistedInject
   ReviewerResource(
@@ -44,8 +48,9 @@
       @Assisted ChangeResource change,
       @Assisted Account.Id id) {
     this.change = change;
-    this.revision = null;
     this.user = userFactory.create(id);
+    this.revision = null;
+    this.address = null;
   }
 
   @AssistedInject
@@ -56,6 +61,21 @@
     this.revision = revision;
     this.change = revision.getChangeResource();
     this.user = userFactory.create(id);
+    this.address = null;
+  }
+
+  ReviewerResource(ChangeResource change, Address address) {
+    this.change = change;
+    this.address = address;
+    this.revision = null;
+    this.user = null;
+  }
+
+  ReviewerResource(RevisionResource revision, Address address) {
+    this.revision = revision;
+    this.change = revision.getChangeResource();
+    this.address = address;
+    this.user = null;
   }
 
   public ChangeResource getChangeResource() {
@@ -75,22 +95,22 @@
   }
 
   public IdentifiedUser getReviewerUser() {
+    checkArgument(user != null, "no user provided");
     return user;
   }
 
-  /**
-   * @return the control for the caller's user (as opposed to the reviewer's user as returned by
-   *     {@link #getReviewerControl()}).
-   */
-  public ChangeControl getControl() {
-    return change.getControl();
+  public Address getReviewerByEmail() {
+    checkArgument(address != null, "no address provided");
+    return address;
   }
 
   /**
-   * @return the control for the reviewer's user (as opposed to the caller's user as returned by
-   *     {@link #getControl()}).
+   * Check if this resource was constructed by email or by {@code Account.Id}.
+   *
+   * @return true if the resource was constructed by providing an {@code Address}; false if the
+   *     resource was constructed by providing an {@code Account.Id}.
    */
-  public ChangeControl getReviewerControl() {
-    return change.getControl().forUser(user);
+  public boolean isByEmail() {
+    return user == null;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
index 14c74bc..8794083 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Reviewers.java
@@ -25,11 +25,14 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class Reviewers implements ChildCollection<ChangeResource, ReviewerResource> {
@@ -68,13 +71,28 @@
 
   @Override
   public ReviewerResource parse(ChangeResource rsrc, IdString id)
-      throws OrmException, ResourceNotFoundException, AuthException {
-    Account.Id accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+      throws OrmException, ResourceNotFoundException, AuthException, IOException,
+          ConfigInvalidException {
+    Address address = Address.tryParse(id.get());
 
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    } catch (ResourceNotFoundException e) {
+      if (address == null) {
+        throw e;
+      }
+    }
     // See if the id exists as a reviewer for this change
-    if (fetchAccountIds(rsrc).contains(accountId)) {
+    if (accountId != null && fetchAccountIds(rsrc).contains(accountId)) {
       return resourceFactory.create(rsrc, accountId);
     }
+
+    // See if the address exists as a reviewer on the change
+    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
+      return new ReviewerResource(rsrc, address);
+    }
+
     throw new ResourceNotFoundException(id);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
index 4d35f9e..b9b2d1d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionResource.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.hash.Hasher;
+import com.google.common.hash.Hashing;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestResource.HasETag;
 import com.google.gerrit.extensions.restapi.RestView;
@@ -24,7 +26,7 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.edit.ChangeEdit;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.TypeLiteral;
 import java.util.Optional;
 
@@ -51,16 +53,16 @@
     return cacheable;
   }
 
+  public PermissionBackend.ForChange permissions() {
+    return change.permissions();
+  }
+
   public ChangeResource getChangeResource() {
     return change;
   }
 
-  public ChangeControl getControl() {
-    return getChangeResource().getControl();
-  }
-
   public Change getChange() {
-    return getControl().getChange();
+    return getChangeResource().getChange();
   }
 
   public Project.NameKey getProject() {
@@ -77,10 +79,15 @@
 
   @Override
   public String getETag() {
-    // Conservative estimate: refresh the revision if its parent change has
-    // changed, so we don't have to check whether a given modification affected
-    // this revision specifically.
-    return change.getETag();
+    Hasher h = Hashing.murmur3_128().newHasher();
+    prepareETag(h, getUser());
+    return h.hash().toString();
+  }
+
+  void prepareETag(Hasher h, CurrentUser user) {
+    // Conservative estimate: refresh the revision if its parent change has changed, so we don't
+    // have to check whether a given modification affected this revision specifically.
+    change.prepareETag(h, user);
   }
 
   Account.Id getAccountId() {
@@ -88,7 +95,7 @@
   }
 
   CurrentUser getUser() {
-    return getControl().getUser();
+    return getChangeResource().getUser();
   }
 
   RevisionResource doNotCache() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
index d3623cf..be8bce0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RevisionReviewers.java
@@ -26,11 +26,14 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.account.AccountsCollection;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collection;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class RevisionReviewers implements ChildCollection<RevisionResource, ReviewerResource> {
@@ -69,18 +72,33 @@
 
   @Override
   public ReviewerResource parse(RevisionResource rsrc, IdString id)
-      throws OrmException, ResourceNotFoundException, AuthException, MethodNotAllowedException {
+      throws OrmException, ResourceNotFoundException, AuthException, MethodNotAllowedException,
+          IOException, ConfigInvalidException {
     if (!rsrc.isCurrent()) {
       throw new MethodNotAllowedException("Cannot access on non-current patch set");
     }
+    Address address = Address.tryParse(id.get());
 
-    Account.Id accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
-
+    Account.Id accountId = null;
+    try {
+      accountId = accounts.parse(TopLevelResource.INSTANCE, id).getUser().getAccountId();
+    } catch (ResourceNotFoundException e) {
+      if (address == null) {
+        throw e;
+      }
+    }
     Collection<Account.Id> reviewers =
         approvalsUtil.getReviewers(dbProvider.get(), rsrc.getNotes()).all();
+    // See if the id exists as a reviewer for this change
     if (reviewers.contains(accountId)) {
       return resourceFactory.create(rsrc, accountId);
     }
+
+    // See if the address exists as a reviewer on the change
+    if (address != null && rsrc.getNotes().getReviewersByEmail().all().contains(address)) {
+      return new ReviewerResource(rsrc, address);
+    }
+
     throw new ResourceNotFoundException(id);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
index ad55fd3..084bc25 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revisions.java
@@ -28,6 +28,9 @@
 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.permissions.ChangePermission;
+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;
@@ -37,6 +40,7 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
 public class Revisions implements ChildCollection<ChangeResource, RevisionResource> {
@@ -44,17 +48,20 @@
   private final Provider<ReviewDb> dbProvider;
   private final ChangeEditUtil editUtil;
   private final PatchSetUtil psUtil;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   Revisions(
       DynamicMap<RestView<RevisionResource>> views,
       Provider<ReviewDb> dbProvider,
       ChangeEditUtil editUtil,
-      PatchSetUtil psUtil) {
+      PatchSetUtil psUtil,
+      PermissionBackend permissionBackend) {
     this.views = views;
     this.dbProvider = dbProvider;
     this.editUtil = editUtil;
     this.psUtil = psUtil;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -69,10 +76,11 @@
 
   @Override
   public RevisionResource parse(ChangeResource change, IdString id)
-      throws ResourceNotFoundException, AuthException, OrmException, IOException {
+      throws ResourceNotFoundException, AuthException, OrmException, IOException,
+          PermissionBackendException {
     if (id.get().equals("current")) {
       PatchSet ps = psUtil.current(dbProvider.get(), change.getNotes());
-      if (ps != null && visible(change, ps)) {
+      if (ps != null && visible(change)) {
         return new RevisionResource(change, ps).doNotCache();
       }
       throw new ResourceNotFoundException(id);
@@ -80,7 +88,7 @@
 
     List<RevisionResource> match = Lists.newArrayListWithExpectedSize(2);
     for (RevisionResource rsrc : find(change, id.get())) {
-      if (visible(change, rsrc.getPatchSet())) {
+      if (visible(change)) {
         match.add(rsrc);
       }
     }
@@ -95,8 +103,17 @@
     }
   }
 
-  private boolean visible(ChangeResource change, PatchSet ps) throws OrmException {
-    return change.getControl().isPatchVisible(ps, dbProvider.get());
+  private boolean visible(ChangeResource change) throws PermissionBackendException {
+    try {
+      permissionBackend
+          .user(change.getUser())
+          .change(change.getNotes())
+          .database(dbProvider)
+          .check(ChangePermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
   }
 
   private List<RevisionResource> find(ChangeResource change, String id)
@@ -139,12 +156,13 @@
   }
 
   private List<RevisionResource> loadEdit(ChangeResource change, RevId revid)
-      throws AuthException, IOException, OrmException {
-    Optional<ChangeEdit> edit = editUtil.byChange(change.getChange());
+      throws AuthException, IOException {
+    Optional<ChangeEdit> edit = editUtil.byChange(change.getNotes(), change.getUser());
     if (edit.isPresent()) {
       PatchSet ps = new PatchSet(new PatchSet.Id(change.getId(), 0));
-      ps.setRevision(edit.get().getRevision());
-      if (revid == null || edit.get().getRevision().equals(revid)) {
+      RevId editRevId = new RevId(ObjectId.toString(edit.get().getEditCommit()));
+      ps.setRevision(editRevId);
+      if (revid == null || editRevId.equals(revid)) {
         return Collections.singletonList(new RevisionResource(change, ps, edit));
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
index 409be9d..73a6c60 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -17,16 +17,12 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.extensions.events.AssigneeChanged;
 import com.google.gerrit.server.mail.send.SetAssigneeSender;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -36,9 +32,9 @@
 import com.google.gerrit.server.validators.AssigneeValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -46,93 +42,74 @@
   private static final Logger log = LoggerFactory.getLogger(SetAssigneeOp.class);
 
   public interface Factory {
-    SetAssigneeOp create(String assignee);
+    SetAssigneeOp create(IdentifiedUser assignee);
   }
 
-  private final AccountsCollection accounts;
   private final ChangeMessagesUtil cmUtil;
   private final DynamicSet<AssigneeValidationListener> validationListeners;
-  private final String assignee;
+  private final IdentifiedUser newAssignee;
   private final AssigneeChanged assigneeChanged;
   private final SetAssigneeSender.Factory setAssigneeSenderFactory;
   private final Provider<IdentifiedUser> user;
   private final IdentifiedUser.GenericFactory userFactory;
 
   private Change change;
-  private Account newAssignee;
-  private Account oldAssignee;
+  private IdentifiedUser oldAssignee;
 
-  @AssistedInject
+  @Inject
   SetAssigneeOp(
-      AccountsCollection accounts,
       ChangeMessagesUtil cmUtil,
       DynamicSet<AssigneeValidationListener> validationListeners,
       AssigneeChanged assigneeChanged,
       SetAssigneeSender.Factory setAssigneeSenderFactory,
       Provider<IdentifiedUser> user,
       IdentifiedUser.GenericFactory userFactory,
-      @Assisted String assignee) {
-    this.accounts = accounts;
+      @Assisted IdentifiedUser newAssignee) {
     this.cmUtil = cmUtil;
     this.validationListeners = validationListeners;
     this.assigneeChanged = assigneeChanged;
     this.setAssigneeSenderFactory = setAssigneeSenderFactory;
     this.user = user;
     this.userFactory = userFactory;
-    this.assignee = checkNotNull(assignee);
+    this.newAssignee = checkNotNull(newAssignee, "assignee");
   }
 
   @Override
   public boolean updateChange(ChangeContext ctx) throws OrmException, RestApiException {
     change = ctx.getChange();
-    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
-    IdentifiedUser newAssigneeUser = accounts.parse(assignee);
-    newAssignee = newAssigneeUser.getAccount();
-    IdentifiedUser oldAssigneeUser = null;
-    if (change.getAssignee() != null) {
-      oldAssigneeUser = userFactory.create(change.getAssignee());
-      oldAssignee = oldAssigneeUser.getAccount();
-      if (newAssignee.equals(oldAssignee)) {
-        return false;
-      }
-    }
-    if (!newAssignee.isActive()) {
-      throw new UnprocessableEntityException(
-          String.format("Account of %s is not active", assignee));
-    }
-    if (!ctx.getControl().forUser(newAssigneeUser).isRefVisible()) {
-      throw new AuthException(
-          String.format("Change %s is not visible to %s.", change.getChangeId(), assignee));
+    if (newAssignee.getAccountId().equals(change.getAssignee())) {
+      return false;
     }
     try {
       for (AssigneeValidationListener validator : validationListeners) {
-        validator.validateAssignee(change, newAssignee);
+        validator.validateAssignee(change, newAssignee.getAccount());
       }
     } catch (ValidationException e) {
       throw new ResourceConflictException(e.getMessage());
     }
+
+    if (change.getAssignee() != null) {
+      oldAssignee = userFactory.create(change.getAssignee());
+    }
+
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     // notedb
-    update.setAssignee(newAssignee.getId());
+    update.setAssignee(newAssignee.getAccountId());
     // reviewdb
-    change.setAssignee(newAssignee.getId());
-    addMessage(ctx, update, oldAssigneeUser, newAssigneeUser);
+    change.setAssignee(newAssignee.getAccountId());
+    addMessage(ctx, update);
     return true;
   }
 
-  private void addMessage(
-      ChangeContext ctx,
-      ChangeUpdate update,
-      IdentifiedUser previousAssignee,
-      IdentifiedUser newAssignee)
-      throws OrmException {
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
     StringBuilder msg = new StringBuilder();
     msg.append("Assignee ");
-    if (previousAssignee == null) {
+    if (oldAssignee == null) {
       msg.append("added: ");
       msg.append(newAssignee.getNameEmail());
     } else {
       msg.append("changed from: ");
-      msg.append(previousAssignee.getNameEmail());
+      msg.append(oldAssignee.getNameEmail());
       msg.append(" to: ");
       msg.append(newAssignee.getNameEmail());
     }
@@ -145,16 +122,17 @@
   public void postUpdate(Context ctx) throws OrmException {
     try {
       SetAssigneeSender cm =
-          setAssigneeSenderFactory.create(change.getProject(), change.getId(), newAssignee.getId());
+          setAssigneeSenderFactory.create(
+              change.getProject(), change.getId(), newAssignee.getAccountId());
       cm.setFrom(user.get().getAccountId());
       cm.send();
     } catch (Exception err) {
       log.error("Cannot send email to new assignee of change " + change.getId(), err);
     }
-    assigneeChanged.fire(change, ctx.getAccount(), oldAssignee, ctx.getWhen());
-  }
-
-  public Account.Id getNewAssignee() {
-    return newAssignee != null ? newAssignee.getId() : null;
+    assigneeChanged.fire(
+        change,
+        ctx.getAccount(),
+        oldAssignee != null ? oldAssignee.getAccount() : null,
+        ctx.getWhen());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 0e78c18..1f17dd3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.change.HashtagsUtil.InvalidHashtagException;
 import com.google.gerrit.server.extensions.events.HashtagsEdited;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -39,8 +40,8 @@
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 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 java.util.Collection;
 import java.util.HashSet;
@@ -64,7 +65,7 @@
   private Set<String> toRemove;
   private ImmutableSortedSet<String> updatedHashtags;
 
-  @AssistedInject
+  @Inject
   SetHashtagsOp(
       NotesMigration notesMigration,
       ChangeMessagesUtil cmUtil,
@@ -94,38 +95,36 @@
       updatedHashtags = ImmutableSortedSet.of();
       return false;
     }
-    if (!ctx.getControl().canEditHashtags()) {
-      throw new AuthException("Editing hashtags not permitted");
-    }
+
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     ChangeNotes notes = update.getNotes().load();
 
-    Set<String> existingHashtags = notes.getHashtags();
-    Set<String> updated = new HashSet<>();
-    toAdd = new HashSet<>(extractTags(input.add));
-    toRemove = new HashSet<>(extractTags(input.remove));
-
     try {
+      Set<String> existingHashtags = notes.getHashtags();
+      Set<String> updated = new HashSet<>();
+      toAdd = new HashSet<>(extractTags(input.add));
+      toRemove = new HashSet<>(extractTags(input.remove));
+
       for (HashtagValidationListener validator : validationListeners) {
         validator.validateHashtags(update.getChange(), toAdd, toRemove);
       }
-    } catch (ValidationException e) {
+
+      updated.addAll(existingHashtags);
+      toAdd.removeAll(existingHashtags);
+      toRemove.retainAll(existingHashtags);
+      if (updated()) {
+        updated.addAll(toAdd);
+        updated.removeAll(toRemove);
+        update.setHashtags(updated);
+        addMessage(ctx, update);
+      }
+
+      updatedHashtags = ImmutableSortedSet.copyOf(updated);
+      return true;
+    } catch (ValidationException | InvalidHashtagException e) {
       throw new BadRequestException(e.getMessage());
     }
-
-    updated.addAll(existingHashtags);
-    toAdd.removeAll(existingHashtags);
-    toRemove.retainAll(existingHashtags);
-    if (updated()) {
-      updated.addAll(toAdd);
-      updated.removeAll(toRemove);
-      update.setHashtags(updated);
-      addMessage(ctx, update);
-    }
-
-    updatedHashtags = ImmutableSortedSet.copyOf(updated);
-    return true;
   }
 
   private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java
new file mode 100644
index 0000000..de79f03
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetPrivateOp.java
@@ -0,0 +1,109 @@
+// 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.change;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+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.server.ChangeMessagesUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.PrivateStateChanged;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class SetPrivateOp implements BatchUpdateOp {
+  public static class Input {
+    String message;
+
+    public Input() {}
+
+    public Input(String message) {
+      this.message = message;
+    }
+  }
+
+  public interface Factory {
+    SetPrivateOp create(ChangeMessagesUtil cmUtil, boolean isPrivate, Input input);
+  }
+
+  private final ChangeMessagesUtil cmUtil;
+  private final PatchSetUtil psUtil;
+  private final boolean isPrivate;
+  private final Input input;
+  private final PrivateStateChanged privateStateChanged;
+
+  private Change change;
+  private PatchSet ps;
+
+  @Inject
+  SetPrivateOp(
+      PrivateStateChanged privateStateChanged,
+      PatchSetUtil psUtil,
+      @Assisted ChangeMessagesUtil cmUtil,
+      @Assisted boolean isPrivate,
+      @Assisted Input input) {
+    this.cmUtil = cmUtil;
+    this.psUtil = psUtil;
+    this.isPrivate = isPrivate;
+    this.input = input;
+    this.privateStateChanged = privateStateChanged;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws ResourceConflictException, OrmException {
+    change = ctx.getChange();
+    ChangeNotes notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getDb(), notes, change.currentPatchSetId());
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    change.setPrivate(isPrivate);
+    change.setLastUpdatedOn(ctx.getWhen());
+    update.setPrivate(isPrivate);
+    addMessage(ctx, update);
+    return true;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    privateStateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+    Change c = ctx.getChange();
+    StringBuilder buf = new StringBuilder(c.isPrivate() ? "Set private" : "Unset private");
+
+    String m = Strings.nullToEmpty(input == null ? null : input.message).trim();
+    if (!m.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(m);
+    }
+
+    ChangeMessage cmsg =
+        ChangeMessagesUtil.newMessage(
+            ctx,
+            buf.toString(),
+            c.isPrivate()
+                ? ChangeMessagesUtil.TAG_SET_PRIVATE
+                : ChangeMessagesUtil.TAG_UNSET_PRIVATE);
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java
new file mode 100644
index 0000000..ca89cc9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetReadyForReview.java
@@ -0,0 +1,131 @@
+// 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.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.WorkInProgressOp.Input;
+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.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class SetReadyForReview extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+    implements UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(SetReadyForReview.class);
+  private final WorkInProgressOp.Factory opFactory;
+  private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final ProjectControl.GenericFactory projectControlFactory;
+
+  @Inject
+  SetReadyForReview(
+      RetryHelper retryHelper,
+      WorkInProgressOp.Factory opFactory,
+      Provider<ReviewDb> db,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      ProjectControl.GenericFactory projectControlFactory) {
+    super(retryHelper);
+    this.opFactory = opFactory;
+    this.db = db;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.projectControlFactory = projectControlFactory;
+  }
+
+  @Override
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException, PermissionBackendException, NoSuchProjectException,
+          IOException {
+    Change change = rsrc.getChange();
+    WorkInProgressOp.checkPermissions(
+        permissionBackend,
+        self.get(),
+        change,
+        projectControlFactory.controlFor(rsrc.getProject(), rsrc.getUser()));
+    if (!rsrc.isUserOwner()
+        && !permissionBackend.user(self).test(GlobalPermission.ADMINISTRATE_SERVER)
+        && !projectControlFactory.controlFor(rsrc.getProject(), rsrc.getUser()).isOwner()) {
+      throw new AuthException("not allowed to set ready for review");
+    }
+
+    if (change.getStatus() != Status.NEW) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+
+    if (!change.isWorkInProgress()) {
+      throw new ResourceConflictException("change is not work in progress");
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.addOp(rsrc.getChange().getId(), opFactory.create(false, input));
+      bu.execute();
+      return Response.ok("");
+    }
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    boolean isProjectOwner;
+    try {
+      isProjectOwner =
+          projectControlFactory.controlFor(rsrc.getProject(), rsrc.getUser()).isOwner();
+    } catch (IOException | NoSuchProjectException e) {
+      isProjectOwner = false;
+      log.error("Cannot retrieve project owner ACL", e);
+    }
+    return new Description()
+        .setLabel("Start Review")
+        .setTitle("Set Ready For Review")
+        .setVisible(
+            and(
+                rsrc.getChange().getStatus() == Status.NEW && rsrc.getChange().isWorkInProgress(),
+                or(
+                    rsrc.isUserOwner(),
+                    or(
+                        isProjectOwner,
+                        permissionBackend
+                            .user(self)
+                            .testCond(GlobalPermission.ADMINISTRATE_SERVER)))));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java
new file mode 100644
index 0000000..bd412d7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetWorkInProgress.java
@@ -0,0 +1,125 @@
+// 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.change;
+
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Status;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.change.WorkInProgressOp.Input;
+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.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class SetWorkInProgress extends RetryingRestModifyView<ChangeResource, Input, Response<?>>
+    implements UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(SetWorkInProgress.class);
+  private final WorkInProgressOp.Factory opFactory;
+  private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
+  private final ProjectControl.GenericFactory projectControlFactory;
+
+  @Inject
+  SetWorkInProgress(
+      WorkInProgressOp.Factory opFactory,
+      RetryHelper retryHelper,
+      Provider<ReviewDb> db,
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
+      ProjectControl.GenericFactory projectControlFactory) {
+    super(retryHelper);
+    this.opFactory = opFactory;
+    this.db = db;
+    this.self = self;
+    this.permissionBackend = permissionBackend;
+    this.projectControlFactory = projectControlFactory;
+  }
+
+  @Override
+  protected Response<?> applyImpl(
+      BatchUpdate.Factory updateFactory, ChangeResource rsrc, Input input)
+      throws RestApiException, UpdateException, PermissionBackendException, NoSuchProjectException,
+          IOException {
+    Change change = rsrc.getChange();
+    WorkInProgressOp.checkPermissions(
+        permissionBackend,
+        self.get(),
+        change,
+        projectControlFactory.controlFor(rsrc.getProject(), rsrc.getUser()));
+
+    if (change.getStatus() != Status.NEW) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
+    }
+
+    if (change.isWorkInProgress()) {
+      throw new ResourceConflictException("change is already work in progress");
+    }
+
+    try (BatchUpdate bu =
+        updateFactory.create(db.get(), rsrc.getProject(), rsrc.getUser(), TimeUtil.nowTs())) {
+      bu.addOp(rsrc.getChange().getId(), opFactory.create(true, input));
+      bu.execute();
+      return Response.ok("");
+    }
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    boolean isProjectOwner;
+    try {
+      isProjectOwner =
+          projectControlFactory.controlFor(rsrc.getProject(), rsrc.getUser()).isOwner();
+    } catch (IOException | NoSuchProjectException e) {
+      isProjectOwner = false;
+      log.error("Cannot retrieve project owner ACL", e);
+    }
+    return new Description()
+        .setLabel("WIP")
+        .setTitle("Set Work In Progress")
+        .setVisible(
+            and(
+                rsrc.getChange().getStatus() == Status.NEW && !rsrc.getChange().isWorkInProgress(),
+                or(
+                    rsrc.isUserOwner(),
+                    or(
+                        isProjectOwner,
+                        permissionBackend
+                            .user(self)
+                            .testCond(GlobalPermission.ADMINISTRATE_SERVER)))));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
index 807ca5f..84ba88e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
@@ -51,10 +52,13 @@
 import com.google.gerrit.server.git.MergeOp;
 import com.google.gerrit.server.git.MergeSuperSet;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.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.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
 import com.google.inject.Inject;
@@ -62,11 +66,14 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.Collection;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Queue;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
@@ -93,6 +100,7 @@
       "This change depends on other changes which are not ready";
   private static final String BLOCKED_HIDDEN_SUBMIT_TOOLTIP =
       "This change depends on other hidden changes which are not ready";
+  private static final String BLOCKED_WORK_IN_PROGRESS = "This change is marked work in progress";
   private static final String CLICK_FAILURE_TOOLTIP = "Clicking the button would fail";
   private static final String CHANGE_UNMERGEABLE = "Problems with integrating this change";
   private static final String CHANGES_NOT_MERGEABLE = "Problems with change(s): ";
@@ -111,24 +119,25 @@
    */
   @VisibleForTesting
   public static class TestSubmitInput extends SubmitInput {
-    public final boolean failAfterRefUpdates;
+    public boolean failAfterRefUpdates;
 
-    public TestSubmitInput(SubmitInput base, boolean failAfterRefUpdates) {
-      this.onBehalfOf = base.onBehalfOf;
-      this.notify = base.notify;
-      this.failAfterRefUpdates = failAfterRefUpdates;
-    }
+    /**
+     * For each change being submitted, an element is removed from this queue and, if the value is
+     * true, a bogus ref update is added to the batch, in order to generate a lock failure during
+     * execution.
+     */
+    public Queue<Boolean> generateLockFailures;
   }
 
   private final Provider<ReviewDb> dbProvider;
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
   private final ChangeData.Factory changeDataFactory;
   private final ChangeMessagesUtil cmUtil;
   private final ChangeNotes.Factory changeNotesFactory;
   private final Provider<MergeOp> mergeOpProvider;
   private final Provider<MergeSuperSet> mergeSuperSet;
   private final AccountsCollection accounts;
-  private final ChangesCollection changes;
   private final String label;
   private final String labelWithParents;
   private final ParameterizedString titlePattern;
@@ -143,25 +152,25 @@
   Submit(
       Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
       ChangeData.Factory changeDataFactory,
       ChangeMessagesUtil cmUtil,
       ChangeNotes.Factory changeNotesFactory,
       Provider<MergeOp> mergeOpProvider,
       Provider<MergeSuperSet> mergeSuperSet,
       AccountsCollection accounts,
-      ChangesCollection changes,
       @GerritServerConfig Config cfg,
       Provider<InternalChangeQuery> queryProvider,
       PatchSetUtil psUtil) {
     this.dbProvider = dbProvider;
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
     this.changeDataFactory = changeDataFactory;
     this.cmUtil = cmUtil;
     this.changeNotesFactory = changeNotesFactory;
     this.mergeOpProvider = mergeOpProvider;
     this.mergeSuperSet = mergeSuperSet;
     this.accounts = accounts;
-    this.changes = changes;
     this.label =
         MoreObjects.firstNonNull(
             Strings.emptyToNull(cfg.getString("change", null, "submitLabel")), "Submit");
@@ -193,18 +202,26 @@
 
   @Override
   public Output apply(RevisionResource rsrc, SubmitInput input)
-      throws RestApiException, RepositoryNotFoundException, IOException, OrmException {
+      throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
+          PermissionBackendException, UpdateException, ConfigInvalidException {
     input.onBehalfOf = Strings.emptyToNull(input.onBehalfOf);
+    IdentifiedUser submitter;
     if (input.onBehalfOf != null) {
-      rsrc = onBehalfOf(rsrc, input);
+      submitter = onBehalfOf(rsrc, input);
+    } else {
+      rsrc.permissions().check(ChangePermission.SUBMIT);
+      submitter = rsrc.getUser().asIdentifiedUser();
     }
-    ChangeControl control = rsrc.getControl();
-    IdentifiedUser caller = control.getUser().asIdentifiedUser();
+
+    return new Output(mergeChange(rsrc, submitter, input));
+  }
+
+  public Change mergeChange(RevisionResource rsrc, IdentifiedUser submitter, SubmitInput input)
+      throws OrmException, RestApiException, IOException, UpdateException, ConfigInvalidException,
+          PermissionBackendException {
     Change change = rsrc.getChange();
-    if (input.onBehalfOf == null && !control.canSubmit()) {
-      throw new AuthException("submit not permitted");
-    } else if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + status(change));
+    if (!change.getStatus().isOpen()) {
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     } else if (!ProjectUtil.branchExists(repoManager, change.getDest())) {
       throw new ResourceConflictException(
           String.format("destination branch \"%s\" not found.", change.getDest().get()));
@@ -217,7 +234,7 @@
 
     try (MergeOp op = mergeOpProvider.get()) {
       ReviewDb db = dbProvider.get();
-      op.merge(db, change, caller, true, input, false);
+      op.merge(db, change, submitter, true, input, false);
       try {
         change =
             changeNotesFactory.createChecked(db, change.getProject(), change.getId()).getChange();
@@ -228,7 +245,7 @@
 
     switch (change.getStatus()) {
       case MERGED:
-        return new Output(change);
+        return change;
       case NEW:
         ChangeMessage msg = getConflictMessage(rsrc);
         if (msg != null) {
@@ -236,9 +253,8 @@
         }
         // $FALL-THROUGH$
       case ABANDONED:
-      case DRAFT:
       default:
-        throw new ResourceConflictException("change is " + status(change));
+        throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
   }
 
@@ -250,21 +266,26 @@
    */
   private String problemsForSubmittingChangeset(ChangeData cd, ChangeSet cs, CurrentUser user) {
     try {
-      @SuppressWarnings("resource")
-      ReviewDb db = dbProvider.get();
       if (cs.furtherHiddenChanges()) {
         return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
       }
       for (ChangeData c : cs.changes()) {
-        ChangeControl changeControl = c.changeControl(user);
-
-        if (!changeControl.isVisible(db)) {
+        Set<ChangePermission> can =
+            permissionBackend
+                .user(user)
+                .database(dbProvider)
+                .change(c)
+                .test(EnumSet.of(ChangePermission.READ, ChangePermission.SUBMIT));
+        if (!can.contains(ChangePermission.READ)) {
           return BLOCKED_HIDDEN_SUBMIT_TOOLTIP;
         }
-        if (!changeControl.canSubmit()) {
+        if (!can.contains(ChangePermission.SUBMIT)) {
           return BLOCKED_SUBMIT_TOOLTIP;
         }
-        MergeOp.checkSubmitRule(c);
+        if (c.change().isWorkInProgress()) {
+          return BLOCKED_WORK_IN_PROGRESS;
+        }
+        MergeOp.checkSubmitRule(c, false);
       }
 
       Collection<ChangeData> unmergeable = unmergeableChanges(cs);
@@ -281,7 +302,7 @@
       }
     } catch (ResourceConflictException e) {
       return BLOCKED_SUBMIT_TOOLTIP;
-    } catch (OrmException | IOException e) {
+    } catch (PermissionBackendException | OrmException | IOException e) {
       log.error("Error checking if change is submittable", e);
       throw new OrmRuntimeException("Could not determine problems for the change", e);
     }
@@ -290,37 +311,33 @@
 
   @Override
   public UiAction.Description getDescription(RevisionResource resource) {
-    PatchSet.Id current = resource.getChange().currentPatchSetId();
-    String topic = resource.getChange().getTopic();
-    boolean visible =
-        !resource.getPatchSet().isDraft()
-            && resource.getChange().getStatus().isOpen()
-            && resource.getPatchSet().getId().equals(current)
-            && resource.getControl().canSubmit();
-    ReviewDb db = dbProvider.get();
-    ChangeData cd = changeDataFactory.create(db, resource.getControl());
+    Change change = resource.getChange();
+    if (!change.getStatus().isOpen()
+        || !resource.isCurrent()
+        || !resource.permissions().testOrFalse(ChangePermission.SUBMIT)) {
+      return null; // submit not visible
+    }
 
+    ReviewDb db = dbProvider.get();
+    ChangeData cd = changeDataFactory.create(db, resource.getNotes());
     try {
-      MergeOp.checkSubmitRule(cd);
+      MergeOp.checkSubmitRule(cd, false);
     } catch (ResourceConflictException e) {
-      visible = false;
+      return null; // submit not visible
     } catch (OrmException e) {
       log.error("Error checking if change is submittable", e);
       throw new OrmRuntimeException("Could not determine problems for the change", e);
     }
 
-    if (!visible) {
-      return new UiAction.Description().setLabel("").setTitle("").setVisible(false);
-    }
-
     ChangeSet cs;
     try {
-      cs = mergeSuperSet.get().completeChangeSet(db, cd.change(), resource.getControl().getUser());
-    } catch (OrmException | IOException e) {
+      cs = mergeSuperSet.get().completeChangeSet(db, cd.change(), resource.getUser());
+    } catch (OrmException | IOException | PermissionBackendException e) {
       throw new OrmRuntimeException(
           "Could not determine complete set of changes to be submitted", e);
     }
 
+    String topic = change.getTopic();
     int topicSize = 0;
     if (!Strings.isNullOrEmpty(topic)) {
       topicSize = getChangesByTopic(topic).size();
@@ -367,7 +384,7 @@
     Map<String, String> params =
         ImmutableMap.of(
             "patchSet", String.valueOf(resource.getPatchSet().getPatchSetId()),
-            "branch", resource.getChange().getDest().getShortName(),
+            "branch", change.getDest().getShortName(),
             "commit", ObjectId.fromString(revId.get()).abbreviate(7).name(),
             "submitSize", String.valueOf(cs.size()));
     ParameterizedString tp = cs.size() > 1 ? titlePatternWithAncestors : titlePattern;
@@ -390,10 +407,6 @@
         .orNull();
   }
 
-  static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
-
   public Collection<ChangeData> unmergeableChanges(ChangeSet cs) throws OrmException, IOException {
     Set<ChangeData> mergeabilityMap = new HashSet<>();
     for (ChangeData change : cs.changes()) {
@@ -458,24 +471,22 @@
     return commits;
   }
 
-  private RevisionResource onBehalfOf(RevisionResource rsrc, SubmitInput in)
-      throws AuthException, UnprocessableEntityException, OrmException {
-    ChangeControl caller = rsrc.getControl();
-    if (!caller.canSubmit()) {
-      throw new AuthException("submit not permitted");
-    }
-    if (!caller.canSubmitAs()) {
-      throw new AuthException("submit on behalf of not permitted");
-    }
-    ChangeControl target =
-        caller.forUser(accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
-    if (!target.getRefControl().isVisible()) {
+  private IdentifiedUser onBehalfOf(RevisionResource rsrc, SubmitInput in)
+      throws AuthException, UnprocessableEntityException, OrmException, PermissionBackendException,
+          IOException, ConfigInvalidException {
+    PermissionBackend.ForChange perm = rsrc.permissions().database(dbProvider);
+    perm.check(ChangePermission.SUBMIT);
+    perm.check(ChangePermission.SUBMIT_AS);
+
+    CurrentUser caller = rsrc.getUser();
+    IdentifiedUser submitter = accounts.parseOnBehalfOf(caller, in.onBehalfOf);
+    try {
+      perm.user(submitter).check(ChangePermission.READ);
+    } catch (AuthException e) {
       throw new UnprocessableEntityException(
-          String.format(
-              "on_behalf_of account %s cannot see destination ref",
-              target.getUser().getAccountId()));
+          String.format("on_behalf_of account %s cannot see change", submitter.getAccountId()));
     }
-    return new RevisionResource(changes.parse(target), rsrc.getPatchSet());
+    return submitter;
   }
 
   public static boolean wholeTopicEnabled(Config config) {
@@ -510,12 +521,11 @@
 
     @Override
     public ChangeInfo apply(ChangeResource rsrc, SubmitInput input)
-        throws RestApiException, RepositoryNotFoundException, IOException, OrmException {
+        throws RestApiException, RepositoryNotFoundException, IOException, OrmException,
+            PermissionBackendException, UpdateException, ConfigInvalidException {
       PatchSet ps = psUtil.current(dbProvider.get(), rsrc.getNotes());
       if (ps == null) {
         throw new ResourceConflictException("current revision is missing");
-      } else if (!rsrc.getControl().isPatchVisible(ps, dbProvider.get())) {
-        throw new AuthException("current revision not accessible");
       }
 
       Output out = submit.apply(new RevisionResource(rsrc, ps), input);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
index 568b50a..98e47a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SubmittedTogether.java
@@ -29,6 +29,7 @@
 import com.google.gerrit.server.change.WalkSorter.PatchSetData;
 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.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -107,7 +108,7 @@
   @Override
   public Object apply(ChangeResource resource)
       throws AuthException, BadRequestException, ResourceConflictException, IOException,
-          OrmException {
+          OrmException, PermissionBackendException {
     SubmittedTogetherInfo info = applyInfo(resource);
     if (options.isEmpty()) {
       return info.changes;
@@ -116,7 +117,7 @@
   }
 
   public SubmittedTogetherInfo applyInfo(ChangeResource resource)
-      throws AuthException, IOException, OrmException {
+      throws AuthException, IOException, OrmException, PermissionBackendException {
     Change c = resource.getChange();
     try {
       List<ChangeData> cds;
@@ -124,9 +125,7 @@
 
       if (c.getStatus().isOpen()) {
         ChangeSet cs =
-            mergeSuperSet
-                .get()
-                .completeChangeSet(dbProvider.get(), c, resource.getControl().getUser());
+            mergeSuperSet.get().completeChangeSet(dbProvider.get(), c, resource.getUser());
         cds = cs.changes().asList();
         hidden = cs.nonVisibleChanges().size();
       } else if (c.getStatus().asChangeStatus() == ChangeStatus.MERGED) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
index fd14adf..4d3abb2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -25,13 +26,16 @@
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
 import com.google.gerrit.server.ReviewersUtil;
 import com.google.gerrit.server.ReviewersUtil.VisibilityControl;
-import com.google.gerrit.server.account.AccountVisibility;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.project.ProjectCache;
 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 org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.kohsuke.args4j.Option;
 
@@ -44,50 +48,51 @@
       usage = "exclude groups from query")
   boolean excludeGroups;
 
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
+  private final ProjectCache projectCache;
 
   @Inject
   SuggestChangeReviewers(
       AccountVisibility av,
       GenericFactory identifiedUserFactory,
       Provider<ReviewDb> dbProvider,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       @GerritServerConfig Config cfg,
-      ReviewersUtil reviewersUtil) {
+      ReviewersUtil reviewersUtil,
+      ProjectCache projectCache) {
     super(av, identifiedUserFactory, dbProvider, cfg, reviewersUtil);
+    this.permissionBackend = permissionBackend;
     this.self = self;
+    this.projectCache = projectCache;
   }
 
   @Override
   public List<SuggestedReviewerInfo> apply(ChangeResource rsrc)
-      throws AuthException, BadRequestException, OrmException, IOException {
+      throws AuthException, BadRequestException, OrmException, IOException, ConfigInvalidException {
     if (!self.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
     return reviewersUtil.suggestReviewers(
         rsrc.getNotes(),
         this,
-        rsrc.getControl().getProjectControl(),
+        projectCache.checkedGet(rsrc.getProject()),
         getVisibility(rsrc),
         excludeGroups);
   }
 
-  private VisibilityControl getVisibility(final ChangeResource rsrc) {
-    if (rsrc.getControl().getRefControl().isVisibleByRegisteredUsers()) {
-      return new VisibilityControl() {
-        @Override
-        public boolean isVisibleTo(Account.Id account) throws OrmException {
-          return true;
-        }
-      };
-    }
+  private VisibilityControl getVisibility(ChangeResource rsrc) {
+    // Use the destination reference, not the change, as drafts may deny
+    // anyone who is not already a reviewer.
+    // TODO(hiesel) Replace this with a check on the change resource once support for drafts was
+    // removed
+    PermissionBackend.ForRef perm = permissionBackend.user(self).ref(rsrc.getChange().getDest());
     return new VisibilityControl() {
       @Override
       public boolean isVisibleTo(Account.Id account) throws OrmException {
         IdentifiedUser who = identifiedUserFactory.create(account);
-        // we can't use changeControl directly as it won't suggest reviewers
-        // to drafts
-        return rsrc.getControl().forUser(who).isRefVisible();
+        return perm.user(who).testOrFalse(RefPermission.READ);
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
index e1d8f17..6124f42 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewersUtil;
-import com.google.gerrit.server.account.AccountVisibility;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
index 524f4d6..1792c83 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitRule.java
@@ -40,6 +40,7 @@
   private final ChangeData.Factory changeDataFactory;
   private final RulesCache rules;
   private final AccountLoader.Factory accountInfoFactory;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   @Option(name = "--filters", usage = "impact of filters in parent projects")
   private Filters filters = Filters.RUN;
@@ -49,11 +50,13 @@
       Provider<ReviewDb> db,
       ChangeData.Factory changeDataFactory,
       RulesCache rules,
-      AccountLoader.Factory infoFactory) {
+      AccountLoader.Factory infoFactory,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.rules = rules;
     this.accountInfoFactory = infoFactory;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
   }
 
   @Override
@@ -67,7 +70,8 @@
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
     SubmitRuleEvaluator evaluator =
-        new SubmitRuleEvaluator(changeDataFactory.create(db.get(), rsrc.getControl()));
+        submitRuleEvaluatorFactory.create(
+            rsrc.getUser(), changeDataFactory.create(db.get(), rsrc.getNotes()));
 
     List<SubmitRecord> records =
         evaluator
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
index b19f1d1..ca6f9cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/TestSubmitType.java
@@ -36,15 +36,21 @@
   private final Provider<ReviewDb> db;
   private final ChangeData.Factory changeDataFactory;
   private final RulesCache rules;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   @Option(name = "--filters", usage = "impact of filters in parent projects")
   private Filters filters = Filters.RUN;
 
   @Inject
-  TestSubmitType(Provider<ReviewDb> db, ChangeData.Factory changeDataFactory, RulesCache rules) {
+  TestSubmitType(
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      RulesCache rules,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
     this.db = db;
     this.changeDataFactory = changeDataFactory;
     this.rules = rules;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
   }
 
   @Override
@@ -58,7 +64,8 @@
     }
     input.filters = MoreObjects.firstNonNull(input.filters, filters);
     SubmitRuleEvaluator evaluator =
-        new SubmitRuleEvaluator(changeDataFactory.create(db.get(), rsrc.getControl()));
+        submitRuleEvaluatorFactory.create(
+            rsrc.getUser(), changeDataFactory.create(db.get(), rsrc.getNotes()));
 
     SubmitTypeRecord rec =
         evaluator
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.java
new file mode 100644
index 0000000..2bad16c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Unignore.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.server.change;
+
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class Unignore
+    implements RestModifyView<ChangeResource, Unignore.Input>, UiAction<ChangeResource> {
+  private static final Logger log = LoggerFactory.getLogger(Unignore.class);
+
+  public static class Input {}
+
+  private final StarredChangesUtil stars;
+
+  @Inject
+  Unignore(StarredChangesUtil stars) {
+    this.stars = stars;
+  }
+
+  @Override
+  public Description getDescription(ChangeResource rsrc) {
+    return new UiAction.Description()
+        .setLabel("Unignore")
+        .setTitle("Unignore the change")
+        .setVisible(isIgnored(rsrc));
+  }
+
+  @Override
+  public Response<String> apply(ChangeResource rsrc, Input input)
+      throws OrmException, IllegalLabelException {
+    if (isIgnored(rsrc)) {
+      stars.unignore(rsrc);
+    }
+    return Response.ok("");
+  }
+
+  private boolean isIgnored(ChangeResource rsrc) {
+    try {
+      return stars.isIgnored(rsrc);
+    } catch (OrmException e) {
+      log.error("failed to check ignored star", e);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
index b2ca405..c2631d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Votes.java
@@ -84,9 +84,12 @@
       Iterable<PatchSetApproval> byPatchSetUser =
           approvalsUtil.byPatchSetUser(
               db.get(),
-              rsrc.getControl(),
+              rsrc.getChangeResource().getNotes(),
+              rsrc.getChangeResource().getUser(),
               rsrc.getChange().currentPatchSetId(),
-              rsrc.getReviewerUser().getAccountId());
+              rsrc.getReviewerUser().getAccountId(),
+              null,
+              null);
       for (PatchSetApproval psa : byPatchSetUser) {
         votes.put(psa.getLabel(), psa.getValue());
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java
new file mode 100644
index 0000000..28226a7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/WorkInProgressOp.java
@@ -0,0 +1,175 @@
+// 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.change;
+
+import com.google.common.base.MoreObjects;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.restapi.AuthException;
+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.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.extensions.events.WorkInProgressStateChanged;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+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.project.ProjectControl;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+/* Set work in progress or ready for review state on a change */
+public class WorkInProgressOp implements BatchUpdateOp {
+  public static class Input {
+    @Nullable String message;
+
+    @Nullable NotifyHandling notify;
+
+    public Input() {}
+
+    public Input(String message) {
+      this.message = message;
+    }
+  }
+
+  public interface Factory {
+    WorkInProgressOp create(boolean workInProgress, Input in);
+  }
+
+  public static void checkPermissions(
+      PermissionBackend permissionBackend,
+      CurrentUser user,
+      Change change,
+      ProjectControl projectControl)
+      throws PermissionBackendException, AuthException {
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Authentication required");
+    }
+    if (change.getOwner().equals(user.asIdentifiedUser().getAccountId())) {
+      return;
+    }
+
+    try {
+      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+      return;
+    } catch (AuthException e) {
+      // Skip.
+    }
+    if (!projectControl.isOwner()) {
+      throw new AuthException("not allowed to toggle work in progress");
+    }
+  }
+
+  private final ChangeMessagesUtil cmUtil;
+  private final EmailReviewComments.Factory email;
+  private final PatchSetUtil psUtil;
+  private final boolean workInProgress;
+  private final Input in;
+  private final NotifyHandling notify;
+  private final WorkInProgressStateChanged stateChanged;
+
+  private Change change;
+  private ChangeNotes notes;
+  private PatchSet ps;
+  private ChangeMessage cmsg;
+
+  @Inject
+  WorkInProgressOp(
+      ChangeMessagesUtil cmUtil,
+      EmailReviewComments.Factory email,
+      PatchSetUtil psUtil,
+      WorkInProgressStateChanged stateChanged,
+      @Assisted boolean workInProgress,
+      @Assisted Input in) {
+    this.cmUtil = cmUtil;
+    this.email = email;
+    this.psUtil = psUtil;
+    this.stateChanged = stateChanged;
+    this.workInProgress = workInProgress;
+    this.in = in;
+    notify =
+        MoreObjects.firstNonNull(
+            in.notify, workInProgress ? NotifyHandling.NONE : NotifyHandling.ALL);
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) throws OrmException {
+    change = ctx.getChange();
+    notes = ctx.getNotes();
+    ps = psUtil.get(ctx.getDb(), ctx.getNotes(), change.currentPatchSetId());
+    ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
+    change.setWorkInProgress(workInProgress);
+    if (!change.hasReviewStarted() && !workInProgress) {
+      change.setReviewStarted(true);
+    }
+    change.setLastUpdatedOn(ctx.getWhen());
+    update.setWorkInProgress(workInProgress);
+    addMessage(ctx, update);
+    return true;
+  }
+
+  private void addMessage(ChangeContext ctx, ChangeUpdate update) throws OrmException {
+    Change c = ctx.getChange();
+    StringBuilder buf =
+        new StringBuilder(c.isWorkInProgress() ? "Set Work In Progress" : "Set Ready For Review");
+
+    String m = Strings.nullToEmpty(in == null ? null : in.message).trim();
+    if (!m.isEmpty()) {
+      buf.append("\n\n");
+      buf.append(m);
+    }
+
+    cmsg =
+        ChangeMessagesUtil.newMessage(
+            ctx,
+            buf.toString(),
+            c.isWorkInProgress()
+                ? ChangeMessagesUtil.TAG_SET_WIP
+                : ChangeMessagesUtil.TAG_SET_READY);
+
+    cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
+  }
+
+  @Override
+  public void postUpdate(Context ctx) {
+    stateChanged.fire(change, ps, ctx.getAccount(), ctx.getWhen());
+    if (workInProgress || notify.ordinal() < NotifyHandling.OWNER_REVIEWERS.ordinal()) {
+      return;
+    }
+    email
+        .create(
+            notify,
+            ImmutableListMultimap.of(),
+            notes,
+            ps,
+            ctx.getIdentifiedUser(),
+            cmsg,
+            ImmutableList.of(),
+            cmsg.getMessage(),
+            ImmutableList.of())
+        .sendAsync();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsName.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsName.java
index 198d5c5..7719e38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllProjectsName.java
@@ -17,8 +17,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 
 /** Special name of the project that all projects derive from. */
-@SuppressWarnings("serial")
 public class AllProjectsName extends Project.NameKey {
+  private static final long serialVersionUID = 1L;
+
   public AllProjectsName(String name) {
     super(name);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersName.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersName.java
index ff28be4..22d29a4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AllUsersName.java
@@ -17,8 +17,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 
 /** Special name of the project in which meta data for all users is stored. */
-@SuppressWarnings("serial")
 public class AllUsersName extends Project.NameKey {
+  private static final long serialVersionUID = 1L;
+
   public AllUsersName(String name) {
     super(name);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java
index 3f3d6fd..79676f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AnonymousCowardNameProvider.java
@@ -24,7 +24,7 @@
   private final String anonymousCoward;
 
   @Inject
-  public AnonymousCowardNameProvider(@GerritServerConfig final Config cfg) {
+  public AnonymousCowardNameProvider(@GerritServerConfig Config cfg) {
     String anonymousCoward = cfg.getString("user", null, "anonymousCoward");
     if (anonymousCoward == null) {
       anonymousCoward = DEFAULT;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
index 2382809..d3f9186 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthConfig.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.config;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_MAILTO;
-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_MAILTO;
+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.extensions.client.AuthType;
 import com.google.gerrit.extensions.client.GitBasicAuthPolicy;
-import com.google.gerrit.server.account.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.auth.openid.OpenIdProviderPattern;
 import com.google.gwtjsonrpc.server.SignedToken;
 import com.google.gwtjsonrpc.server.XsrfException;
@@ -67,7 +67,7 @@
   private GitBasicAuthPolicy gitBasicAuthPolicy;
 
   @Inject
-  AuthConfig(@GerritServerConfig final Config cfg) throws XsrfException {
+  AuthConfig(@GerritServerConfig Config cfg) throws XsrfException {
     authType = toType(cfg);
     httpHeader = cfg.getString("auth", null, "httpheader");
     httpDisplaynameHeader = cfg.getString("auth", null, "httpdisplaynameheader");
@@ -136,7 +136,7 @@
     return Collections.unmodifiableList(r);
   }
 
-  private static AuthType toType(final Config cfg) {
+  private static AuthType toType(Config cfg) {
     return cfg.getEnum("auth", null, "type", AuthType.OPENID);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
index 7b40786..16c7508 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CacheResource.java
@@ -31,7 +31,7 @@
     this.cacheProvider = cacheProvider;
   }
 
-  public CacheResource(String pluginName, String cacheName, final Cache<?, ?> cache) {
+  public CacheResource(String pluginName, String cacheName, Cache<?, ?> cache) {
     this(
         pluginName,
         cacheName,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
index f002f8d..7ecfa63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CachesCollection.java
@@ -27,8 +27,10 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -40,6 +42,7 @@
 
   private final DynamicMap<RestView<CacheResource>> views;
   private final Provider<ListCaches> list;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
   private final DynamicMap<Cache<?, ?>> cacheMap;
   private final PostCaches postCaches;
@@ -48,11 +51,13 @@
   CachesCollection(
       DynamicMap<RestView<CacheResource>> views,
       Provider<ListCaches> list,
+      PermissionBackend permissionBackend,
       Provider<CurrentUser> self,
       DynamicMap<Cache<?, ?>> cacheMap,
       PostCaches postCaches) {
     this.views = views;
     this.list = list;
+    this.permissionBackend = permissionBackend;
     this.self = self;
     this.cacheMap = cacheMap;
     this.postCaches = postCaches;
@@ -65,15 +70,8 @@
 
   @Override
   public CacheResource parse(ConfigResource parent, IdString id)
-      throws AuthException, ResourceNotFoundException {
-    CurrentUser user = self.get();
-    if (user instanceof AnonymousUser) {
-      throw new AuthException("Authentication required");
-    } else if (!user.isIdentifiedUser()) {
-      throw new ResourceNotFoundException();
-    } else if (!user.getCapabilities().canViewCaches()) {
-      throw new AuthException("not allowed to view caches");
-    }
+      throws AuthException, ResourceNotFoundException, PermissionBackendException {
+    permissionBackend.user(self).check(GlobalPermission.VIEW_CACHES);
 
     String cacheName = id.get();
     String pluginName = "gerrit";
@@ -95,7 +93,6 @@
     return views;
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public PostCaches post(ConfigResource parent) throws RestApiException {
     return postCaches;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java
index e670e2c..539951f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CanonicalWebUrlProvider.java
@@ -23,7 +23,7 @@
   private final String canonicalUrl;
 
   @Inject
-  public CanonicalWebUrlProvider(@GerritServerConfig final Config config) {
+  public CanonicalWebUrlProvider(@GerritServerConfig Config config) {
     String u = config.getString("gerrit", null, "canonicalweburl");
     if (u != null && !u.endsWith("/")) {
       u += "/";
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java
new file mode 100644
index 0000000..eaf45be
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/CheckConsistency.java
@@ -0,0 +1,76 @@
+// 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.config;
+
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckAccountExternalIdsResultInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.CheckAccountsResultInfo;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInput;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountsConsistencyChecker;
+import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
+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 java.io.IOException;
+
+@Singleton
+public class CheckConsistency implements RestModifyView<ConfigResource, ConsistencyCheckInput> {
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final AccountsConsistencyChecker accountsConsistencyChecker;
+  private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
+
+  @Inject
+  CheckConsistency(
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      AccountsConsistencyChecker accountsConsistencyChecker,
+      ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.accountsConsistencyChecker = accountsConsistencyChecker;
+    this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
+  }
+
+  @Override
+  public ConsistencyCheckInfo apply(ConfigResource resource, ConsistencyCheckInput input)
+      throws RestApiException, IOException, OrmException, PermissionBackendException {
+    permissionBackend.user(user).check(GlobalPermission.ACCESS_DATABASE);
+
+    if (input == null || (input.checkAccounts == null && input.checkAccountExternalIds == null)) {
+      throw new BadRequestException("input required");
+    }
+
+    ConsistencyCheckInfo consistencyCheckInfo = new ConsistencyCheckInfo();
+    if (input.checkAccounts != null) {
+      consistencyCheckInfo.checkAccountsResult =
+          new CheckAccountsResultInfo(accountsConsistencyChecker.check());
+    }
+    if (input.checkAccountExternalIds != null) {
+      consistencyCheckInfo.checkAccountExternalIdsResult =
+          new CheckAccountExternalIdsResultInfo(externalIdsConsistencyChecker.check());
+    }
+
+    return consistencyCheckInfo;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java
index 0da1d3b..c6527fd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ConfigUtil.java
@@ -31,7 +31,7 @@
 public class ConfigUtil {
 
   @SuppressWarnings("unchecked")
-  private static <T> T[] allValuesOf(final T defaultValue) {
+  private static <T> T[] allValuesOf(T defaultValue) {
     try {
       return (T[]) defaultValue.getClass().getMethod("values").invoke(null);
     } catch (IllegalArgumentException
@@ -63,7 +63,7 @@
       final T[] all) {
 
     String n = valueString.replace(' ', '_').replace('-', '_');
-    for (final T e : all) {
+    for (T e : all) {
       if (e.name().equalsIgnoreCase(n)) {
         return e;
       }
@@ -81,7 +81,7 @@
     r.append(".");
     r.append(setting);
     r.append("; supported values are: ");
-    for (final T e : all) {
+    for (T e : all) {
       r.append(e.name());
       r.append(" ");
     }
@@ -194,7 +194,7 @@
    *     assume if the value does not contain an indication of the units.
    * @return the setting, or {@code defaultValue} if not set, expressed in {@code units}.
    */
-  public static long getTimeUnit(final String valueString, long defaultValue, TimeUnit wantUnit) {
+  public static long getTimeUnit(String valueString, long defaultValue, TimeUnit wantUnit) {
     Matcher m = Pattern.compile("^(0|[1-9][0-9]*)\\s*(.*)$").matcher(valueString);
     if (!m.matches()) {
       return defaultValue;
@@ -410,8 +410,8 @@
     return Integer.class == t || int.class == t;
   }
 
-  private static boolean match(final String a, final String... cases) {
-    for (final String b : cases) {
+  private static boolean match(String a, String... cases) {
+    for (String b : cases) {
       if (b != null && b.equalsIgnoreCase(a)) {
         return true;
       }
@@ -434,7 +434,7 @@
             + valueString);
   }
 
-  private static IllegalArgumentException notTimeUnit(final String val) {
+  private static IllegalArgumentException notTimeUnit(String val) {
     return new IllegalArgumentException("Invalid time unit value: " + val);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java
index 48d4507..e9d5e5e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/DownloadConfig.java
@@ -40,7 +40,7 @@
   private final ImmutableSet<ArchiveFormat> archiveFormats;
 
   @Inject
-  DownloadConfig(@GerritServerConfig final Config cfg) {
+  DownloadConfig(@GerritServerConfig Config cfg) {
     String[] allSchemes = cfg.getStringList("download", null, "scheme");
     if (allSchemes.length == 0) {
       downloadSchemes =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/EmailExpanderProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/EmailExpanderProvider.java
index 1c42c09..734bf03 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/EmailExpanderProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/EmailExpanderProvider.java
@@ -23,7 +23,7 @@
   private final EmailExpander expander;
 
   @Inject
-  EmailExpanderProvider(@GerritServerConfig final Config cfg) {
+  EmailExpanderProvider(@GerritServerConfig Config cfg) {
     final String s = cfg.getString("auth", null, "emailformat");
     if (EmailExpander.Simple.canHandle(s)) {
       expander = new EmailExpander.Simple(s);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
index 5e19091..366dae1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/FlushCache.java
@@ -23,6 +23,9 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.FlushCache.Input;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -34,17 +37,20 @@
 
   public static final String WEB_SESSIONS = "web_sessions";
 
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> self;
 
   @Inject
-  public FlushCache(Provider<CurrentUser> self) {
+  public FlushCache(PermissionBackend permissionBackend, Provider<CurrentUser> self) {
+    this.permissionBackend = permissionBackend;
     this.self = self;
   }
 
   @Override
-  public Response<String> apply(CacheResource rsrc, Input input) throws AuthException {
-    if (WEB_SESSIONS.equals(rsrc.getName()) && !self.get().getCapabilities().canMaintainServer()) {
-      throw new AuthException(String.format("only site maintainers can flush %s", WEB_SESSIONS));
+  public Response<String> apply(CacheResource rsrc, Input input)
+      throws AuthException, PermissionBackendException {
+    if (WEB_SESSIONS.equals(rsrc.getName())) {
+      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
     }
 
     rsrc.getCache().invalidateAll();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritConfig.java
index fdc3b7f..0715f8e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritConfig.java
@@ -18,10 +18,12 @@
 import org.eclipse.jgit.lib.Config;
 
 class GerritConfig extends Config {
+  private final Config gerritConfig;
   private final SecureStore secureStore;
 
-  GerritConfig(Config baseConfig, SecureStore secureStore) {
-    super(baseConfig);
+  GerritConfig(Config noteDbConfigOverGerritConfig, Config gerritConfig, SecureStore secureStore) {
+    super(noteDbConfigOverGerritConfig);
+    this.gerritConfig = gerritConfig;
     this.secureStore = secureStore;
   }
 
@@ -42,4 +44,11 @@
     }
     return super.getStringList(section, subsection, name);
   }
+
+  @Override
+  public String toText() {
+    // Only show the contents of gerrit.config, hiding the implementation detail that some values
+    // may come from secure.config (or another secure store) and notedb.config.
+    return gerritConfig.toText();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 4e0096b..773d4a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -17,7 +17,6 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.cache.Cache;
-import com.google.gerrit.audit.AuditModule;
 import com.google.gerrit.common.EventListener;
 import com.google.gerrit.common.UserScopedEventListener;
 import com.google.gerrit.extensions.annotations.Exports;
@@ -25,6 +24,7 @@
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
 import com.google.gerrit.extensions.auth.oauth.OAuthTokenEncrypter;
+import com.google.gerrit.extensions.common.AccountVisibility;
 import com.google.gerrit.extensions.config.CapabilityDefinition;
 import com.google.gerrit.extensions.config.CloneCommand;
 import com.google.gerrit.extensions.config.DownloadCommand;
@@ -41,7 +41,6 @@
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.ChangeRevertedListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
-import com.google.gerrit.extensions.events.DraftPublishedListener;
 import com.google.gerrit.extensions.events.GarbageCollectorListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
@@ -50,6 +49,7 @@
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
 import com.google.gerrit.extensions.events.PluginEventListener;
+import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
 import com.google.gerrit.extensions.events.ReviewerDeletedListener;
@@ -57,6 +57,7 @@
 import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.UsageDataPublishedListener;
 import com.google.gerrit.extensions.events.VoteDeletedListener;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
@@ -75,27 +76,26 @@
 import com.google.gerrit.rules.RulesCache;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.CmdLineParserModule;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.AccountByEmailCacheImpl;
 import com.google.gerrit.server.account.AccountCacheImpl;
 import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.AccountManager;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.AccountVisibility;
 import com.google.gerrit.server.account.AccountVisibilityProvider;
 import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.ChangeUserName;
 import com.google.gerrit.server.account.EmailExpander;
 import com.google.gerrit.server.account.GroupCacheImpl;
 import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.account.GroupDetailFactory;
 import com.google.gerrit.server.account.GroupIncludeCacheImpl;
 import com.google.gerrit.server.account.GroupMembers;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalIdModule;
 import com.google.gerrit.server.api.accounts.AccountExternalIdCreator;
 import com.google.gerrit.server.auth.AuthBackend;
 import com.google.gerrit.server.auth.UniversalAuthBackend;
@@ -110,6 +110,7 @@
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.AbandonOp;
 import com.google.gerrit.server.git.ChangeMessageModifier;
 import com.google.gerrit.server.git.EmailMerge;
@@ -119,14 +120,15 @@
 import com.google.gerrit.server.git.MergedByPushOp;
 import com.google.gerrit.server.git.NotesBranchUtil;
 import com.google.gerrit.server.git.ReceivePackInitializer;
-import com.google.gerrit.server.git.ReplaceOp;
-import com.google.gerrit.server.git.SubmoduleOp;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.ReceiveCommitsModule;
 import com.google.gerrit.server.git.strategy.SubmitStrategy;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.MergeValidationListener;
 import com.google.gerrit.server.git.validators.MergeValidators;
+import com.google.gerrit.server.git.validators.MergeValidators.AccountMergeValidator;
 import com.google.gerrit.server.git.validators.MergeValidators.ProjectConfigValidator;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
@@ -135,7 +137,7 @@
 import com.google.gerrit.server.git.validators.UploadValidationListener;
 import com.google.gerrit.server.git.validators.UploadValidators;
 import com.google.gerrit.server.group.GroupModule;
-import com.google.gerrit.server.index.change.ReindexAfterUpdate;
+import com.google.gerrit.server.index.change.ReindexAfterRefUpdate;
 import com.google.gerrit.server.mail.EmailModule;
 import com.google.gerrit.server.mail.ListMailFilter;
 import com.google.gerrit.server.mail.MailFilter;
@@ -160,16 +162,16 @@
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.project.AccessControlModule;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.CommentLinkProvider;
 import com.google.gerrit.server.project.PermissionCollection;
 import com.google.gerrit.server.project.ProjectCacheImpl;
-import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.ProjectNode;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SectionSortCache;
+import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.ChangeQueryProcessor;
 import com.google.gerrit.server.query.change.ConflictsCacheImpl;
 import com.google.gerrit.server.ssh.SshAddressesModule;
 import com.google.gerrit.server.tools.ToolsCatalog;
@@ -181,8 +183,8 @@
 import com.google.gerrit.server.validators.HashtagValidationListener;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
-import com.google.gitiles.blame.BlameCache;
-import com.google.gitiles.blame.BlameCacheImpl;
+import com.google.gitiles.blame.cache.BlameCache;
+import com.google.gitiles.blame.cache.BlameCacheImpl;
 import com.google.inject.Inject;
 import com.google.inject.TypeLiteral;
 import com.google.inject.internal.UniqueAnnotations;
@@ -214,10 +216,10 @@
     bind(BlameCache.class).to(BlameCacheImpl.class);
     bind(Sequences.class);
     install(authModule);
-    install(AccountByEmailCacheImpl.module());
-    install(AccountCacheImpl.module(true));
+    install(AccountCacheImpl.module());
     install(BatchUpdate.module());
     install(ChangeKindCacheImpl.module());
+    install(ChangeFinder.module());
     install(ConflictsCacheImpl.module());
     install(GroupCacheImpl.module());
     install(GroupIncludeCacheImpl.module());
@@ -232,10 +234,12 @@
     install(new AccessControlModule());
     install(new CmdLineParserModule());
     install(new EmailModule());
+    install(new ExternalIdModule());
     install(new GitModule());
     install(new GroupModule());
     install(new NoteDbModule(cfg));
     install(new PrologModule());
+    install(new ReceiveCommitsModule());
     install(new SshAddressesModule());
     install(ThreadLocalRequestContext.module());
 
@@ -245,11 +249,9 @@
     factory(DeleteReviewerSender.Factory.class);
     factory(AddKeySender.Factory.class);
     factory(CapabilityCollection.Factory.class);
-    factory(CapabilityControl.Factory.class);
-    factory(ChangeData.Factory.class);
+    factory(ChangeData.AssistedFactory.class);
     factory(ChangeJson.AssistedFactory.class);
     factory(CreateChangeSender.Factory.class);
-    factory(GroupDetailFactory.Factory.class);
     factory(GroupMembers.Factory.class);
     factory(EmailMerge.Factory.class);
     factory(MergedSender.Factory.class);
@@ -261,9 +263,11 @@
     factory(RegisterNewEmailSender.Factory.class);
     factory(ReplacePatchSetSender.Factory.class);
     factory(SetAssigneeSender.Factory.class);
+    factory(VisibleRefFilter.Factory.class);
     bind(PermissionCollection.Factory.class);
     bind(AccountVisibility.class).toProvider(AccountVisibilityProvider.class).in(SINGLETON);
     factory(ProjectOwnerGroupsProvider.Factory.class);
+    factory(SubmitRuleEvaluator.Factory.class);
 
     bind(AuthBackend.class).to(UniversalAuthBackend.class).in(SINGLETON);
     DynamicSet.setOf(binder(), AuthBackend.class);
@@ -278,6 +282,7 @@
 
     bind(GcConfig.class);
     bind(ChangeCleanupConfig.class);
+    bind(AccountDeactivator.class);
 
     bind(ApprovalsUtil.class);
 
@@ -291,11 +296,9 @@
 
     bind(PatchSetInfoFactory.class);
     bind(IdentifiedUser.GenericFactory.class).in(SINGLETON);
-    bind(ChangeControl.GenericFactory.class);
-    bind(ProjectControl.GenericFactory.class);
     bind(AccountControl.Factory.class);
 
-    install(new AuditModule());
+    bind(UiActions.class);
     install(new com.google.gerrit.server.access.Module());
     install(new com.google.gerrit.server.account.Module());
     install(new com.google.gerrit.server.api.Module());
@@ -313,14 +316,15 @@
     DynamicSet.setOf(binder(), ChangeAbandonedListener.class);
     DynamicSet.setOf(binder(), ChangeDeletedListener.class);
     DynamicSet.setOf(binder(), CommentAddedListener.class);
-    DynamicSet.setOf(binder(), DraftPublishedListener.class);
     DynamicSet.setOf(binder(), HashtagsEditedListener.class);
     DynamicSet.setOf(binder(), ChangeMergedListener.class);
     DynamicSet.setOf(binder(), ChangeRestoredListener.class);
     DynamicSet.setOf(binder(), ChangeRevertedListener.class);
+    DynamicSet.setOf(binder(), PrivateStateChangedListener.class);
     DynamicSet.setOf(binder(), ReviewerAddedListener.class);
     DynamicSet.setOf(binder(), ReviewerDeletedListener.class);
     DynamicSet.setOf(binder(), VoteDeletedListener.class);
+    DynamicSet.setOf(binder(), WorkInProgressStateChangedListener.class);
     DynamicSet.setOf(binder(), RevisionCreatedListener.class);
     DynamicSet.setOf(binder(), TopicEditedListener.class);
     DynamicSet.setOf(binder(), AgreementSignupListener.class);
@@ -337,7 +341,7 @@
     DynamicSet.setOf(binder(), GarbageCollectorListener.class);
     DynamicSet.setOf(binder(), HeadUpdatedListener.class);
     DynamicSet.setOf(binder(), UsageDataPublishedListener.class);
-    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterUpdate.class);
+    DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(ReindexAfterRefUpdate.class);
     DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
         .to(ProjectConfigEntry.UpdateChecker.class);
     DynamicSet.setOf(binder(), EventListener.class);
@@ -386,21 +390,22 @@
 
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeHasOperandFactory.class);
+    DynamicMap.mapOf(binder(), ChangeQueryProcessor.ChangeAttributeFactory.class);
+
     install(new GitwebConfig.LegacyModule(cfg));
 
     bind(AnonymousUser.class);
 
     factory(AbandonOp.Factory.class);
+    factory(AccountMergeValidator.Factory.class);
     factory(RefOperationValidators.Factory.class);
     factory(OnSubmitValidators.Factory.class);
     factory(MergeValidators.Factory.class);
     factory(ProjectConfigValidator.Factory.class);
     factory(NotesBranchUtil.Factory.class);
-    factory(ReplaceOp.Factory.class);
     factory(MergedByPushOp.Factory.class);
     factory(GitModules.Factory.class);
     factory(VersionedAuthorizedKeys.Factory.class);
-    factory(SubmoduleOp.Factory.class);
 
     bind(AccountManager.class);
     factory(ChangeUserName.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigModule.java
index 100a7cd..a93d1f2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigModule.java
@@ -32,7 +32,7 @@
 
 /** Creates {@link GerritServerConfig}. */
 public class GerritServerConfigModule extends AbstractModule {
-  public static String getSecureStoreClassName(final Path sitePath) {
+  public static String getSecureStoreClassName(Path sitePath) {
     if (sitePath != null) {
       return getSecureStoreFromGerritConfig(sitePath);
     }
@@ -41,7 +41,7 @@
     return nullToDefault(secureStoreProperty);
   }
 
-  private static String getSecureStoreFromGerritConfig(final Path sitePath) {
+  private static String getSecureStoreFromGerritConfig(Path sitePath) {
     AbstractModule m =
         new AbstractModule() {
           @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
index 494b63a..82fb6ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerConfigProvider.java
@@ -14,11 +14,18 @@
 
 package com.google.gerrit.server.config;
 
+import static java.util.stream.Collectors.joining;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.securestore.SecureStore;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
@@ -26,8 +33,15 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-/** Provides {@link Config} annotated with {@link GerritServerConfig}. */
-class GerritServerConfigProvider implements Provider<Config> {
+/**
+ * Provides {@link Config} annotated with {@link GerritServerConfig}.
+ *
+ * <p>Note that this class is not a singleton, so the few callers that need a reloaded-on-demand
+ * config can inject a {@code GerritServerConfigProvider}. However, most callers won't need this,
+ * and will just inject {@code @GerritServerConfig Config} directly, which is bound as a singleton
+ * in {@link GerritServerConfigModule}.
+ */
+public class GerritServerConfigProvider implements Provider<Config> {
   private static final Logger log = LoggerFactory.getLogger(GerritServerConfigProvider.class);
 
   private final SitePaths site;
@@ -41,19 +55,46 @@
 
   @Override
   public Config get() {
-    FileBasedConfig cfg = new FileBasedConfig(site.gerrit_config.toFile(), FS.DETECTED);
-
-    if (!cfg.getFile().exists()) {
+    FileBasedConfig baseConfig = loadConfig(null, site.gerrit_config);
+    if (!baseConfig.getFile().exists()) {
       log.info("No " + site.gerrit_config.toAbsolutePath() + "; assuming defaults");
-      return new GerritConfig(cfg, secureStore);
     }
 
+    FileBasedConfig noteDbConfigOverBaseConfig = loadConfig(baseConfig, site.notedb_config);
+    checkNoteDbConfig(noteDbConfigOverBaseConfig);
+
+    return new GerritConfig(noteDbConfigOverBaseConfig, baseConfig, secureStore);
+  }
+
+  private static FileBasedConfig loadConfig(@Nullable Config base, Path path) {
+    FileBasedConfig cfg = new FileBasedConfig(base, path.toFile(), FS.DETECTED);
     try {
       cfg.load();
     } catch (IOException | ConfigInvalidException e) {
       throw new ProvisionException(e.getMessage(), e);
     }
+    return cfg;
+  }
 
-    return new GerritConfig(cfg, secureStore);
+  private static void checkNoteDbConfig(FileBasedConfig noteDbConfig) {
+    List<String> bad = new ArrayList<>();
+    for (String section : noteDbConfig.getSections()) {
+      if (section.equals(NotesMigration.SECTION_NOTE_DB)) {
+        continue;
+      }
+      for (String subsection : noteDbConfig.getSubsections(section)) {
+        noteDbConfig
+            .getNames(section, subsection, false)
+            .forEach(n -> bad.add(section + "." + subsection + "." + n));
+      }
+      noteDbConfig.getNames(section, false).forEach(n -> bad.add(section + "." + n));
+    }
+    if (!bad.isEmpty()) {
+      throw new ProvisionException(
+          "Non-NoteDb config options not allowed in "
+              + noteDbConfig.getFile()
+              + ":\n"
+              + bad.stream().collect(joining("\n")));
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java
index 83b60e2f1..dd84d78 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritServerIdProvider.java
@@ -46,11 +46,11 @@
       return;
     }
 
-    // We're not generally supposed to do work in provider constructors, but
-    // this is a bit of a special case because we really need to have the ID
-    // available by the time the dbInjector is created. This even applies during
-    // RebuildNoteDb, which otherwise would have been a reasonable place to do
-    // the ID generation. Fortunately, it's not much work, and it happens once.
+    // We're not generally supposed to do work in provider constructors, but this is a bit of a
+    // special case because we really need to have the ID available by the time the dbInjector
+    // is created. This even applies during MigrateToNoteDb, which otherwise would have been a
+    // reasonable place to do the ID generation. Fortunately, it's not much work, and it happens
+    // once.
     id = generate();
     Config newCfg = readGerritConfig(sitePaths);
     newCfg.setString(SECTION, null, KEY, id);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
index c0da3f3..7a1031e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GetServerInfo.java
@@ -21,6 +21,7 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.ContributorAgreement;
 import com.google.gerrit.extensions.client.UiType;
+import com.google.gerrit.extensions.common.AccountsInfo;
 import com.google.gerrit.extensions.common.AuthInfo;
 import com.google.gerrit.extensions.common.ChangeConfigInfo;
 import com.google.gerrit.extensions.common.DownloadInfo;
@@ -41,6 +42,7 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
 import com.google.gerrit.server.EnableSignedPush;
+import com.google.gerrit.server.account.AccountVisibilityProvider;
 import com.google.gerrit.server.account.Realm;
 import com.google.gerrit.server.avatar.AvatarProvider;
 import com.google.gerrit.server.change.AllowedFormats;
@@ -53,6 +55,7 @@
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.inject.Inject;
 import java.net.MalformedURLException;
+import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.EnumSet;
@@ -68,6 +71,7 @@
   private static final String KEY_TOKEN = "token";
 
   private final Config config;
+  private final AccountVisibilityProvider accountVisibilityProvider;
   private final AuthConfig authConfig;
   private final Realm realm;
   private final DynamicMap<DownloadScheme> downloadSchemes;
@@ -86,10 +90,12 @@
   private final AgreementJson agreementJson;
   private final GerritOptions gerritOptions;
   private final ChangeIndexCollection indexes;
+  private final SitePaths sitePaths;
 
   @Inject
   public GetServerInfo(
       @GerritServerConfig Config config,
+      AccountVisibilityProvider accountVisibilityProvider,
       AuthConfig authConfig,
       Realm realm,
       DynamicMap<DownloadScheme> downloadSchemes,
@@ -107,8 +113,10 @@
       ProjectCache projectCache,
       AgreementJson agreementJson,
       GerritOptions gerritOptions,
-      ChangeIndexCollection indexes) {
+      ChangeIndexCollection indexes,
+      SitePaths sitePaths) {
     this.config = config;
+    this.accountVisibilityProvider = accountVisibilityProvider;
     this.authConfig = authConfig;
     this.realm = realm;
     this.downloadSchemes = downloadSchemes;
@@ -127,11 +135,13 @@
     this.agreementJson = agreementJson;
     this.gerritOptions = gerritOptions;
     this.indexes = indexes;
+    this.sitePaths = sitePaths;
   }
 
   @Override
   public ServerInfo apply(ConfigResource rsrc) throws MalformedURLException {
     ServerInfo info = new ServerInfo();
+    info.accounts = getAccountsInfo(accountVisibilityProvider);
     info.auth = getAuthInfo(authConfig, realm);
     info.change = getChangeInfo(config);
     info.download =
@@ -139,6 +149,9 @@
     info.gerrit = getGerritInfo(config, allProjectsName, allUsersName);
     info.noteDbEnabled = toBoolean(isNoteDbEnabled());
     info.plugin = getPluginInfo();
+    if (Files.exists(sitePaths.site_theme)) {
+      info.defaultTheme = "/static/" + SitePaths.THEME_FILENAME;
+    }
     info.sshd = getSshdInfo(config);
     info.suggest = getSuggestInfo(config);
 
@@ -150,6 +163,12 @@
     return info;
   }
 
+  private AccountsInfo getAccountsInfo(AccountVisibilityProvider accountVisibilityProvider) {
+    AccountsInfo info = new AccountsInfo();
+    info.visibility = accountVisibilityProvider.get();
+    return info;
+  }
+
   private AuthInfo getAuthInfo(AuthConfig cfg, Realm realm) {
     AuthInfo info = new AuthInfo();
     info.authType = cfg.getAuthType();
@@ -203,7 +222,6 @@
   private ChangeConfigInfo getChangeInfo(Config cfg) {
     ChangeConfigInfo info = new ChangeConfigInfo();
     info.allowBlame = toBoolean(cfg.getBoolean("change", "allowBlame", true));
-    info.allowDrafts = toBoolean(cfg.getBoolean("change", "allowDrafts", true));
     boolean hasAssigneeInIndex =
         indexes.getSearchIndex().getSchema().hasField(ChangeField.ASSIGNEE);
     info.showAssigneeInChangesTable =
@@ -216,8 +234,10 @@
     info.replyLabel =
         Optional.ofNullable(cfg.getString("change", null, "replyLabel")).orElse("Reply") + "\u2026";
     info.updateDelay =
-        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 30, TimeUnit.SECONDS);
+        (int) ConfigUtil.getTimeUnit(cfg, "change", null, "updateDelay", 300, TimeUnit.SECONDS);
     info.submitWholeTopic = Submit.wholeTopicEnabled(cfg);
+    info.disablePrivateChanges =
+        toBoolean(config.getBoolean("change", null, "disablePrivateChanges", false));
     return info;
   }
 
@@ -310,9 +330,15 @@
     PluginConfigInfo info = new PluginConfigInfo();
     info.hasAvatars = toBoolean(avatar.get() != null);
     info.jsResourcePaths = new ArrayList<>();
+    info.htmlResourcePaths = new ArrayList<>();
     for (WebUiPlugin u : plugins) {
-      info.jsResourcePaths.add(
-          String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath()));
+      String path =
+          String.format("plugins/%s/%s", u.getPluginName(), u.getJavaScriptResourcePath());
+      if (path.endsWith(".html")) {
+        info.htmlResourcePaths.add(path);
+      } else {
+        info.jsResourcePaths.add(path);
+      }
     }
     return info;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
index 7e9bd71..bbda9eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ListTasks.java
@@ -19,13 +19,14 @@
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.TaskInfoFactory;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.ProjectTask;
 import com.google.gerrit.server.git.WorkQueue.Task;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
+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.util.IdGenerator;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -41,37 +42,49 @@
 
 @Singleton
 public class ListTasks implements RestReadView<ConfigResource> {
+  private final PermissionBackend permissionBackend;
   private final WorkQueue workQueue;
-  private final ProjectCache projectCache;
-  private final Provider<IdentifiedUser> self;
+  private final Provider<CurrentUser> self;
 
   @Inject
-  public ListTasks(WorkQueue workQueue, ProjectCache projectCache, Provider<IdentifiedUser> self) {
+  public ListTasks(
+      PermissionBackend permissionBackend, WorkQueue workQueue, Provider<CurrentUser> self) {
+    this.permissionBackend = permissionBackend;
     this.workQueue = workQueue;
-    this.projectCache = projectCache;
     this.self = self;
   }
 
   @Override
-  public List<TaskInfo> apply(ConfigResource resource) throws AuthException {
+  public List<TaskInfo> apply(ConfigResource resource)
+      throws AuthException, PermissionBackendException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
 
     List<TaskInfo> allTasks = getTasks();
-    if (user.getCapabilities().canViewQueue()) {
+    try {
+      permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
       return allTasks;
+    } catch (AuthException e) {
+      // Fall through to filter tasks.
     }
-    Map<String, Boolean> visibilityCache = new HashMap<>();
 
+    Map<String, Boolean> visibilityCache = new HashMap<>();
     List<TaskInfo> visibleTasks = new ArrayList<>();
     for (TaskInfo task : allTasks) {
       if (task.projectName != null) {
         Boolean visible = visibilityCache.get(task.projectName);
         if (visible == null) {
-          ProjectState e = projectCache.get(new Project.NameKey(task.projectName));
-          visible = e != null ? e.controlFor(user).isVisible() : false;
+          try {
+            permissionBackend
+                .user(user)
+                .project(new Project.NameKey(task.projectName))
+                .check(ProjectPermission.ACCESS);
+            visible = true;
+          } catch (AuthException e) {
+            visible = false;
+          }
           visibilityCache.put(task.projectName, visible);
         }
         if (visible) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
index a05058e..7bf5ad5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/Module.java
@@ -36,6 +36,7 @@
     child(CONFIG_KIND, "top-menus").to(TopMenuCollection.class);
     get(CONFIG_KIND, "version").to(GetVersion.class);
     get(CONFIG_KIND, "info").to(GetServerInfo.class);
+    post(CONFIG_KIND, "check.consistency").to(CheckConsistency.class);
     get(CONFIG_KIND, "preferences").to(GetPreferences.class);
     put(CONFIG_KIND, "preferences").to(SetPreferences.class);
     get(CONFIG_KIND, "preferences.diff").to(GetDiffPreferences.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
index 3cfa2b9..d08f0a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/PostCaches.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.config.PostCaches.Input;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
@@ -66,7 +67,8 @@
 
   @Override
   public Response<String> apply(ConfigResource rsrc, Input input)
-      throws AuthException, BadRequestException, UnprocessableEntityException {
+      throws AuthException, BadRequestException, UnprocessableEntityException,
+          PermissionBackendException {
     if (input == null || input.operation == null) {
       throw new BadRequestException("operation must be specified");
     }
@@ -90,7 +92,7 @@
     }
   }
 
-  private void flushAll() throws AuthException {
+  private void flushAll() throws AuthException, PermissionBackendException {
     for (DynamicMap.Entry<Cache<?, ?>> e : cacheMap) {
       CacheResource cacheResource =
           new CacheResource(e.getPluginName(), e.getExportName(), e.getProvider());
@@ -101,7 +103,8 @@
     }
   }
 
-  private void flush(List<String> cacheNames) throws UnprocessableEntityException, AuthException {
+  private void flush(List<String> cacheNames)
+      throws UnprocessableEntityException, AuthException, PermissionBackendException {
     List<CacheResource> cacheResources = new ArrayList<>(cacheNames.size());
 
     for (String n : cacheNames) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
index 33e68d3..a2e0356 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/ProjectOwnerGroupsProvider.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.util.ServerRequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 
 /**
  * Provider of the group(s) which should become owners of a newly created project. The only matching
@@ -40,7 +40,7 @@
     ProjectOwnerGroupsProvider create(Project.NameKey project);
   }
 
-  @AssistedInject
+  @Inject
   public ProjectOwnerGroupsProvider(
       GroupBackend gb,
       ThreadLocalRequestContext context,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
index 3987aed..fdb400b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/RequestScopedReviewDbProvider.java
@@ -32,7 +32,7 @@
 
   @Inject
   public RequestScopedReviewDbProvider(
-      final SchemaFactory<ReviewDb> schema, final Provider<RequestCleanup> cleanup) {
+      final SchemaFactory<ReviewDb> schema, Provider<RequestCleanup> cleanup) {
     this.schema = schema;
     this.cleanup = cleanup;
   }
@@ -41,7 +41,7 @@
   @Override
   public ReviewDb get() {
     if (db == null) {
-      final ReviewDb c;
+      ReviewDb c;
       try {
         c = schema.open();
       } catch (OrmException e) {
@@ -51,12 +51,9 @@
         cleanup
             .get()
             .add(
-                new Runnable() {
-                  @Override
-                  public void run() {
-                    c.close();
-                    db = null;
-                  }
+                () -> {
+                  c.close();
+                  db = null;
                 });
       } catch (Throwable e) {
         c.close();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
index 4792131..cc96cf0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SetPreferences.java
@@ -75,7 +75,7 @@
   }
 
   private GeneralPreferencesInfo writeToGit(GeneralPreferencesInfo i)
-      throws RepositoryNotFoundException, IOException, ConfigInvalidException {
+      throws RepositoryNotFoundException, IOException, ConfigInvalidException, BadRequestException {
     try (MetaDataUpdate md = metaDataUpdateFactory.get().create(allUsersName)) {
       VersionedAccountPreferences p = VersionedAccountPreferences.forDefault();
       p.load(md);
@@ -85,7 +85,7 @@
       com.google.gerrit.server.account.SetPreferences.storeUrlAliases(p, i.urlAliases);
       p.commit(md);
 
-      accountCache.evictAll();
+      accountCache.evictAllNoReindex();
 
       GeneralPreferencesInfo r =
           loadSection(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
index 87f22e0..3748bfd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/SitePaths.java
@@ -29,6 +29,7 @@
   public static final String CSS_FILENAME = "GerritSite.css";
   public static final String HEADER_FILENAME = "GerritSiteHeader.html";
   public static final String FOOTER_FILENAME = "GerritSiteFooter.html";
+  public static final String THEME_FILENAME = "gerrit-theme.html";
 
   public final Path site_path;
   public final Path bin_dir;
@@ -52,6 +53,7 @@
 
   public final Path gerrit_config;
   public final Path secure_config;
+  public final Path notedb_config;
 
   public final Path ssl_keystore;
   public final Path ssh_key;
@@ -66,6 +68,8 @@
   public final Path site_css;
   public final Path site_header;
   public final Path site_footer;
+  // For PolyGerrit UI only.
+  public final Path site_theme;
   public final Path site_gitweb;
 
   /** {@code true} if {@link #site_path} has not been initialized. */
@@ -97,6 +101,7 @@
 
     gerrit_config = etc_dir.resolve("gerrit.config");
     secure_config = etc_dir.resolve("secure.config");
+    notedb_config = etc_dir.resolve("notedb.config");
 
     ssl_keystore = etc_dir.resolve("keystore");
     ssh_key = etc_dir.resolve("ssh_host_key");
@@ -113,6 +118,9 @@
     site_footer = etc_dir.resolve(FOOTER_FILENAME);
     site_gitweb = etc_dir.resolve("gitweb_config.perl");
 
+    // For PolyGerrit UI.
+    site_theme = static_dir.resolve(THEME_FILENAME);
+
     boolean isNew;
     try (DirectoryStream<Path> files = Files.newDirectoryStream(site_path)) {
       isNew = Iterables.isEmpty(files);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
index b239856..fcaee8e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TasksCollection.java
@@ -21,12 +21,13 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.ProjectTask;
 import com.google.gerrit.server.git.WorkQueue.Task;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
+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.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -36,21 +37,21 @@
   private final DynamicMap<RestView<TaskResource>> views;
   private final ListTasks list;
   private final WorkQueue workQueue;
-  private final Provider<IdentifiedUser> self;
-  private final ProjectCache projectCache;
+  private final Provider<CurrentUser> self;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   TasksCollection(
       DynamicMap<RestView<TaskResource>> views,
       ListTasks list,
       WorkQueue workQueue,
-      Provider<IdentifiedUser> self,
-      ProjectCache projectCache) {
+      Provider<CurrentUser> self,
+      PermissionBackend permissionBackend) {
     this.views = views;
     this.list = list;
     this.workQueue = workQueue;
     this.self = self;
-    this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -60,30 +61,42 @@
 
   @Override
   public TaskResource parse(ConfigResource parent, IdString id)
-      throws ResourceNotFoundException, AuthException {
+      throws ResourceNotFoundException, AuthException, PermissionBackendException {
     CurrentUser user = self.get();
     if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
 
+    int taskId;
     try {
-      int taskId = (int) Long.parseLong(id.get(), 16);
-      Task<?> task = workQueue.getTask(taskId);
-      if (task != null) {
-        if (self.get().getCapabilities().canViewQueue()) {
-          return new TaskResource(task);
-        } else if (task instanceof ProjectTask) {
-          ProjectTask<?> projectTask = ((ProjectTask<?>) task);
-          ProjectState e = projectCache.get(projectTask.getProjectNameKey());
-          if (e != null && e.controlFor(user).isVisible()) {
-            return new TaskResource(task);
-          }
-        }
-      }
-      throw new ResourceNotFoundException(id);
+      taskId = (int) Long.parseLong(id.get(), 16);
     } catch (NumberFormatException e) {
       throw new ResourceNotFoundException(id);
     }
+
+    Task<?> task = workQueue.getTask(taskId);
+    if (task instanceof ProjectTask) {
+      try {
+        permissionBackend
+            .user(user)
+            .project(((ProjectTask<?>) task).getProjectNameKey())
+            .check(ProjectPermission.ACCESS);
+        return new TaskResource(task);
+      } catch (AuthException e) {
+        // Fall through and try view queue permission.
+      }
+    }
+
+    if (task != null) {
+      try {
+        permissionBackend.user(user).check(GlobalPermission.VIEW_QUEUE);
+        return new TaskResource(task);
+      } catch (AuthException e) {
+        // Fall through and return not found.
+      }
+    }
+
+    throw new ResourceNotFoundException(id);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooter.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooter.java
index ac2f0c6..ddd2877 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooter.java
@@ -25,7 +25,7 @@
   private final Pattern match;
   private final String system;
 
-  public TrackingFooter(String f, final String m, final String s) throws PatternSyntaxException {
+  public TrackingFooter(String f, String m, String s) throws PatternSyntaxException {
     f = f.trim();
     if (f.endsWith(":")) {
       f = f.substring(0, f.length() - 1);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooters.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooters.java
index a897bdc..85528d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooters.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFooters.java
@@ -23,7 +23,7 @@
 public class TrackingFooters {
   protected List<TrackingFooter> trackingFooters;
 
-  public TrackingFooters(final List<TrackingFooter> trFooters) {
+  public TrackingFooters(List<TrackingFooter> trFooters) {
     trackingFooters = trFooters;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFootersProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFootersProvider.java
index 103cb9a..7b23fcc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFootersProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/TrackingFootersProvider.java
@@ -40,7 +40,7 @@
   private static final Logger log = LoggerFactory.getLogger(TrackingFootersProvider.class);
 
   @Inject
-  TrackingFootersProvider(@GerritServerConfig final Config cfg) {
+  TrackingFootersProvider(@GerritServerConfig Config cfg) {
     for (String name : cfg.getSubsections(TRACKING_ID_TAG)) {
       boolean configValid = true;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
index 1a8a788..ec76f50 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/ChangeAttribute.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.data;
 
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gson.annotations.SerializedName;
 import java.util.List;
 
 public class ChangeAttribute {
@@ -34,6 +36,10 @@
   public Boolean open;
   public Change.Status status;
   public List<MessageAttribute> comments;
+  public Boolean wip;
+
+  @SerializedName("private")
+  public Boolean isPrivate;
 
   public List<TrackingIdAttribute> trackingIds;
   public PatchSetAttribute currentPatchSet;
@@ -43,4 +49,5 @@
   public List<DependencyAttribute> neededBy;
   public List<SubmitRecordAttribute> submitRecords;
   public List<AccountAttribute> allReviewers;
+  public List<PluginDefinedInfo> plugins;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
index d3b3786..dc47057 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/data/PatchSetAttribute.java
@@ -25,7 +25,6 @@
   public AccountAttribute uploader;
   public Long createdOn;
   public AccountAttribute author;
-  public boolean isDraft;
   public ChangeKind kind;
 
   public List<ApprovalAttribute> approvals;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
index eef6d35..73c2b5e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/documentation/QueryDocumentationExecutor.java
@@ -130,8 +130,9 @@
     return parser != null && searcher != null;
   }
 
-  @SuppressWarnings("serial")
   public static class DocQueryException extends Exception {
+    private static final long serialVersionUID = 1L;
+
     DocQueryException() {}
 
     DocQueryException(String msg) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
index a6464a7..e641abc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEdit.java
@@ -18,11 +18,6 @@
 
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.server.IdentifiedUser;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.revwalk.RevCommit;
 
 /**
@@ -33,44 +28,25 @@
  * change number and P is the patch set number it is based on.
  */
 public class ChangeEdit {
-  private final IdentifiedUser user;
   private final Change change;
-  private final Ref ref;
+  private final String editRefName;
   private final RevCommit editCommit;
   private final PatchSet basePatchSet;
 
   public ChangeEdit(
-      IdentifiedUser user, Change change, Ref ref, RevCommit editCommit, PatchSet basePatchSet) {
-    checkNotNull(user);
-    checkNotNull(change);
-    checkNotNull(ref);
-    checkNotNull(editCommit);
-    checkNotNull(basePatchSet);
-    this.user = user;
-    this.change = change;
-    this.ref = ref;
-    this.editCommit = editCommit;
-    this.basePatchSet = basePatchSet;
+      Change change, String editRefName, RevCommit editCommit, PatchSet basePatchSet) {
+    this.change = checkNotNull(change);
+    this.editRefName = checkNotNull(editRefName);
+    this.editCommit = checkNotNull(editCommit);
+    this.basePatchSet = checkNotNull(basePatchSet);
   }
 
   public Change getChange() {
     return change;
   }
 
-  public IdentifiedUser getUser() {
-    return user;
-  }
-
-  public Ref getRef() {
-    return ref;
-  }
-
-  public RevId getRevision() {
-    return new RevId(ObjectId.toString(ref.getObjectId()));
-  }
-
   public String getRefName() {
-    return RefNames.refsEdit(user.getAccountId(), change.getId(), basePatchSet.getId());
+    return editRefName;
   }
 
   public RevCommit getEditCommit() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
index 78baef7..1024c62 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditJson.java
@@ -52,6 +52,7 @@
     out.commit = fillCommit(edit.getEditCommit());
     out.baseRevision = edit.getBasePatchSet().getRevision().get();
     out.basePatchSetNumber = edit.getBasePatchSet().getPatchSetId();
+    out.ref = edit.getRefName();
     if (downloadCommands) {
       out.fetch = fillFetchMap(edit);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
index 3d1fb79..82fa596 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditModifier.java
@@ -14,11 +14,10 @@
 
 package com.google.gerrit.server.edit;
 
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MergeConflictException;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.reviewdb.client.Change;
@@ -36,14 +35,19 @@
 import com.google.gerrit.server.edit.tree.TreeCreator;
 import com.google.gerrit.server.edit.tree.TreeModification;
 import com.google.gerrit.server.index.change.ChangeIndexer;
-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.InvalidChangeOperationException;
+import com.google.gerrit.server.util.CommitMessageUtil;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
+import java.util.List;
 import java.util.Optional;
 import java.util.TimeZone;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -76,6 +80,7 @@
   private final ChangeIndexer indexer;
   private final Provider<ReviewDb> reviewDb;
   private final Provider<CurrentUser> currentUser;
+  private final PermissionBackend permissionBackend;
   private final ChangeEditUtil changeEditUtil;
   private final PatchSetUtil patchSetUtil;
 
@@ -85,11 +90,13 @@
       ChangeIndexer indexer,
       Provider<ReviewDb> reviewDb,
       Provider<CurrentUser> currentUser,
+      PermissionBackend permissionBackend,
       ChangeEditUtil changeEditUtil,
       PatchSetUtil patchSetUtil) {
     this.indexer = indexer;
     this.reviewDb = reviewDb;
     this.currentUser = currentUser;
+    this.permissionBackend = permissionBackend;
     this.tz = gerritIdent.getTimeZone();
     this.changeEditUtil = changeEditUtil;
     this.patchSetUtil = patchSetUtil;
@@ -99,57 +106,57 @@
    * Creates a new change edit.
    *
    * @param repository the affected Git repository
-   * @param changeControl the {@code ChangeControl} of the change for which the change edit should
-   *     be created
+   * @param notes the {@link ChangeNotes} of the change for which the change edit should be created
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if a change edit already existed for the change
+   * @throws PermissionBackendException
    */
-  public void createEdit(Repository repository, ChangeControl changeControl)
-      throws AuthException, IOException, InvalidChangeOperationException, OrmException {
-    ensureAuthenticatedAndPermitted(changeControl);
+  public void createEdit(Repository repository, ChangeNotes notes)
+      throws AuthException, IOException, InvalidChangeOperationException, OrmException,
+          PermissionBackendException {
+    assertCanEdit(notes);
 
-    Optional<ChangeEdit> changeEdit = lookupChangeEdit(changeControl);
+    Optional<ChangeEdit> changeEdit = lookupChangeEdit(notes);
     if (changeEdit.isPresent()) {
       throw new InvalidChangeOperationException(
-          String.format("A change edit already exists for change %s", changeControl.getId()));
+          String.format("A change edit already exists for change %s", notes.getChangeId()));
     }
 
-    PatchSet currentPatchSet = lookupCurrentPatchSet(changeControl);
+    PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
     ObjectId patchSetCommitId = getPatchSetCommitId(currentPatchSet);
-    createEditReference(
-        repository, changeControl, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
+    createEdit(repository, notes, currentPatchSet, patchSetCommitId, TimeUtil.nowTs());
   }
 
   /**
    * Rebase change edit on latest patch set
    *
    * @param repository the affected Git repository
-   * @param changeControl the {@code ChangeControl} of the change whose change edit should be
-   *     rebased
+   * @param notes the {@link ChangeNotes} of the change whose change edit should be rebased
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if a change edit doesn't exist for the specified
    *     change, the change edit is already based on the latest patch set, or the change represents
    *     the root commit
    * @throws MergeConflictException if rebase fails due to merge conflicts
+   * @throws PermissionBackendException
    */
-  public void rebaseEdit(Repository repository, ChangeControl changeControl)
+  public void rebaseEdit(Repository repository, ChangeNotes notes)
       throws AuthException, InvalidChangeOperationException, IOException, OrmException,
-          MergeConflictException {
-    ensureAuthenticatedAndPermitted(changeControl);
+          MergeConflictException, PermissionBackendException {
+    assertCanEdit(notes);
 
-    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
     if (!optionalChangeEdit.isPresent()) {
       throw new InvalidChangeOperationException(
-          String.format("No change edit exists for change %s", changeControl.getId()));
+          String.format("No change edit exists for change %s", notes.getChangeId()));
     }
     ChangeEdit changeEdit = optionalChangeEdit.get();
 
-    PatchSet currentPatchSet = lookupCurrentPatchSet(changeControl);
+    PatchSet currentPatchSet = lookupCurrentPatchSet(notes);
     if (isBasedOn(changeEdit, currentPatchSet)) {
       throw new InvalidChangeOperationException(
           String.format(
               "Change edit for change %s is already based on latest patch set %s",
-              changeControl.getId(), currentPatchSet.getId()));
+              notes.getChangeId(), currentPatchSet.getId()));
     }
 
     rebase(repository, changeEdit, currentPatchSet);
@@ -189,20 +196,22 @@
    * be created based on the current patch set.
    *
    * @param repository the affected Git repository
-   * @param changeControl the {@code ChangeControl} of the change whose change edit's message should
-   *     be modified
+   * @param notes the {@link ChangeNotes} of the change whose change edit's message should be
+   *     modified
    * @param newCommitMessage the new commit message
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws UnchangedCommitMessageException if the commit message is the same as before
+   * @throws PermissionBackendException
+   * @throws BadRequestException if the commit message is malformed
    */
-  public void modifyMessage(
-      Repository repository, ChangeControl changeControl, String newCommitMessage)
-      throws AuthException, IOException, UnchangedCommitMessageException, OrmException {
-    ensureAuthenticatedAndPermitted(changeControl);
-    newCommitMessage = getWellFormedCommitMessage(newCommitMessage);
+  public void modifyMessage(Repository repository, ChangeNotes notes, String newCommitMessage)
+      throws AuthException, IOException, UnchangedCommitMessageException, OrmException,
+          PermissionBackendException, BadRequestException {
+    assertCanEdit(notes);
+    newCommitMessage = CommitMessageUtil.checkAndSanitizeCommitMessage(newCommitMessage);
 
-    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
-    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, changeControl);
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
+    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, notes);
     RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
     RevCommit baseCommit =
         optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
@@ -218,9 +227,9 @@
         createCommit(repository, basePatchSetCommit, baseTree, newCommitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEditReference(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
     } else {
-      createEditReference(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
+      createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
     }
   }
 
@@ -229,17 +238,18 @@
    * will be created based on the current patch set.
    *
    * @param repository the affected Git repository
-   * @param changeControl the {@code ChangeControl} of the change whose change edit should be
-   *     modified
+   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
    * @param filePath the path of the file whose contents should be modified
    * @param newContent the new file content
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if the file already had the specified content
+   * @throws PermissionBackendException
    */
   public void modifyFile(
-      Repository repository, ChangeControl changeControl, String filePath, RawInput newContent)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
-    modifyTree(repository, changeControl, new ChangeFileContentModification(filePath, newContent));
+      Repository repository, ChangeNotes notes, String filePath, RawInput newContent)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException {
+    modifyTree(repository, notes, new ChangeFileContentModification(filePath, newContent));
   }
 
   /**
@@ -247,15 +257,16 @@
    * will be created based on the current patch set.
    *
    * @param repository the affected Git repository
-   * @param changeControl the {@code ChangeControl} of the change whose change edit should be
-   *     modified
+   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
    * @param file path of the file which should be deleted
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if the file does not exist
+   * @throws PermissionBackendException
    */
-  public void deleteFile(Repository repository, ChangeControl changeControl, String file)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
-    modifyTree(repository, changeControl, new DeleteFileModification(file));
+  public void deleteFile(Repository repository, ChangeNotes notes, String file)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException {
+    modifyTree(repository, notes, new DeleteFileModification(file));
   }
 
   /**
@@ -263,21 +274,19 @@
    * exist, a new one will be created based on the current patch set.
    *
    * @param repository the affected Git repository
-   * @param changeControl the {@code ChangeControl} of the change whose change edit should be
-   *     modified
+   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
    * @param currentFilePath the current path/name of the file
    * @param newFilePath the desired path/name of the file
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if the file was already renamed to the specified new
    *     name
+   * @throws PermissionBackendException
    */
   public void renameFile(
-      Repository repository,
-      ChangeControl changeControl,
-      String currentFilePath,
-      String newFilePath)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
-    modifyTree(repository, changeControl, new RenameFileModification(currentFilePath, newFilePath));
+      Repository repository, ChangeNotes notes, String currentFilePath, String newFilePath)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException {
+    modifyTree(repository, notes, new RenameFileModification(currentFilePath, newFilePath));
   }
 
   /**
@@ -286,29 +295,31 @@
    * current patch set.
    *
    * @param repository the affected Git repository
-   * @param changeControl the {@code ChangeControl} of the change whose change edit should be
-   *     modified
+   * @param notes the {@link ChangeNotes} of the change whose change edit should be modified
    * @param file the path of the file which should be restored
    * @throws AuthException if the user isn't authenticated or not allowed to use change edits
    * @throws InvalidChangeOperationException if the file was already restored
+   * @throws PermissionBackendException
    */
-  public void restoreFile(Repository repository, ChangeControl changeControl, String file)
-      throws AuthException, InvalidChangeOperationException, IOException, OrmException {
-    modifyTree(repository, changeControl, new RestoreFileModification(file));
+  public void restoreFile(Repository repository, ChangeNotes notes, String file)
+      throws AuthException, InvalidChangeOperationException, IOException, OrmException,
+          PermissionBackendException {
+    modifyTree(repository, notes, new RestoreFileModification(file));
   }
 
   private void modifyTree(
-      Repository repository, ChangeControl changeControl, TreeModification treeModification)
-      throws AuthException, IOException, OrmException, InvalidChangeOperationException {
-    ensureAuthenticatedAndPermitted(changeControl);
+      Repository repository, ChangeNotes notes, TreeModification treeModification)
+      throws AuthException, IOException, OrmException, InvalidChangeOperationException,
+          PermissionBackendException {
+    assertCanEdit(notes);
 
-    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(changeControl);
-    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, changeControl);
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
+    PatchSet basePatchSet = getBasePatchSet(optionalChangeEdit, notes);
     RevCommit basePatchSetCommit = lookupCommit(repository, basePatchSet);
     RevCommit baseCommit =
         optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(basePatchSetCommit);
 
-    ObjectId newTreeId = createNewTree(repository, baseCommit, treeModification);
+    ObjectId newTreeId = createNewTree(repository, baseCommit, ImmutableList.of(treeModification));
 
     String commitMessage = baseCommit.getFullMessage();
     Timestamp nowTimestamp = TimeUtil.nowTs();
@@ -316,52 +327,116 @@
         createCommit(repository, basePatchSetCommit, newTreeId, commitMessage, nowTimestamp);
 
     if (optionalChangeEdit.isPresent()) {
-      updateEditReference(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+      updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
     } else {
-      createEditReference(repository, changeControl, basePatchSet, newEditCommit, nowTimestamp);
+      createEdit(repository, notes, basePatchSet, newEditCommit, nowTimestamp);
     }
   }
 
-  private void ensureAuthenticatedAndPermitted(ChangeControl changeControl)
-      throws AuthException, OrmException {
-    ensureAuthenticated();
-    ensurePermitted(changeControl);
+  /**
+   * Applies the indicated modifications to the specified patch set. If a change edit exists and is
+   * based on the same patch set, the modified patch set tree is merged with the change edit. If the
+   * change edit doesn't exist, a new one will be created.
+   *
+   * @param repository the affected Git repository
+   * @param notes the {@link ChangeNotes} of the change to which the patch set belongs
+   * @param patchSet the {@code PatchSet} which should be modified
+   * @param treeModifications the modifications which should be applied
+   * @return the resulting {@code ChangeEdit}
+   * @throws AuthException if the user isn't authenticated or not allowed to use change edits
+   * @throws InvalidChangeOperationException if the existing change edit is based on another patch
+   *     set or no change edit exists but the specified patch set isn't the current one
+   * @throws MergeConflictException if the modified patch set tree can't be merged with an existing
+   *     change edit
+   */
+  public ChangeEdit combineWithModifiedPatchSetTree(
+      Repository repository,
+      ChangeNotes notes,
+      PatchSet patchSet,
+      List<TreeModification> treeModifications)
+      throws AuthException, IOException, InvalidChangeOperationException, MergeConflictException,
+          OrmException, PermissionBackendException {
+    assertCanEdit(notes);
+
+    Optional<ChangeEdit> optionalChangeEdit = lookupChangeEdit(notes);
+    ensureAllowedPatchSet(notes, optionalChangeEdit, patchSet);
+
+    RevCommit patchSetCommit = lookupCommit(repository, patchSet);
+    ObjectId newTreeId = createNewTree(repository, patchSetCommit, treeModifications);
+
+    if (optionalChangeEdit.isPresent()) {
+      ChangeEdit changeEdit = optionalChangeEdit.get();
+      newTreeId = merge(repository, changeEdit, newTreeId);
+      if (ObjectId.equals(newTreeId, changeEdit.getEditCommit().getTree())) {
+        // Modifications are already contained in the change edit.
+        return changeEdit;
+      }
+    }
+
+    String commitMessage =
+        optionalChangeEdit.map(ChangeEdit::getEditCommit).orElse(patchSetCommit).getFullMessage();
+    Timestamp nowTimestamp = TimeUtil.nowTs();
+    ObjectId newEditCommit =
+        createCommit(repository, patchSetCommit, newTreeId, commitMessage, nowTimestamp);
+
+    if (optionalChangeEdit.isPresent()) {
+      return updateEdit(repository, optionalChangeEdit.get(), newEditCommit, nowTimestamp);
+    }
+    return createEdit(repository, notes, patchSet, newEditCommit, nowTimestamp);
   }
 
-  private void ensureAuthenticated() throws AuthException {
+  private void assertCanEdit(ChangeNotes notes) throws AuthException, PermissionBackendException {
     if (!currentUser.get().isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-  }
-
-  private void ensurePermitted(ChangeControl changeControl) throws OrmException, AuthException {
-    if (!changeControl.canAddPatchSet(reviewDb.get())) {
-      throw new AuthException("Not allowed to edit a change.");
+    try {
+      permissionBackend
+          .user(currentUser)
+          .database(reviewDb)
+          .change(notes)
+          .check(ChangePermission.ADD_PATCH_SET);
+    } catch (AuthException denied) {
+      throw new AuthException("edit not permitted", denied);
     }
   }
 
-  private String getWellFormedCommitMessage(String commitMessage) {
-    String wellFormedMessage = Strings.nullToEmpty(commitMessage).trim();
-    checkState(!wellFormedMessage.isEmpty(), "Commit message cannot be null or empty");
-    wellFormedMessage = wellFormedMessage + "\n";
-    return wellFormedMessage;
+  private static void ensureAllowedPatchSet(
+      ChangeNotes notes, Optional<ChangeEdit> optionalChangeEdit, PatchSet patchSet)
+      throws InvalidChangeOperationException {
+    if (optionalChangeEdit.isPresent()) {
+      ChangeEdit changeEdit = optionalChangeEdit.get();
+      if (!isBasedOn(changeEdit, patchSet)) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "Only the patch set %s on which the existing change edit is based may be modified "
+                    + "(specified patch set: %s)",
+                changeEdit.getBasePatchSet().getId(), patchSet.getId()));
+      }
+    } else {
+      PatchSet.Id patchSetId = patchSet.getId();
+      PatchSet.Id currentPatchSetId = notes.getChange().currentPatchSetId();
+      if (!patchSetId.equals(currentPatchSetId)) {
+        throw new InvalidChangeOperationException(
+            String.format(
+                "A change edit may only be created for the current patch set %s (and not for %s)",
+                currentPatchSetId, patchSetId));
+      }
+    }
   }
 
-  private Optional<ChangeEdit> lookupChangeEdit(ChangeControl changeControl)
+  private Optional<ChangeEdit> lookupChangeEdit(ChangeNotes notes)
       throws AuthException, IOException {
-    return changeEditUtil.byChange(changeControl);
+    return changeEditUtil.byChange(notes);
   }
 
-  private PatchSet getBasePatchSet(
-      Optional<ChangeEdit> optionalChangeEdit, ChangeControl changeControl) throws OrmException {
+  private PatchSet getBasePatchSet(Optional<ChangeEdit> optionalChangeEdit, ChangeNotes notes)
+      throws OrmException {
     Optional<PatchSet> editBasePatchSet = optionalChangeEdit.map(ChangeEdit::getBasePatchSet);
-    return editBasePatchSet.isPresent()
-        ? editBasePatchSet.get()
-        : lookupCurrentPatchSet(changeControl);
+    return editBasePatchSet.isPresent() ? editBasePatchSet.get() : lookupCurrentPatchSet(notes);
   }
 
-  private PatchSet lookupCurrentPatchSet(ChangeControl changeControl) throws OrmException {
-    return patchSetUtil.current(reviewDb.get(), changeControl.getNotes());
+  private PatchSet lookupCurrentPatchSet(ChangeNotes notes) throws OrmException {
+    return patchSetUtil.current(reviewDb.get(), notes);
   }
 
   private static boolean isBasedOn(ChangeEdit changeEdit, PatchSet patchSet) {
@@ -372,16 +447,21 @@
   private static RevCommit lookupCommit(Repository repository, PatchSet patchSet)
       throws IOException {
     ObjectId patchSetCommitId = getPatchSetCommitId(patchSet);
+    return lookupCommit(repository, patchSetCommitId);
+  }
+
+  private static RevCommit lookupCommit(Repository repository, ObjectId commitId)
+      throws IOException {
     try (RevWalk revWalk = new RevWalk(repository)) {
-      return revWalk.parseCommit(patchSetCommitId);
+      return revWalk.parseCommit(commitId);
     }
   }
 
   private static ObjectId createNewTree(
-      Repository repository, RevCommit baseCommit, TreeModification treeModification)
+      Repository repository, RevCommit baseCommit, List<TreeModification> treeModifications)
       throws IOException, InvalidChangeOperationException {
     TreeCreator treeCreator = new TreeCreator(baseCommit);
-    treeCreator.addTreeModification(treeModification);
+    treeCreator.addTreeModifications(treeModifications);
     ObjectId newTreeId = treeCreator.createNewTreeAndGetId(repository);
 
     if (ObjectId.equals(newTreeId, baseCommit.getTree())) {
@@ -390,7 +470,7 @@
     return newTreeId;
   }
 
-  private ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
+  private static ObjectId merge(Repository repository, ChangeEdit changeEdit, ObjectId newTreeId)
       throws IOException, MergeConflictException {
     PatchSet basePatchSet = changeEdit.getBasePatchSet();
     ObjectId basePatchSetCommitId = getPatchSetCommitId(basePatchSet);
@@ -436,17 +516,20 @@
     return ObjectId.fromString(patchSet.getRevision().get());
   }
 
-  private void createEditReference(
+  private ChangeEdit createEdit(
       Repository repository,
-      ChangeControl changeControl,
+      ChangeNotes notes,
       PatchSet basePatchSet,
-      ObjectId newEditCommit,
+      ObjectId newEditCommitId,
       Timestamp timestamp)
       throws IOException, OrmException {
-    Change change = changeControl.getChange();
+    Change change = notes.getChange();
     String editRefName = getEditRefName(change, basePatchSet);
-    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommit, timestamp);
+    updateReference(repository, editRefName, ObjectId.zeroId(), newEditCommitId, timestamp);
     reindex(change);
+
+    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+    return new ChangeEdit(change, editRefName, newEditCommit, basePatchSet);
   }
 
   private String getEditRefName(Change change, PatchSet basePatchSet) {
@@ -454,13 +537,17 @@
     return RefNames.refsEdit(me.getAccountId(), change.getId(), basePatchSet.getId());
   }
 
-  private void updateEditReference(
-      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommit, Timestamp timestamp)
+  private ChangeEdit updateEdit(
+      Repository repository, ChangeEdit changeEdit, ObjectId newEditCommitId, Timestamp timestamp)
       throws IOException, OrmException {
     String editRefName = changeEdit.getRefName();
     RevCommit currentEditCommit = changeEdit.getEditCommit();
-    updateReference(repository, editRefName, currentEditCommit, newEditCommit, timestamp);
+    updateReference(repository, editRefName, currentEditCommit, newEditCommitId, timestamp);
     reindex(changeEdit.getChange());
+
+    RevCommit newEditCommit = lookupCommit(repository, newEditCommitId);
+    return new ChangeEdit(
+        changeEdit.getChange(), editRefName, newEditCommit, changeEdit.getBasePatchSet());
   }
 
   private void updateReference(
@@ -479,7 +566,13 @@
     try (RevWalk revWalk = new RevWalk(repository)) {
       RefUpdate.Result res = ru.update(revWalk);
       if (res != RefUpdate.Result.NEW && res != RefUpdate.Result.FORCED) {
-        throw new IOException("update failed: " + ru);
+        throw new IOException(
+            "cannot update "
+                + ru.getName()
+                + " in "
+                + repository.getDirectory()
+                + ": "
+                + ru.getResult());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index 6509ecc..d1d72fa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -38,8 +37,7 @@
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.RepoContext;
@@ -53,6 +51,7 @@
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.lib.Repository;
@@ -69,54 +68,42 @@
 public class ChangeEditUtil {
   private final GitRepositoryManager gitManager;
   private final PatchSetInserter.Factory patchSetInserterFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeIndexer indexer;
   private final Provider<ReviewDb> db;
-  private final Provider<CurrentUser> user;
+  private final Provider<CurrentUser> userProvider;
   private final ChangeKindCache changeKindCache;
-  private final BatchUpdate.Factory updateFactory;
   private final PatchSetUtil psUtil;
 
   @Inject
   ChangeEditUtil(
       GitRepositoryManager gitManager,
       PatchSetInserter.Factory patchSetInserterFactory,
-      ChangeControl.GenericFactory changeControlFactory,
       ChangeIndexer indexer,
       Provider<ReviewDb> db,
-      Provider<CurrentUser> user,
+      Provider<CurrentUser> userProvider,
       ChangeKindCache changeKindCache,
-      BatchUpdate.Factory updateFactory,
       PatchSetUtil psUtil) {
     this.gitManager = gitManager;
     this.patchSetInserterFactory = patchSetInserterFactory;
-    this.changeControlFactory = changeControlFactory;
     this.indexer = indexer;
     this.db = db;
-    this.user = user;
+    this.userProvider = userProvider;
     this.changeKindCache = changeKindCache;
-    this.updateFactory = updateFactory;
     this.psUtil = psUtil;
   }
 
   /**
-   * Retrieve edit for a change and the user from the request scope.
+   * Retrieve edit for a given change.
    *
    * <p>At most one change edit can exist per user and change.
    *
-   * @param change
+   * @param notes change notes of change to retrieve change edits for.
    * @return edit for this change for this user, if present.
-   * @throws AuthException
-   * @throws IOException
-   * @throws OrmException
+   * @throws AuthException if this is not a logged-in user.
+   * @throws IOException if an error occurs.
    */
-  public Optional<ChangeEdit> byChange(Change change)
-      throws AuthException, IOException, OrmException {
-    try {
-      return byChange(changeControlFactory.controlFor(db.get(), change, user.get()));
-    } catch (NoSuchChangeException e) {
-      throw new IOException(e);
-    }
+  public Optional<ChangeEdit> byChange(ChangeNotes notes) throws AuthException, IOException {
+    return byChange(notes, userProvider.get());
   }
 
   /**
@@ -124,17 +111,19 @@
    *
    * <p>At most one change edit can exist per user and change.
    *
-   * @param ctl control with user to retrieve change edits for.
+   * @param notes change notes of change to retrieve change edits for.
+   * @param user user to retrieve edits as.
    * @return edit for this change for this user, if present.
    * @throws AuthException if this is not a logged-in user.
    * @throws IOException if an error occurs.
    */
-  public Optional<ChangeEdit> byChange(ChangeControl ctl) throws AuthException, IOException {
-    if (!ctl.getUser().isIdentifiedUser()) {
+  public Optional<ChangeEdit> byChange(ChangeNotes notes, CurrentUser user)
+      throws AuthException, IOException {
+    if (!user.isIdentifiedUser()) {
       throw new AuthException("Authentication required");
     }
-    IdentifiedUser u = ctl.getUser().asIdentifiedUser();
-    Change change = ctl.getChange();
+    IdentifiedUser u = user.asIdentifiedUser();
+    Change change = notes.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject())) {
       int n = change.currentPatchSetId().get();
       String[] refNames = new String[n];
@@ -148,8 +137,8 @@
       }
       try (RevWalk rw = new RevWalk(repo)) {
         RevCommit commit = rw.parseCommit(ref.getObjectId());
-        PatchSet basePs = getBasePatchSet(ctl, ref);
-        return Optional.of(new ChangeEdit(u, change, ref, commit, basePs));
+        PatchSet basePs = getBasePatchSet(notes, ref);
+        return Optional.of(new ChangeEdit(change, ref.getName(), commit, basePs));
       }
     }
   }
@@ -157,6 +146,9 @@
   /**
    * Promote change edit to patch set, by squashing the edit into its parent.
    *
+   * @param updateFactory factory for creating updates.
+   * @param notes the {@code ChangeNotes} of the change to which the change edit belongs
+   * @param user the current user
    * @param edit change edit to publish
    * @param notify Notify handling that defines to whom email notifications should be sent after the
    *     change edit is published.
@@ -167,25 +159,28 @@
    * @throws RestApiException
    */
   public void publish(
+      BatchUpdate.Factory updateFactory,
+      ChangeNotes notes,
+      CurrentUser user,
       final ChangeEdit edit,
       NotifyHandling notify,
       ListMultimap<RecipientType, Account.Id> accountsToNotify)
       throws IOException, OrmException, RestApiException, UpdateException {
     Change change = edit.getChange();
     try (Repository repo = gitManager.openRepository(change.getProject());
-        RevWalk rw = new RevWalk(repo);
-        ObjectInserter oi = repo.newObjectInserter()) {
+        ObjectInserter oi = repo.newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
       PatchSet basePatchSet = edit.getBasePatchSet();
       if (!basePatchSet.getId().equals(change.currentPatchSetId())) {
         throw new ResourceConflictException("only edit for current patch set can be published");
       }
 
       RevCommit squashed = squashEdit(rw, oi, edit.getEditCommit(), basePatchSet);
-      ChangeControl ctl = changeControlFactory.controlFor(db.get(), change, edit.getUser());
       PatchSet.Id psId = ChangeUtil.nextPatchSetId(repo, change.currentPatchSetId());
       PatchSetInserter inserter =
           patchSetInserterFactory
-              .create(ctl, psId, squashed)
+              .create(notes, psId, squashed)
               .setNotify(notify)
               .setAccountsToNotify(accountsToNotify);
 
@@ -194,7 +189,8 @@
 
       // Previously checked that the base patch set is the current patch set.
       ObjectId prior = ObjectId.fromString(basePatchSet.getRevision().get());
-      ChangeKind kind = changeKindCache.getChangeKind(change.getProject(), repo, prior, squashed);
+      ChangeKind kind =
+          changeKindCache.getChangeKind(change.getProject(), rw, repo.getConfig(), prior, squashed);
       if (kind == ChangeKind.NO_CODE_CHANGE) {
         message.append("Commit message was updated.");
         inserter.setDescription("Edit commit message");
@@ -206,36 +202,19 @@
       }
 
       try (BatchUpdate bu =
-          updateFactory.create(db.get(), change.getProject(), ctl.getUser(), TimeUtil.nowTs())) {
+          updateFactory.create(db.get(), change.getProject(), user, TimeUtil.nowTs())) {
         bu.setRepository(repo, rw, oi);
-        bu.addOp(
-            change.getId(),
-            inserter
-                .setDraft(change.getStatus() == Status.DRAFT || basePatchSet.isDraft())
-                .setMessage(message.toString()));
+        bu.addOp(change.getId(), inserter.setMessage(message.toString()));
         bu.addOp(
             change.getId(),
             new BatchUpdateOp() {
               @Override
               public void updateRepo(RepoContext ctx) throws Exception {
-                deleteRef(ctx.getRepository(), edit);
+                ctx.addRefUpdate(edit.getEditCommit().copy(), ObjectId.zeroId(), edit.getRefName());
               }
             });
         bu.execute();
-      } catch (UpdateException e) {
-        if (e.getCause() instanceof IOException
-            && e.getMessage()
-                .equals(
-                    String.format(
-                        "%s: Failed to delete ref %s: %s",
-                        IOException.class.getName(),
-                        edit.getRefName(),
-                        RefUpdate.Result.LOCK_FAILURE.name()))) {
-          throw new ResourceConflictException("edit is already published");
-        }
       }
-
-      indexer.index(db.get(), inserter.getChange());
     }
   }
 
@@ -254,13 +233,13 @@
     indexer.index(db.get(), change);
   }
 
-  private PatchSet getBasePatchSet(ChangeControl ctl, Ref ref) throws IOException {
+  private PatchSet getBasePatchSet(ChangeNotes notes, Ref ref) throws IOException {
     try {
       int pos = ref.getName().lastIndexOf("/");
       checkArgument(pos > 0, "invalid edit ref: %s", ref.getName());
       String psId = ref.getName().substring(pos + 1);
       return psUtil.get(
-          db.get(), ctl.getNotes(), new PatchSet.Id(ctl.getId(), Integer.parseInt(psId)));
+          db.get(), notes, new PatchSet.Id(notes.getChange().getId(), Integer.parseInt(psId)));
     } catch (OrmException | NumberFormatException e) {
       throw new IOException(e);
     }
@@ -280,7 +259,7 @@
   private static void deleteRef(Repository repo, ChangeEdit edit) throws IOException {
     String refName = edit.getRefName();
     RefUpdate ru = repo.updateRef(refName, true);
-    ru.setExpectedOldObjectId(edit.getRef().getObjectId());
+    ru.setExpectedOldObjectId(edit.getEditCommit());
     ru.setForceUpdate(true);
     RefUpdate.Result result = ru.delete();
     switch (result) {
@@ -295,6 +274,8 @@
       case REJECTED:
       case REJECTED_CURRENT_BRANCH:
       case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
       default:
         throw new IOException(String.format("Failed to delete ref %s: %s", refName, result));
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
index dc35309..3d75e6a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/ChangeFileContentModification.java
@@ -17,6 +17,7 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.io.ByteStreams;
 import com.google.gerrit.extensions.restapi.RawInput;
 import java.io.IOException;
@@ -53,6 +54,16 @@
     return Collections.singletonList(changeContentEdit);
   }
 
+  @Override
+  public String getFilePath() {
+    return filePath;
+  }
+
+  @VisibleForTesting
+  RawInput getNewContent() {
+    return newContent;
+  }
+
   /** A {@code PathEdit} which changes the contents of a file. */
   private static class ChangeContent extends DirCacheEditor.PathEdit {
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
index 62da19a..feffb70 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/DeleteFileModification.java
@@ -34,4 +34,9 @@
     DirCacheEditor.DeletePath deletePathEdit = new DirCacheEditor.DeletePath(filePath);
     return Collections.singletonList(deletePathEdit);
   }
+
+  @Override
+  public String getFilePath() {
+    return filePath;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
index aeacd23..b847599 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RenameFileModification.java
@@ -52,4 +52,9 @@
       }
     }
   }
+
+  @Override
+  public String getFilePath() {
+    return newFilePath;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
index 1bd55f6..393a866 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/RestoreFileModification.java
@@ -58,4 +58,9 @@
       }
     }
   }
+
+  @Override
+  public String getFilePath() {
+    return filePath;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
index 7e9a96a..e867e76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeCreator.java
@@ -36,8 +36,6 @@
 public class TreeCreator {
 
   private final RevCommit baseCommit;
-  // At the moment, a list wouldn't be necessary as only one modification is
-  // applied per created tree. This is going to change in the near future.
   private final List<TreeModification> treeModifications = new ArrayList<>();
 
   public TreeCreator(RevCommit baseCommit) {
@@ -45,14 +43,14 @@
   }
 
   /**
-   * Apply a modification to the tree which is taken as a basis. If this method is called multiple
+   * Apply modifications to the tree which is taken as a basis. If this method is called multiple
    * times, the modifications are applied subsequently in exactly the order they were provided.
    *
-   * @param treeModification a modification which should be applied to the base tree
+   * @param treeModifications modifications which should be applied to the base tree
    */
-  public void addTreeModification(TreeModification treeModification) {
-    checkNotNull(treeModification, "treeModification must not be null");
-    treeModifications.add(treeModification);
+  public void addTreeModifications(List<TreeModification> treeModifications) {
+    checkNotNull(treeModifications, "treeModifications must not be null");
+    this.treeModifications.addAll(treeModifications);
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
index 217a309..2656707 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/tree/TreeModification.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.edit.tree;
 
+import com.google.common.annotations.VisibleForTesting;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -35,4 +36,14 @@
    */
   List<DirCacheEditor.PathEdit> getPathEdits(Repository repository, RevCommit baseCommit)
       throws IOException;
+
+  /**
+   * Indicates a file path which is affected by this {@code TreeModification}. If the modification
+   * refers to several file paths (e.g. renaming a file), returning either of them is appropriate as
+   * long as the returned value is deterministic.
+   *
+   * @return an affected file path
+   */
+  @VisibleForTesting
+  String getFilePath();
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
index 17fc52b..c0f9c29 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/CommitReceivedEvent.java
@@ -16,14 +16,19 @@
 
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
+import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
-public class CommitReceivedEvent extends RefEvent {
+public class CommitReceivedEvent extends RefEvent implements AutoCloseable {
   static final String TYPE = "commit-received";
   public ReceiveCommand command;
   public Project project;
   public String refName;
+  public RevWalk revWalk;
   public RevCommit commit;
   public IdentifiedUser user;
 
@@ -35,14 +40,18 @@
       ReceiveCommand command,
       Project project,
       String refName,
-      RevCommit commit,
-      IdentifiedUser user) {
+      ObjectReader reader,
+      ObjectId commitId,
+      IdentifiedUser user)
+      throws IOException {
     this();
     this.command = command;
     this.project = project;
     this.refName = refName;
-    this.commit = commit;
+    this.revWalk = new RevWalk(reader);
+    this.commit = revWalk.parseCommit(commitId);
     this.user = user;
+    revWalk.parseBody(commit);
   }
 
   @Override
@@ -54,4 +63,9 @@
   public String getRefName() {
     return refName;
   }
+
+  @Override
+  public void close() {
+    revWalk.close();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/DraftPublishedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/DraftPublishedEvent.java
deleted file mode 100644
index 0724253..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/DraftPublishedEvent.java
+++ /dev/null
@@ -1,28 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.events;
-
-import com.google.common.base.Supplier;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.data.AccountAttribute;
-
-public class DraftPublishedEvent extends PatchSetEvent {
-  static final String TYPE = "draft-published";
-  public Supplier<AccountAttribute> uploader;
-
-  public DraftPublishedEvent(Change change) {
-    super(TYPE, change);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
index 5c3da33..844b43b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventFactory.java
@@ -35,8 +35,8 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.account.AccountByEmailCache;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.change.ChangeKindCache;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.data.AccountAttribute;
@@ -56,6 +56,7 @@
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -83,9 +84,9 @@
   private static final Logger log = LoggerFactory.getLogger(EventFactory.class);
 
   private final AccountCache accountCache;
+  private final Emails emails;
   private final Provider<String> urlProvider;
   private final PatchListCache patchListCache;
-  private final AccountByEmailCache byEmailCache;
   private final PersonIdent myIdent;
   private final ChangeData.Factory changeDataFactory;
   private final ApprovalsUtil approvalsUtil;
@@ -96,8 +97,8 @@
   @Inject
   EventFactory(
       AccountCache accountCache,
+      Emails emails,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
-      AccountByEmailCache byEmailCache,
       PatchListCache patchListCache,
       @GerritPersonIdent PersonIdent myIdent,
       ChangeData.Factory changeDataFactory,
@@ -106,9 +107,9 @@
       Provider<InternalChangeQuery> queryProvider,
       SchemaFactory<ReviewDb> schema) {
     this.accountCache = accountCache;
+    this.emails = emails;
     this.urlProvider = urlProvider;
     this.patchListCache = patchListCache;
-    this.byEmailCache = byEmailCache;
     this.myIdent = myIdent;
     this.changeDataFactory = changeDataFactory;
     this.approvalsUtil = approvalsUtil;
@@ -157,6 +158,8 @@
     a.assignee = asAccountAttribute(change.getAssignee());
     a.status = change.getStatus();
     a.createdOn = change.getCreatedOn().getTime() / 1000L;
+    a.wip = change.isWorkInProgress() ? true : null;
+    a.isPrivate = change.isPrivate() ? true : null;
     return a;
   }
 
@@ -311,7 +314,7 @@
     // set whose parent matches this patch set's revision.
     for (ChangeData cd :
         queryProvider.get().byProjectGroups(change.getProject(), currentPs.getGroups())) {
-      patchSets:
+      PATCH_SETS:
       for (PatchSet ps : cd.patchSets()) {
         RevCommit commit = rw.parseCommit(ObjectId.fromString(ps.getRevision().get()));
         for (RevCommit p : commit.getParents()) {
@@ -319,7 +322,7 @@
             continue;
           }
           ca.neededBy.add(newNeededBy(checkNotNull(cd.change()), ps));
-          continue patchSets;
+          continue PATCH_SETS;
         }
       }
     }
@@ -425,6 +428,8 @@
         p.insertions = patch.getInsertions();
         patchSetAttribute.files.add(p);
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Cannot get patch list: " + e.getMessage());
     } catch (PatchListNotAvailableException e) {
       log.error("Cannot get patch list", e);
     }
@@ -470,7 +475,6 @@
     p.ref = patchSet.getRefName();
     p.uploader = asAccountAttribute(patchSet.getUploader());
     p.createdOn = patchSet.getCreatedOn().getTime() / 1000L;
-    p.isDraft = patchSet.isDraft();
     PatchSet.Id pId = patchSet.getId();
     try {
       p.parents = new ArrayList<>();
@@ -497,8 +501,10 @@
         }
       }
       p.kind = changeKindCache.getChangeKind(db, change, patchSet);
-    } catch (IOException e) {
+    } catch (IOException | OrmException e) {
       log.error("Cannot load patch set data for {}", patchSet.getId(), e);
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Cannot get size information for {}: {}", pId, e.getMessage());
     } catch (PatchListNotAvailableException e) {
       log.error("Cannot get size information for {}.", pId, e);
     }
@@ -507,7 +513,7 @@
 
   // TODO: The same method exists in PatchSetInfoFactory, find a common place
   // for it
-  private UserIdentity toUserIdentity(PersonIdent who) {
+  private UserIdentity toUserIdentity(PersonIdent who) throws IOException, OrmException {
     UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
@@ -517,7 +523,7 @@
     // If only one account has access to this email address, select it
     // as the identity of the user.
     //
-    Set<Account.Id> a = byEmailCache.get(u.getEmail());
+    Set<Account.Id> a = emails.getAccountFor(u.getEmail());
     if (a.size() == 1) {
       u.setAccount(a.iterator().next());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
index 19470ad..5498ec8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/EventTypes.java
@@ -29,9 +29,9 @@
     register(ChangeRestoredEvent.TYPE, ChangeRestoredEvent.class);
     register(CommentAddedEvent.TYPE, CommentAddedEvent.class);
     register(CommitReceivedEvent.TYPE, CommitReceivedEvent.class);
-    register(DraftPublishedEvent.TYPE, DraftPublishedEvent.class);
     register(HashtagsChangedEvent.TYPE, HashtagsChangedEvent.class);
     register(PatchSetCreatedEvent.TYPE, PatchSetCreatedEvent.class);
+    register(PrivateStateChangedEvent.TYPE, PrivateStateChangedEvent.class);
     register(ProjectCreatedEvent.TYPE, ProjectCreatedEvent.class);
     register(RefReceivedEvent.TYPE, RefReceivedEvent.class);
     register(RefUpdatedEvent.TYPE, RefUpdatedEvent.class);
@@ -39,6 +39,7 @@
     register(ReviewerDeletedEvent.TYPE, ReviewerDeletedEvent.class);
     register(TopicChangedEvent.TYPE, TopicChangedEvent.class);
     register(VoteDeletedEvent.TYPE, VoteDeletedEvent.class);
+    register(WorkInProgressStateChangedEvent.TYPE, WorkInProgressStateChangedEvent.class);
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
new file mode 100644
index 0000000..d03eda4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/PrivateStateChangedEvent.java
@@ -0,0 +1,28 @@
+// 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.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+
+public class PrivateStateChangedEvent extends PatchSetEvent {
+  static final String TYPE = "private-state-changed";
+  public Supplier<AccountAttribute> changer;
+
+  protected PrivateStateChangedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index de6cec1..4c948fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -30,15 +30,16 @@
 import com.google.gerrit.extensions.events.ChangeMergedListener;
 import com.google.gerrit.extensions.events.ChangeRestoredListener;
 import com.google.gerrit.extensions.events.CommentAddedListener;
-import com.google.gerrit.extensions.events.DraftPublishedListener;
 import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.extensions.events.NewProjectCreatedListener;
+import com.google.gerrit.extensions.events.PrivateStateChangedListener;
 import com.google.gerrit.extensions.events.ReviewerAddedListener;
 import com.google.gerrit.extensions.events.ReviewerDeletedListener;
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
 import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.events.VoteDeletedListener;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Account;
@@ -54,6 +55,7 @@
 import com.google.gerrit.server.data.RefUpdateAttribute;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
@@ -79,8 +81,9 @@
         ChangeDeletedListener,
         ChangeMergedListener,
         ChangeRestoredListener,
+        WorkInProgressStateChangedListener,
+        PrivateStateChangedListener,
         CommentAddedListener,
-        DraftPublishedListener,
         GitReferenceUpdatedListener,
         HashtagsEditedListener,
         NewProjectCreatedListener,
@@ -100,16 +103,19 @@
       DynamicSet.bind(binder(), ChangeMergedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ChangeRestoredListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), CommentAddedListener.class).to(StreamEventsApiListener.class);
-      DynamicSet.bind(binder(), DraftPublishedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), GitReferenceUpdatedListener.class)
           .to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), HashtagsEditedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), NewProjectCreatedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), PrivateStateChangedListener.class)
+          .to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ReviewerAddedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), ReviewerDeletedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), RevisionCreatedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), TopicEditedListener.class).to(StreamEventsApiListener.class);
       DynamicSet.bind(binder(), VoteDeletedListener.class).to(StreamEventsApiListener.class);
+      DynamicSet.bind(binder(), WorkInProgressStateChangedListener.class)
+          .to(StreamEventsApiListener.class);
     }
   }
 
@@ -155,7 +161,7 @@
     return psUtil.get(db.get(), notes, PatchSet.Id.fromRef(info.ref));
   }
 
-  private Supplier<ChangeAttribute> changeAttributeSupplier(final Change change) {
+  private Supplier<ChangeAttribute> changeAttributeSupplier(Change change) {
     return Suppliers.memoize(
         new Supplier<ChangeAttribute>() {
           @Override
@@ -165,7 +171,7 @@
         });
   }
 
-  private Supplier<AccountAttribute> accountAttributeSupplier(final AccountInfo account) {
+  private Supplier<AccountAttribute> accountAttributeSupplier(AccountInfo account) {
     return Suppliers.memoize(
         new Supplier<AccountAttribute>() {
           @Override
@@ -178,7 +184,7 @@
   }
 
   private Supplier<PatchSetAttribute> patchSetAttributeSupplier(
-      final Change change, final PatchSet patchSet) {
+      final Change change, PatchSet patchSet) {
     return Suppliers.memoize(
         new Supplier<PatchSetAttribute>() {
           @Override
@@ -264,7 +270,7 @@
       event.oldAssignee = accountAttributeSupplier(ev.getOldAssignee());
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
@@ -280,7 +286,7 @@
       event.oldTopic = ev.getOldTopic();
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
@@ -298,13 +304,13 @@
       event.uploader = accountAttributeSupplier(ev.getWho());
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
 
   @Override
-  public void onReviewerDeleted(final ReviewerDeletedListener.Event ev) {
+  public void onReviewerDeleted(ReviewerDeletedListener.Event ev) {
     try {
       ChangeNotes notes = getNotes(ev.getChange());
       Change change = notes.getChange();
@@ -318,7 +324,7 @@
           approvalsAttributeSupplier(change, ev.getNewApprovals(), ev.getOldApprovals());
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
@@ -336,7 +342,7 @@
         event.reviewer = accountAttributeSupplier(reviewer);
         dispatcher.get().postEvent(change, event);
       }
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
@@ -363,13 +369,13 @@
       event.removed = hashtagArray(ev.getRemovedHashtags());
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
 
   @Override
-  public void onGitReferenceUpdated(final GitReferenceUpdatedListener.Event ev) {
+  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event ev) {
     RefUpdatedEvent event = new RefUpdatedEvent();
     if (ev.getUpdater() != null) {
       event.submitter = accountAttributeSupplier(ev.getUpdater());
@@ -386,24 +392,10 @@
                     refName);
               }
             });
-    dispatcher.get().postEvent(refName, event);
-  }
-
-  @Override
-  public void onDraftPublished(DraftPublishedListener.Event ev) {
     try {
-      ChangeNotes notes = getNotes(ev.getChange());
-      Change change = notes.getChange();
-      PatchSet ps = getPatchSet(notes, ev.getRevision());
-      DraftPublishedEvent event = new DraftPublishedEvent(change);
-
-      event.change = changeAttributeSupplier(change);
-      event.patchSet = patchSetAttributeSupplier(change, ps);
-      event.uploader = accountAttributeSupplier(ev.getWho());
-
-      dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
-      log.error("Failed to dispatch event", e);
+      dispatcher.get().postEvent(refName, event);
+    } catch (PermissionBackendException e) {
+      log.error("error while posting event", e);
     }
   }
 
@@ -422,7 +414,7 @@
       event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
@@ -440,7 +432,7 @@
       event.reason = ev.getReason();
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
@@ -458,7 +450,7 @@
       event.newRev = ev.getNewRevisionId();
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
@@ -476,7 +468,43 @@
       event.reason = ev.getReason();
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onWorkInProgressStateChanged(WorkInProgressStateChangedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
+      WorkInProgressStateChangedEvent event = new WorkInProgressStateChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
+      log.error("Failed to dispatch event", e);
+    }
+  }
+
+  @Override
+  public void onPrivateStateChanged(PrivateStateChangedListener.Event ev) {
+    try {
+      ChangeNotes notes = getNotes(ev.getChange());
+      Change change = notes.getChange();
+      PatchSet patchSet = getPatchSet(notes, ev.getRevision());
+      PrivateStateChangedEvent event = new PrivateStateChangedEvent(change);
+
+      event.change = changeAttributeSupplier(change);
+      event.changer = accountAttributeSupplier(ev.getWho());
+      event.patchSet = patchSetAttributeSupplier(change, patchSet);
+
+      dispatcher.get().postEvent(change, event);
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
@@ -496,7 +524,7 @@
       event.approvals = approvalsAttributeSupplier(change, ev.getApprovals(), ev.getOldApprovals());
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
@@ -512,7 +540,7 @@
       event.deleter = accountAttributeSupplier(ev.getWho());
 
       dispatcher.get().postEvent(change, event);
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Failed to dispatch event", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
new file mode 100644
index 0000000..5e52c7b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/WorkInProgressStateChangedEvent.java
@@ -0,0 +1,28 @@
+// 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.events;
+
+import com.google.common.base.Supplier;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.data.AccountAttribute;
+
+public class WorkInProgressStateChangedEvent extends PatchSetEvent {
+  static final String TYPE = "wip-state-changed";
+  public Supplier<AccountAttribute> changer;
+
+  protected WorkInProgressStateChangedEvent(Change change) {
+    super(TYPE, change);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index 36574f9..8b8522a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -72,7 +74,13 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
       log.error("Couldn't fire event", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index d969406..217e5d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -66,7 +68,13 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
       log.error("Couldn't fire event", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 323bd34..6715467 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -65,7 +67,13 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
       log.error("Couldn't fire event", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index bfbdc7f..03ad58c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -26,6 +26,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -76,7 +78,13 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
       log.error("Couldn't fire event", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
deleted file mode 100644
index 32a1531..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
+++ /dev/null
@@ -1,75 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.extensions.events;
-
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.common.AccountInfo;
-import com.google.gerrit.extensions.common.ChangeInfo;
-import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.events.DraftPublishedListener;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.io.IOException;
-import java.sql.Timestamp;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class DraftPublished {
-  private static final Logger log = LoggerFactory.getLogger(DraftPublished.class);
-
-  private final DynamicSet<DraftPublishedListener> listeners;
-  private final EventUtil util;
-
-  @Inject
-  public DraftPublished(DynamicSet<DraftPublishedListener> listeners, EventUtil util) {
-    this.listeners = listeners;
-    this.util = util;
-  }
-
-  public void fire(Change change, PatchSet patchSet, Account accountId, Timestamp when) {
-    try {
-      Event event =
-          new Event(
-              util.changeInfo(change),
-              util.revisionInfo(change.getProject(), patchSet),
-              util.accountInfo(accountId),
-              when);
-      for (DraftPublishedListener l : listeners) {
-        try {
-          l.onDraftPublished(event);
-        } catch (Exception e) {
-          util.logEventListenerError(this, l, e);
-        }
-      }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
-      log.error("Couldn't fire event", e);
-    }
-  }
-
-  private static class Event extends AbstractRevisionEvent implements DraftPublishedListener.Event {
-
-    Event(ChangeInfo change, RevisionInfo revision, AccountInfo publisher, Timestamp when) {
-      super(change, revision, publisher, when, NotifyHandling.ALL);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 57382f6..bb0c3ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -29,7 +29,7 @@
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -83,15 +83,16 @@
   }
 
   public RevisionInfo revisionInfo(Project project, PatchSet ps)
-      throws OrmException, PatchListNotAvailableException, GpgException, IOException {
+      throws OrmException, PatchListNotAvailableException, GpgException, IOException,
+          PermissionBackendException {
     return revisionInfo(project.getNameKey(), ps);
   }
 
   public RevisionInfo revisionInfo(Project.NameKey project, PatchSet ps)
-      throws OrmException, PatchListNotAvailableException, GpgException, IOException {
+      throws OrmException, PatchListNotAvailableException, GpgException, IOException,
+          PermissionBackendException {
     ChangeData cd = changeDataFactory.create(db.get(), project, ps.getId().getParentKey());
-    ChangeControl ctl = cd.changeControl();
-    return changeJsonFactory.create(CHANGE_OPTIONS).getRevisionInfo(ctl, ps);
+    return changeJsonFactory.create(CHANGE_OPTIONS).getRevisionInfo(cd, ps);
   }
 
   public AccountInfo accountInfo(Account a) {
@@ -124,9 +125,10 @@
           error);
     } else {
       log.warn(
-          "Error in event listener {} for event {}: {}",
+          "Error in event listener {} for event {}: {} - {}",
           listener.getClass().getName(),
           event.getClass().getName(),
+          error.getClass().getName(),
           error.getMessage());
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
new file mode 100644
index 0000000..61fa6a9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/PrivateStateChanged.java
@@ -0,0 +1,85 @@
+// 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.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.PrivateStateChangedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class PrivateStateChanged {
+  private static final Logger log = LoggerFactory.getLogger(PrivateStateChanged.class);
+
+  private final DynamicSet<PrivateStateChangedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  PrivateStateChanged(DynamicSet<PrivateStateChangedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet patchSet, Account account, Timestamp when) {
+
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(account),
+              when);
+      for (PrivateStateChangedListener l : listeners) {
+        try {
+          l.onPrivateStateChanged(event);
+        } catch (Exception e) {
+          util.logEventListenerError(event, l, e);
+        }
+      }
+    } catch (PatchListNotAvailableException
+        | PermissionBackendException
+        | IOException
+        | GpgException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements PrivateStateChangedListener.Event {
+
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+      super(change, revision, who, when, NotifyHandling.ALL);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index b729781..6ffdd02 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -26,6 +26,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -69,7 +71,13 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
       log.error("Couldn't fire event", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index 8edfb1e..1d00a50 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -26,6 +26,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -80,7 +82,13 @@
           util.logEventListenerError(this, listener, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
       log.error("Couldn't fire event", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 20d0bf4..d2ef2d5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -25,6 +25,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -82,7 +84,13 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
       log.error("Couldn't fire event", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index 0f9c943..c377bdc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -26,6 +26,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.GpgException;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -80,7 +82,13 @@
           util.logEventListenerError(this, l, e);
         }
       }
-    } catch (PatchListNotAvailableException | GpgException | IOException | OrmException e) {
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Couldn't fire event: " + e.getMessage());
+    } catch (PatchListNotAvailableException
+        | GpgException
+        | IOException
+        | OrmException
+        | PermissionBackendException e) {
       log.error("Couldn't fire event", e);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.java
new file mode 100644
index 0000000..4a28d0c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/WorkInProgressStateChanged.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.server.extensions.events;
+
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeInfo;
+import com.google.gerrit.extensions.common.RevisionInfo;
+import com.google.gerrit.extensions.events.WorkInProgressStateChangedListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.GpgException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.sql.Timestamp;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class WorkInProgressStateChanged {
+  private static final Logger log = LoggerFactory.getLogger(WorkInProgressStateChanged.class);
+
+  private final DynamicSet<WorkInProgressStateChangedListener> listeners;
+  private final EventUtil util;
+
+  @Inject
+  WorkInProgressStateChanged(
+      DynamicSet<WorkInProgressStateChangedListener> listeners, EventUtil util) {
+    this.listeners = listeners;
+    this.util = util;
+  }
+
+  public void fire(Change change, PatchSet patchSet, Account account, Timestamp when) {
+
+    if (!listeners.iterator().hasNext()) {
+      return;
+    }
+    try {
+      Event event =
+          new Event(
+              util.changeInfo(change),
+              util.revisionInfo(change.getProject(), patchSet),
+              util.accountInfo(account),
+              when);
+      for (WorkInProgressStateChangedListener l : listeners) {
+        try {
+          l.onWorkInProgressStateChanged(event);
+        } catch (Exception e) {
+          util.logEventListenerError(event, l, e);
+        }
+      }
+    } catch (PatchListNotAvailableException
+        | PermissionBackendException
+        | IOException
+        | GpgException
+        | OrmException e) {
+      log.error("Couldn't fire event", e);
+    }
+  }
+
+  private static class Event extends AbstractRevisionEvent
+      implements WorkInProgressStateChangedListener.Event {
+
+    protected Event(ChangeInfo change, RevisionInfo revision, AccountInfo who, Timestamp when) {
+      super(change, revision, who, when, NotifyHandling.ALL);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
index 548853c..c959e96 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/webui/UiActions.java
@@ -14,22 +14,38 @@
 
 package com.google.gerrit.server.extensions.webui;
 
+import static com.google.gerrit.extensions.conditions.BooleanCondition.and;
+import static com.google.gerrit.extensions.conditions.BooleanCondition.or;
+import static java.util.stream.Collectors.toList;
+
 import com.google.common.base.Predicate;
-import com.google.common.collect.FluentIterable;
+import com.google.common.collect.Streams;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
 import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.RestCollection;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.PrivateInternals_UiActionDescription;
 import com.google.gerrit.extensions.webui.UiAction;
+import com.google.gerrit.extensions.webui.UiAction.Description;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityUtils;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendCondition;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.Iterator;
+import java.util.List;
 import java.util.Objects;
+import java.util.Set;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+@Singleton
 public class UiActions {
   private static final Logger log = LoggerFactory.getLogger(UiActions.class);
 
@@ -37,54 +53,92 @@
     return UiAction.Description::isEnabled;
   }
 
-  public static <R extends RestResource> FluentIterable<UiAction.Description> from(
-      RestCollection<?, R> collection, R resource, Provider<CurrentUser> userProvider) {
-    return from(collection.views(), resource, userProvider);
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> userProvider;
+
+  @Inject
+  UiActions(PermissionBackend permissionBackend, Provider<CurrentUser> userProvider) {
+    this.permissionBackend = permissionBackend;
+    this.userProvider = userProvider;
   }
 
-  public static <R extends RestResource> FluentIterable<UiAction.Description> from(
-      DynamicMap<RestView<R>> views, R resource, Provider<CurrentUser> userProvider) {
-    return FluentIterable.from(views)
-        .transform(
-            (DynamicMap.Entry<RestView<R>> e) -> {
-              int d = e.getExportName().indexOf('.');
-              if (d < 0) {
-                return null;
-              }
-
-              RestView<R> view;
-              try {
-                view = e.getProvider().get();
-              } catch (RuntimeException err) {
-                log.error("error creating view {}.{}", e.getPluginName(), e.getExportName(), err);
-                return null;
-              }
-
-              if (!(view instanceof UiAction)) {
-                return null;
-              }
-
-              try {
-                CapabilityUtils.checkRequiresCapability(
-                    userProvider, e.getPluginName(), view.getClass());
-              } catch (AuthException exc) {
-                return null;
-              }
-
-              UiAction.Description dsc = ((UiAction<R>) view).getDescription(resource);
-              if (dsc == null || !dsc.isVisible()) {
-                return null;
-              }
-
-              String name = e.getExportName().substring(d + 1);
-              PrivateInternals_UiActionDescription.setMethod(
-                  dsc, e.getExportName().substring(0, d));
-              PrivateInternals_UiActionDescription.setId(
-                  dsc, "gerrit".equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
-              return dsc;
-            })
-        .filter(Objects::nonNull);
+  public <R extends RestResource> Iterable<UiAction.Description> from(
+      RestCollection<?, R> collection, R resource) {
+    return from(collection.views(), resource);
   }
 
-  private UiActions() {}
+  public <R extends RestResource> Iterable<UiAction.Description> from(
+      DynamicMap<RestView<R>> views, R resource) {
+    List<UiAction.Description> descs =
+        Streams.stream(views)
+            .map(e -> describe(e, resource))
+            .filter(Objects::nonNull)
+            .collect(toList());
+
+    List<PermissionBackendCondition> conds =
+        Streams.concat(
+                descs.stream().flatMap(u -> Streams.stream(visibleCondition(u))),
+                descs.stream().flatMap(u -> Streams.stream(enabledCondition(u))))
+            .collect(toList());
+    permissionBackend.bulkEvaluateTest(conds);
+
+    return descs.stream().filter(u -> u.isVisible()).collect(toList());
+  }
+
+  private static Iterable<PermissionBackendCondition> visibleCondition(Description u) {
+    return u.getVisibleCondition().children(PermissionBackendCondition.class);
+  }
+
+  private static Iterable<PermissionBackendCondition> enabledCondition(Description u) {
+    return u.getEnabledCondition().children(PermissionBackendCondition.class);
+  }
+
+  @Nullable
+  private <R extends RestResource> UiAction.Description describe(
+      DynamicMap.Entry<RestView<R>> e, R resource) {
+    int d = e.getExportName().indexOf('.');
+    if (d < 0) {
+      return null;
+    }
+
+    RestView<R> view;
+    try {
+      view = e.getProvider().get();
+    } catch (RuntimeException err) {
+      log.error("error creating view {}.{}", e.getPluginName(), e.getExportName(), err);
+      return null;
+    }
+
+    if (!(view instanceof UiAction)) {
+      return null;
+    }
+
+    UiAction.Description dsc = ((UiAction<R>) view).getDescription(resource);
+    if (dsc == null) {
+      return null;
+    }
+
+    Set<GlobalOrPluginPermission> globalRequired;
+    try {
+      globalRequired = GlobalPermission.fromAnnotation(e.getPluginName(), view.getClass());
+    } catch (PermissionBackendException err) {
+      log.error("exception testing view {}.{}", e.getPluginName(), e.getExportName(), err);
+      return null;
+    }
+    if (!globalRequired.isEmpty()) {
+      PermissionBackend.WithUser withUser = permissionBackend.user(userProvider);
+      Iterator<GlobalOrPluginPermission> i = globalRequired.iterator();
+      BooleanCondition p = withUser.testCond(i.next());
+      while (i.hasNext()) {
+        p = or(p, withUser.testCond(i.next()));
+      }
+      dsc.setVisible(and(p, dsc.getVisibleCondition()));
+    }
+
+    String name = e.getExportName().substring(d + 1);
+    PrivateInternals_UiActionDescription.setMethod(dsc, e.getExportName().substring(0, d));
+    PrivateInternals_UiActionDescription.setId(
+        dsc, "gerrit".equals(e.getPluginName()) ? name : e.getPluginName() + '~' + name);
+    return dsc;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.java
new file mode 100644
index 0000000..1e5088be
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/FixReplacementInterpreter.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.server.fixes;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.groupingBy;
+
+import com.google.gerrit.common.RawInputUtil;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.edit.tree.ChangeFileContentModification;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+
+/** An interpreter for {@code FixReplacement}s. */
+@Singleton
+public class FixReplacementInterpreter {
+
+  private static final Comparator<FixReplacement> ASC_RANGE_FIX_REPLACEMENT_COMPARATOR =
+      Comparator.comparing(fixReplacement -> fixReplacement.range);
+
+  private final FileContentUtil fileContentUtil;
+
+  @Inject
+  public FixReplacementInterpreter(FileContentUtil fileContentUtil) {
+    this.fileContentUtil = fileContentUtil;
+  }
+
+  /**
+   * Transforms the given {@code FixReplacement}s into {@code TreeModification}s.
+   *
+   * @param repository the affected Git repository
+   * @param projectState the affected project
+   * @param patchSetCommitId the patch set which should be modified
+   * @param fixReplacements the replacements which should be applied
+   * @return a list of {@code TreeModification}s representing the given replacements
+   * @throws ResourceNotFoundException if a file to which one of the replacements refers doesn't
+   *     exist
+   * @throws ResourceConflictException if the replacements can't be transformed into {@code
+   *     TreeModification}s
+   */
+  public List<TreeModification> toTreeModifications(
+      Repository repository,
+      ProjectState projectState,
+      ObjectId patchSetCommitId,
+      List<FixReplacement> fixReplacements)
+      throws ResourceNotFoundException, IOException, ResourceConflictException {
+    checkNotNull(fixReplacements, "Fix replacements must not be null");
+
+    Map<String, List<FixReplacement>> fixReplacementsPerFilePath =
+        fixReplacements.stream().collect(groupingBy(fixReplacement -> fixReplacement.path));
+
+    List<TreeModification> treeModifications = new ArrayList<>();
+    for (Map.Entry<String, List<FixReplacement>> entry : fixReplacementsPerFilePath.entrySet()) {
+      TreeModification treeModification =
+          toTreeModification(
+              repository, projectState, patchSetCommitId, entry.getKey(), entry.getValue());
+      treeModifications.add(treeModification);
+    }
+    return treeModifications;
+  }
+
+  private TreeModification toTreeModification(
+      Repository repository,
+      ProjectState projectState,
+      ObjectId patchSetCommitId,
+      String filePath,
+      List<FixReplacement> fixReplacements)
+      throws ResourceNotFoundException, IOException, ResourceConflictException {
+    String fileContent = getFileContent(repository, projectState, patchSetCommitId, filePath);
+    String newFileContent = getNewFileContent(fileContent, fixReplacements);
+    return new ChangeFileContentModification(filePath, RawInputUtil.create(newFileContent));
+  }
+
+  private String getFileContent(
+      Repository repository, ProjectState projectState, ObjectId patchSetCommitId, String filePath)
+      throws ResourceNotFoundException, IOException {
+    try (BinaryResult fileContent =
+        fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath)) {
+      return fileContent.asString();
+    }
+  }
+
+  private static String getNewFileContent(String fileContent, List<FixReplacement> fixReplacements)
+      throws ResourceConflictException {
+    List<FixReplacement> sortedReplacements = new ArrayList<>(fixReplacements);
+    sortedReplacements.sort(ASC_RANGE_FIX_REPLACEMENT_COMPARATOR);
+
+    LineIdentifier lineIdentifier = new LineIdentifier(fileContent);
+    StringModifier fileContentModifier = new StringModifier(fileContent);
+    for (FixReplacement fixReplacement : sortedReplacements) {
+      Comment.Range range = fixReplacement.range;
+      try {
+        int startLineIndex = lineIdentifier.getStartIndexOfLine(range.startLine);
+        int startLineLength = lineIdentifier.getLengthOfLine(range.startLine);
+
+        int endLineIndex = lineIdentifier.getStartIndexOfLine(range.endLine);
+        int endLineLength = lineIdentifier.getLengthOfLine(range.endLine);
+
+        if (range.startChar > startLineLength || range.endChar > endLineLength) {
+          throw new ResourceConflictException(
+              String.format(
+                  "Range %s refers to a non-existent offset (start line length: %s,"
+                      + " end line length: %s)",
+                  toString(range), startLineLength, endLineLength));
+        }
+
+        int startIndex = startLineIndex + range.startChar;
+        int endIndex = endLineIndex + range.endChar;
+        fileContentModifier.replace(startIndex, endIndex, fixReplacement.replacement);
+      } catch (StringIndexOutOfBoundsException e) {
+        // Most of the StringIndexOutOfBoundsException should never occur because we reject fix
+        // replacements for invalid ranges. However, we can't cover all cases for efficiency
+        // reasons. For instance, we don't determine the number of lines in a file. That's why we
+        // need to map this exception and thus provide a meaningful error.
+        throw new ResourceConflictException(
+            String.format("Cannot apply fix replacement for range %s", toString(range)), e);
+      }
+    }
+    return fileContentModifier.getResult();
+  }
+
+  private static String toString(Comment.Range range) {
+    return String.format(
+        "(%s:%s - %s:%s)", range.startLine, range.startChar, range.endLine, range.endChar);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.java
new file mode 100644
index 0000000..c32d822
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/LineIdentifier.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.server.fixes;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * An identifier of lines in a string. Lines are sequences of characters which are separated by any
+ * Unicode linebreak sequence as defined by the regular expression {@code \R}. If data for several
+ * lines is requested, calls which are ordered according to ascending line numbers are the most
+ * efficient.
+ */
+class LineIdentifier {
+
+  private static final Pattern LINE_SEPARATOR_PATTERN = Pattern.compile("\\R");
+  private final Matcher lineSeparatorMatcher;
+
+  private int nextLineNumber;
+  private int nextLineStartIndex;
+  private int currentLineStartIndex;
+  private int currentLineEndIndex;
+
+  LineIdentifier(String string) {
+    checkNotNull(string);
+    lineSeparatorMatcher = LINE_SEPARATOR_PATTERN.matcher(string);
+    reset();
+  }
+
+  /**
+   * Returns the start index of the indicated line within the given string. Start indices are
+   * zero-based while line numbers are one-based.
+   *
+   * <p><b>Note:</b> Requesting data for several lines is more efficient if those calls occur with
+   * increasing line number.
+   *
+   * @param lineNumber the line whose start index should be determined
+   * @return the start index of the line
+   * @throws StringIndexOutOfBoundsException if the line number is negative, zero or greater than
+   *     the identified number of lines
+   */
+  public int getStartIndexOfLine(int lineNumber) {
+    findLine(lineNumber);
+    return currentLineStartIndex;
+  }
+
+  /**
+   * Returns the length of the indicated line in the given string. The character(s) used to separate
+   * lines aren't included in the count. Line numbers are one-based.
+   *
+   * <p><b>Note:</b> Requesting data for several lines is more efficient if those calls occur with
+   * increasing line number.
+   *
+   * @param lineNumber the line whose length should be determined
+   * @return the length of the line
+   * @throws StringIndexOutOfBoundsException if the line number is negative, zero or greater than
+   *     the identified number of lines
+   */
+  public int getLengthOfLine(int lineNumber) {
+    findLine(lineNumber);
+    return currentLineEndIndex - currentLineStartIndex;
+  }
+
+  private void findLine(int targetLineNumber) {
+    if (targetLineNumber <= 0) {
+      throw new StringIndexOutOfBoundsException("Line number must be positive");
+    }
+    if (targetLineNumber < nextLineNumber) {
+      reset();
+    }
+    while (nextLineNumber < targetLineNumber + 1 && lineSeparatorMatcher.find()) {
+      currentLineStartIndex = nextLineStartIndex;
+      currentLineEndIndex = lineSeparatorMatcher.start();
+      nextLineStartIndex = lineSeparatorMatcher.end();
+      nextLineNumber++;
+    }
+
+    // End of string
+    if (nextLineNumber == targetLineNumber) {
+      currentLineStartIndex = nextLineStartIndex;
+      currentLineEndIndex = lineSeparatorMatcher.regionEnd();
+    }
+    if (nextLineNumber < targetLineNumber) {
+      throw new StringIndexOutOfBoundsException(
+          String.format("Line %d isn't available", targetLineNumber));
+    }
+  }
+
+  private void reset() {
+    nextLineNumber = 1;
+    nextLineStartIndex = 0;
+    currentLineStartIndex = 0;
+    currentLineEndIndex = 0;
+    lineSeparatorMatcher.reset();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.java b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.java
new file mode 100644
index 0000000..ccd40b3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/fixes/StringModifier.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.server.fixes;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+/**
+ * A modifier of a string. It allows to replace multiple parts of a string by indicating those parts
+ * with indices based on the unmodified string. There is one limitation though: Replacements which
+ * affect lower indices of the string must be specified before replacements for higher indices.
+ */
+class StringModifier {
+
+  private final StringBuilder stringBuilder;
+
+  private int characterShift = 0;
+  private int previousEndOffset = Integer.MIN_VALUE;
+
+  StringModifier(String string) {
+    checkNotNull(string, "string must not be null");
+    stringBuilder = new StringBuilder(string);
+  }
+
+  /**
+   * Replaces part of the string with another content. When called multiple times, the calls must be
+   * ordered according to increasing start indices. Overlapping replacement regions aren't
+   * supported.
+   *
+   * @param startIndex the beginning index in the unmodified string (inclusive)
+   * @param endIndex the ending index in the unmodified string (exclusive)
+   * @param replacement the string which should be used instead of the original content
+   * @throws StringIndexOutOfBoundsException if the start index is smaller than the end index of a
+   *     previous call of this method
+   */
+  public void replace(int startIndex, int endIndex, String replacement) {
+    checkNotNull(replacement, "replacement string must not be null");
+    if (previousEndOffset > startIndex) {
+      throw new StringIndexOutOfBoundsException(
+          String.format(
+              "Not supported to replace the content starting at index %s after previous "
+                  + "replacement which ended at index %s",
+              startIndex, previousEndOffset));
+    }
+    int shiftedStartIndex = startIndex + characterShift;
+    int shiftedEndIndex = endIndex + characterShift;
+    if (shiftedEndIndex > stringBuilder.length()) {
+      throw new StringIndexOutOfBoundsException(
+          String.format("end %s > length %s", shiftedEndIndex, stringBuilder.length()));
+    }
+    stringBuilder.replace(shiftedStartIndex, shiftedEndIndex, replacement);
+
+    int replacedContentLength = endIndex - startIndex;
+    characterShift += replacement.length() - replacedContentLength;
+    previousEndOffset = endIndex;
+  }
+
+  /**
+   * Returns the modified string including all specified replacements.
+   *
+   * @return the modified string
+   */
+  public String getResult() {
+    return stringBuilder.toString();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
index 99b647a..8298db3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/AbandonOp.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
 import com.google.gerrit.server.mail.send.AbandonedSender;
@@ -34,8 +35,8 @@
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -64,7 +65,7 @@
         @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify);
   }
 
-  @AssistedInject
+  @Inject
   AbandonOp(
       AbandonedSender.Factory abandonedSenderFactory,
       ChangeMessagesUtil cmUtil,
@@ -96,9 +97,7 @@
     PatchSet.Id psId = change.currentPatchSetId();
     ChangeUpdate update = ctx.getUpdate(psId);
     if (!change.getStatus().isOpen()) {
-      throw new ResourceConflictException("change is " + status(change));
-    } else if (change.getStatus() == Change.Status.DRAFT) {
-      throw new ResourceConflictException("draft changes cannot be abandoned");
+      throw new ResourceConflictException("change is " + ChangeUtil.status(change));
     }
     patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
     change.setStatus(Change.Status.ABANDONED);
@@ -137,8 +136,4 @@
     }
     changeAbandoned.fire(change, patchSet, account, msgTxt, ctx.getWhen(), notifyHandling);
   }
-
-  private static String status(Change change) {
-    return change != null ? change.getStatus().name().toLowerCase() : "deleted";
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/AsyncReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/AsyncReceiveCommits.java
deleted file mode 100644
index 420d5b3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/AsyncReceiveCommits.java
+++ /dev/null
@@ -1,178 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.config.ConfigUtil;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.WorkQueue.Executor;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.inject.Inject;
-import com.google.inject.PrivateModule;
-import com.google.inject.Provides;
-import com.google.inject.Singleton;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.FactoryModuleBuilder;
-import com.google.inject.name.Named;
-import java.io.OutputStream;
-import java.util.Collection;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.PreReceiveHook;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceiveCommand.Result;
-import org.eclipse.jgit.transport.ReceivePack;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Hook that delegates to {@link ReceiveCommits} in a worker thread. */
-public class AsyncReceiveCommits implements PreReceiveHook {
-  private static final Logger log = LoggerFactory.getLogger(AsyncReceiveCommits.class);
-
-  private static final String TIMEOUT_NAME = "ReceiveCommitsOverallTimeout";
-
-  public interface Factory {
-    AsyncReceiveCommits create(ProjectControl projectControl, Repository repository);
-  }
-
-  public static class Module extends PrivateModule {
-    @Override
-    public void configure() {
-      install(new FactoryModuleBuilder().build(AsyncReceiveCommits.Factory.class));
-      expose(AsyncReceiveCommits.Factory.class);
-      // Don't expose the binding for ReceiveCommits.Factory. All callers should
-      // be using AsyncReceiveCommits.Factory instead.
-      install(new FactoryModuleBuilder().build(ReceiveCommits.Factory.class));
-    }
-
-    @Provides
-    @Singleton
-    @Named(TIMEOUT_NAME)
-    long getTimeoutMillis(@GerritServerConfig final Config cfg) {
-      return ConfigUtil.getTimeUnit(
-          cfg, "receive", null, "timeout", TimeUnit.MINUTES.toMillis(4), TimeUnit.MILLISECONDS);
-    }
-  }
-
-  private class Worker implements ProjectRunnable {
-    private final Collection<ReceiveCommand> commands;
-
-    private Worker(final Collection<ReceiveCommand> commands) {
-      this.commands = commands;
-    }
-
-    @Override
-    public void run() {
-      rc.processCommands(commands, progress);
-    }
-
-    @Override
-    public Project.NameKey getProjectNameKey() {
-      return rc.getProject().getNameKey();
-    }
-
-    @Override
-    public String getRemoteName() {
-      return null;
-    }
-
-    @Override
-    public boolean hasCustomizedPrint() {
-      return true;
-    }
-
-    @Override
-    public String toString() {
-      return "receive-commits";
-    }
-  }
-
-  private class MessageSenderOutputStream extends OutputStream {
-    @Override
-    public void write(int b) {
-      rc.getMessageSender().sendBytes(new byte[] {(byte) b});
-    }
-
-    @Override
-    public void write(byte[] what, int off, int len) {
-      rc.getMessageSender().sendBytes(what, off, len);
-    }
-
-    @Override
-    public void write(byte[] what) {
-      rc.getMessageSender().sendBytes(what);
-    }
-
-    @Override
-    public void flush() {
-      rc.getMessageSender().flush();
-    }
-  }
-
-  private final ReceiveCommits rc;
-  private final Executor executor;
-  private final RequestScopePropagator scopePropagator;
-  private final MultiProgressMonitor progress;
-  private final long timeoutMillis;
-
-  @Inject
-  AsyncReceiveCommits(
-      final ReceiveCommits.Factory factory,
-      @ReceiveCommitsExecutor final Executor executor,
-      final RequestScopePropagator scopePropagator,
-      @Named(TIMEOUT_NAME) final long timeoutMillis,
-      @Assisted final ProjectControl projectControl,
-      @Assisted final Repository repo) {
-    this.executor = executor;
-    this.scopePropagator = scopePropagator;
-    rc = factory.create(projectControl, repo);
-    rc.getReceivePack().setPreReceiveHook(this);
-
-    progress = new MultiProgressMonitor(new MessageSenderOutputStream(), "Processing changes");
-    this.timeoutMillis = timeoutMillis;
-  }
-
-  @Override
-  public void onPreReceive(final ReceivePack rp, final Collection<ReceiveCommand> commands) {
-    try {
-      progress.waitFor(
-          executor.submit(scopePropagator.wrap(new Worker(commands))),
-          timeoutMillis,
-          TimeUnit.MILLISECONDS);
-    } catch (ExecutionException e) {
-      log.warn(
-          "Error in ReceiveCommits while processing changes for project {}",
-          rc.getProject().getName(),
-          e);
-      rc.addError("internal error while processing changes");
-      // ReceiveCommits has tried its best to catch errors, so anything at this
-      // point is very bad.
-      for (final ReceiveCommand c : commands) {
-        if (c.getResult() == Result.NOT_ATTEMPTED) {
-          c.setResult(Result.REJECTED_OTHER_REASON, "internal error");
-        }
-      }
-    } finally {
-      rc.sendMessages();
-    }
-  }
-
-  public ReceiveCommits getReceiveCommits() {
-    return rc;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommit.java
index d09e857..322d158 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommit.java
@@ -30,7 +30,6 @@
 import java.util.Date;
 import java.util.List;
 import java.util.TimeZone;
-import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.Constants;
@@ -75,10 +74,10 @@
 
   @Inject
   BanCommit(
-      final Provider<IdentifiedUser> currentUser,
-      final GitRepositoryManager repoManager,
-      @GerritPersonIdent final PersonIdent gerritIdent,
-      final NotesBranchUtil.Factory notesBranchUtilFactory) {
+      Provider<IdentifiedUser> currentUser,
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent gerritIdent,
+      NotesBranchUtil.Factory notesBranchUtilFactory) {
     this.currentUser = currentUser;
     this.repoManager = repoManager;
     this.notesBranchUtilFactory = notesBranchUtilFactory;
@@ -86,8 +85,8 @@
   }
 
   public BanCommitResult ban(
-      final ProjectControl projectControl, final List<ObjectId> commitsToBan, final String reason)
-      throws PermissionDeniedException, IOException, ConcurrentRefUpdateException {
+      ProjectControl projectControl, List<ObjectId> commitsToBan, String reason)
+      throws PermissionDeniedException, LockFailureException, IOException {
     if (!projectControl.isOwner()) {
       throw new PermissionDeniedException("Not project owner: not permitted to ban commits");
     }
@@ -100,7 +99,7 @@
         RevWalk revWalk = new RevWalk(repo);
         ObjectInserter inserter = repo.newObjectInserter()) {
       ObjectId noteId = null;
-      for (final ObjectId commitToBan : commitsToBan) {
+      for (ObjectId commitToBan : commitsToBan) {
         try {
           revWalk.parseCommit(commitToBan);
         } catch (MissingObjectException e) {
@@ -146,8 +145,7 @@
     return currentUser.get().newCommitterIdent(now, tz);
   }
 
-  private static String buildCommitMessage(
-      final List<ObjectId> bannedCommits, final String reason) {
+  private static String buildCommitMessage(List<ObjectId> bannedCommits, String reason) {
     final StringBuilder commitMsg = new StringBuilder();
     commitMsg.append("Banning ");
     commitMsg.append(bannedCommits.size());
@@ -161,7 +159,7 @@
     }
     commitMsg.append("The following commits are banned:\n");
     final StringBuilder commitList = new StringBuilder();
-    for (final ObjectId c : bannedCommits) {
+    for (ObjectId c : bannedCommits) {
       if (commitList.length() > 0) {
         commitList.append(",\n");
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommitResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommitResult.java
index baa6013..9fadae2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommitResult.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BanCommitResult.java
@@ -23,15 +23,15 @@
   private final List<ObjectId> alreadyBannedCommits = new ArrayList<>(4);
   private final List<ObjectId> ignoredObjectIds = new ArrayList<>(4);
 
-  public void commitBanned(final ObjectId commitId) {
+  public void commitBanned(ObjectId commitId) {
     newlyBannedCommits.add(commitId);
   }
 
-  public void commitAlreadyBanned(final ObjectId commitId) {
+  public void commitAlreadyBanned(ObjectId commitId) {
     alreadyBannedCommits.add(commitId);
   }
 
-  public void notACommit(final ObjectId id) {
+  public void notACommit(ObjectId id) {
     ignoredObjectIds.add(id);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java
deleted file mode 100644
index 1a39a76..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeProgressOp.java
+++ /dev/null
@@ -1,36 +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.server.git;
-
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import org.eclipse.jgit.lib.ProgressMonitor;
-
-/** Trivial op to update a counter during {@code updateChange} */
-class ChangeProgressOp implements BatchUpdateOp {
-  private final ProgressMonitor progress;
-
-  ChangeProgressOp(ProgressMonitor progress) {
-    this.progress = progress;
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx) {
-    synchronized (progress) {
-      progress.update(1);
-    }
-    return false;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeReportFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeReportFormatter.java
index 3bdfe4c..3ce6b2b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeReportFormatter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeReportFormatter.java
@@ -27,10 +27,13 @@
     public abstract String subject();
 
     @Nullable
-    public abstract Boolean isDraft();
+    public abstract Boolean isEdit();
 
     @Nullable
-    public abstract Boolean isEdit();
+    public abstract Boolean isPrivate();
+
+    @Nullable
+    public abstract Boolean isWorkInProgress();
 
     public static Builder builder() {
       return new AutoValue_ChangeReportFormatter_Input.Builder();
@@ -42,25 +45,31 @@
 
       public abstract Builder setSubject(String val);
 
-      public abstract Builder setIsDraft(Boolean val);
-
       public abstract Builder setIsEdit(Boolean val);
 
+      public abstract Builder setIsPrivate(Boolean val);
+
+      public abstract Builder setIsWorkInProgress(Boolean val);
+
       abstract Change change();
 
       abstract String subject();
 
-      abstract Boolean isDraft();
-
       abstract Boolean isEdit();
 
+      abstract Boolean isPrivate();
+
+      abstract Boolean isWorkInProgress();
+
       abstract Input autoBuild();
 
       public Input build() {
         setChange(change());
         setSubject(subject() == null ? change().getSubject() : subject());
-        setIsDraft(isDraft() == null ? Change.Status.DRAFT == change().getStatus() : isDraft());
         setIsEdit(isEdit() == null ? false : isEdit());
+        setIsPrivate(isPrivate() == null ? change().isPrivate() : isPrivate());
+        setIsWorkInProgress(
+            isWorkInProgress() == null ? change().isWorkInProgress() : isWorkInProgress());
         return autoBuild();
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
index 03d44ca..e8569af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ChangeSet.java
@@ -22,6 +22,7 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import java.util.Collection;
@@ -91,6 +92,14 @@
     return changeData.values();
   }
 
+  public ImmutableSet<Project.NameKey> projects() {
+    ImmutableSet.Builder<Project.NameKey> ret = ImmutableSet.builder();
+    for (ChangeData cd : changeData.values()) {
+      ret.add(cd.project());
+    }
+    return ret.build();
+  }
+
   public ImmutableSet<Change.Id> nonVisibleIds() {
     return nonVisibleChanges.keySet();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
index 80c705e..7bc6648 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/CodeReviewCommit.java
@@ -21,7 +21,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.git.strategy.CommitMergeStatus;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
 import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -84,7 +83,7 @@
     }
 
     @Override
-    public void markUninteresting(final RevCommit c)
+    public void markUninteresting(RevCommit c)
         throws MissingObjectException, IncorrectObjectTypeException, IOException {
       checkArgument(c instanceof CodeReviewCommit);
       super.markUninteresting(c);
@@ -110,8 +109,7 @@
    */
   private PatchSet.Id patchsetId;
 
-  /** Change control for the change owner. */
-  private ChangeControl control;
+  private ChangeNotes notes;
 
   /**
    * The result status for this commit.
@@ -120,12 +118,12 @@
    */
   private CommitMergeStatus statusCode;
 
-  public CodeReviewCommit(final AnyObjectId id) {
+  public CodeReviewCommit(AnyObjectId id) {
     super(id);
   }
 
   public ChangeNotes notes() {
-    return getControl().getNotes();
+    return notes;
   }
 
   public CommitMergeStatus getStatusCode() {
@@ -144,21 +142,21 @@
     this.patchsetId = patchsetId;
   }
 
-  public void copyFrom(final CodeReviewCommit src) {
-    control = src.control;
+  public void copyFrom(CodeReviewCommit src) {
+    notes = src.notes;
     patchsetId = src.patchsetId;
     statusCode = src.statusCode;
   }
 
   public Change change() {
-    return getControl().getChange();
+    return getNotes().getChange();
   }
 
-  public ChangeControl getControl() {
-    return control;
+  public ChangeNotes getNotes() {
+    return notes;
   }
 
-  public void setControl(ChangeControl control) {
-    this.control = control;
+  public void setNotes(ChangeNotes notes) {
+    this.notes = notes;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
index 7a266ff..ac69ff1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultChangeReportFormatter.java
@@ -49,12 +49,15 @@
             .append(ChangeUtil.formatChangeUrl(url, input.change()))
             .append(" ")
             .append(ChangeUtil.cropSubject(input.subject()));
-    if (input.isDraft()) {
-      m.append(" [DRAFT]");
-    }
     if (input.isEdit()) {
       m.append(" [EDIT]");
     }
+    if (input.isPrivate()) {
+      m.append(" [PRIVATE]");
+    }
+    if (input.isWorkInProgress()) {
+      m.append(" [WIP]");
+    }
     return m.toString();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultQueueOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultQueueOp.java
index a9c21ff..b30acfa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultQueueOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/DefaultQueueOp.java
@@ -20,7 +20,7 @@
 public abstract class DefaultQueueOp implements Runnable {
   private final WorkQueue workQueue;
 
-  protected DefaultQueueOp(final WorkQueue wq) {
+  protected DefaultQueueOp(WorkQueue wq) {
     workQueue = wq;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java
index 33c31fd..3bf89c7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GarbageCollection.java
@@ -112,7 +112,7 @@
     return result;
   }
 
-  private void fire(final Project.NameKey p, final Properties statistics) {
+  private void fire(Project.NameKey p, Properties statistics) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java
index 03910eb..92514da 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModule.java
@@ -26,7 +26,6 @@
     factory(RenameGroupOp.Factory.class);
     factory(MetaDataUpdate.InternalFactory.class);
     bind(MetaDataUpdate.Server.class);
-    bind(ReceiveConfig.class);
     DynamicSet.bind(binder(), PostUploadHook.class).to(UploadPackMetricsHook.class);
     DynamicItem.itemOf(binder(), ChangeReportFormatter.class);
     DynamicItem.bind(binder(), ChangeReportFormatter.class).to(DefaultChangeReportFormatter.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
index e680ea7..46916c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GitModules.java
@@ -23,8 +23,8 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.server.util.SubmoduleSectionParser;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -55,7 +55,7 @@
   private final RequestId submissionId;
   Set<SubmoduleSubscription> subscriptions;
 
-  @AssistedInject
+  @Inject
   GitModules(
       @CanonicalWebUrl @Nullable String canonicalWebUrl,
       @Assisted Branch.NameKey branch,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
index 960c72a..4a7c7e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/GroupCollector.java
@@ -48,7 +48,7 @@
 import org.slf4j.LoggerFactory;
 
 /**
- * Helper for assigning groups to commits during {@link ReceiveCommits}.
+ * Helper for assigning groups to commits during {@code ReceiveCommits}.
  *
  * <p>For each commit encountered along a walk between the branch tip and the tip of the push, the
  * group of a commit is defined as follows:
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java
index 9c43bdb..b80f846 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/InMemoryInserter.java
@@ -95,6 +95,10 @@
     return ImmutableList.copyOf(inserted.values());
   }
 
+  public int getInsertedObjectCount() {
+    return inserted.values().size();
+  }
+
   public void clear() {
     inserted.clear();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
index 6a05d22..73cda7f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LabelNormalizer.java
@@ -24,28 +24,26 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.Collection;
 import java.util.List;
 
 /**
- * Normalizes votes on labels according to project config and permissions.
+ * Normalizes votes on labels according to project config.
  *
  * <p>Votes are recorded in the database for a user based on the state of the project at that time:
- * what labels are defined for the project, and what the user is allowed to vote on. Both of those
- * can change between the time a vote is originally made and a later point, for example when a
- * change is submitted. This class normalizes old votes against current project configuration.
+ * what labels are defined for the project. The label definition can change between the time a vote
+ * is originally made and a later point, for example when a change is submitted. This class
+ * normalizes old votes against current project configuration.
  */
 @Singleton
 public class LabelNormalizer {
@@ -73,53 +71,50 @@
     }
   }
 
-  private final Provider<ReviewDb> db;
-  private final ChangeControl.GenericFactory changeFactory;
   private final IdentifiedUser.GenericFactory userFactory;
+  private final ProjectCache projectCache;
 
   @Inject
-  LabelNormalizer(
-      Provider<ReviewDb> db,
-      ChangeControl.GenericFactory changeFactory,
-      IdentifiedUser.GenericFactory userFactory) {
-    this.db = db;
-    this.changeFactory = changeFactory;
+  LabelNormalizer(IdentifiedUser.GenericFactory userFactory, ProjectCache projectCache) {
     this.userFactory = userFactory;
+    this.projectCache = projectCache;
   }
 
   /**
-   * @param change change containing the given approvals.
+   * @param notes change containing the given approvals.
    * @param approvals list of approvals.
-   * @return copies of approvals normalized to the defined ranges for the label type and permissions
-   *     for the user. Approvals for unknown labels are not included in the output, nor are
-   *     approvals where the user has no permissions for that label.
+   * @return copies of approvals normalized to the defined ranges for the label type. Approvals for
+   *     unknown labels are not included in the output.
    * @throws OrmException
    */
-  public Result normalize(Change change, Collection<PatchSetApproval> approvals)
-      throws OrmException {
-    IdentifiedUser user = userFactory.create(change.getOwner());
-    return normalize(changeFactory.controlFor(db.get(), change, user), approvals);
+  public Result normalize(ChangeNotes notes, Collection<PatchSetApproval> approvals)
+      throws OrmException, IOException {
+    IdentifiedUser user = userFactory.create(notes.getChange().getOwner());
+    return normalize(notes, user, approvals);
   }
 
   /**
-   * @param ctl change control containing the given approvals.
+   * @param notes change notes containing the given approvals.
+   * @param user current user.
    * @param approvals list of approvals.
-   * @return copies of approvals normalized to the defined ranges for the label type and permissions
-   *     for the user. Approvals for unknown labels are not included in the output, nor are
-   *     approvals where the user has no permissions for that label.
+   * @return copies of approvals normalized to the defined ranges for the label type. Approvals for
+   *     unknown labels are not included in the output.
    */
-  public Result normalize(ChangeControl ctl, Collection<PatchSetApproval> approvals) {
+  public Result normalize(
+      ChangeNotes notes, CurrentUser user, Collection<PatchSetApproval> approvals)
+      throws IOException {
     List<PatchSetApproval> unchanged = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> updated = Lists.newArrayListWithCapacity(approvals.size());
     List<PatchSetApproval> deleted = Lists.newArrayListWithCapacity(approvals.size());
-    LabelTypes labelTypes = ctl.getLabelTypes();
+    LabelTypes labelTypes =
+        projectCache.checkedGet(notes.getProjectName()).getLabelTypes(notes, user);
     for (PatchSetApproval psa : approvals) {
       Change.Id changeId = psa.getKey().getParentKey().getParentKey();
       checkArgument(
-          changeId.equals(ctl.getId()),
+          changeId.equals(notes.getChangeId()),
           "Approval %s does not match change %s",
           psa.getKey(),
-          ctl.getChange().getKey());
+          notes.getChange().getKey());
       if (psa.isLegacySubmit()) {
         unchanged.add(psa);
         continue;
@@ -131,9 +126,7 @@
       }
       PatchSetApproval copy = copy(psa);
       applyTypeFloor(label, copy);
-      if (!applyRightFloor(ctl, label, copy)) {
-        deleted.add(psa);
-      } else if (copy.getValue() != psa.getValue()) {
+      if (copy.getValue() != psa.getValue()) {
         updated.add(copy);
       } else {
         unchanged.add(psa);
@@ -142,35 +135,10 @@
     return Result.create(unchanged, updated, deleted);
   }
 
-  /**
-   * @param ctl change control (for any user).
-   * @param lt label type.
-   * @param id account ID.
-   * @return whether the given account ID has any permissions to vote on this label for this change.
-   */
-  public boolean canVote(ChangeControl ctl, LabelType lt, Account.Id id) {
-    return !getRange(ctl, lt, id).isEmpty();
-  }
-
   private PatchSetApproval copy(PatchSetApproval src) {
     return new PatchSetApproval(src.getPatchSetId(), src);
   }
 
-  private PermissionRange getRange(ChangeControl ctl, LabelType lt, Account.Id id) {
-    String permission = Permission.forLabel(lt.getName());
-    IdentifiedUser user = userFactory.create(id);
-    return ctl.forUser(user).getRange(permission);
-  }
-
-  private boolean applyRightFloor(ChangeControl ctl, LabelType lt, PatchSetApproval a) {
-    PermissionRange range = getRange(ctl, lt, a.getAccountId());
-    if (range.isEmpty()) {
-      return false;
-    }
-    a.setValue((short) range.squash(a.getValue()));
-    return true;
-  }
-
   private void applyTypeFloor(LabelType lt, PatchSetApproval a) {
     LabelValue atMin = lt.getMin();
     if (atMin != null && a.getValue() < atMin.getValue()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LargeObjectException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LargeObjectException.java
index bcde7f8..04db42c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LargeObjectException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LargeObjectException.java
@@ -25,8 +25,7 @@
 
   private static final long serialVersionUID = 1L;
 
-  public LargeObjectException(
-      final String message, final org.eclipse.jgit.errors.LargeObjectException cause) {
+  public LargeObjectException(String message, org.eclipse.jgit.errors.LargeObjectException cause) {
     super(message, cause);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java
deleted file mode 100644
index bc12e02..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LazyPostReceiveHookChain.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.inject.Inject;
-import java.util.Collection;
-import org.eclipse.jgit.transport.PostReceiveHook;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceivePack;
-
-class LazyPostReceiveHookChain implements PostReceiveHook {
-  private final DynamicSet<PostReceiveHook> hooks;
-
-  @Inject
-  LazyPostReceiveHookChain(DynamicSet<PostReceiveHook> hooks) {
-    this.hooks = hooks;
-  }
-
-  @Override
-  public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
-    for (PostReceiveHook h : hooks) {
-      h.onPostReceive(rp, commands);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
index 5f836ae..8f075de 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LocalDiskRepositoryManager.java
@@ -66,7 +66,7 @@
     private final Config serverConfig;
 
     @Inject
-    Lifecycle(@GerritServerConfig final Config cfg) {
+    Lifecycle(@GerritServerConfig Config cfg) {
       this.serverConfig = cfg;
     }
 
@@ -240,7 +240,7 @@
     }
   }
 
-  private void onCreateProject(final Project.NameKey newProjectName) {
+  private void onCreateProject(Project.NameKey newProjectName) {
     namesUpdateLock.lock();
     try {
       SortedSet<Project.NameKey> n = new TreeSet<>(names);
@@ -251,7 +251,7 @@
     }
   }
 
-  private boolean isUnreasonableName(final Project.NameKey nameKey) {
+  private boolean isUnreasonableName(Project.NameKey nameKey) {
     final String name = nameKey.get();
 
     return name.length() == 0 // no empty paths
@@ -272,7 +272,9 @@
         || name.contains(">") // redirect output
         || name.contains("|") // pipe
         || name.contains("$") // dollar sign
-        || name.contains("\r"); // carriage return
+        || name.contains("\r") // carriage return
+        || name.contains("/+") // delimiter in /changes/
+        || name.contains("~"); // delimiter in /changes/
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java
index 7380b0a..02a30e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/LockFailureException.java
@@ -14,13 +14,38 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
 import java.io.IOException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** Thrown when updating a ref in Git fails with LOCK_FAILURE. */
 public class LockFailureException extends IOException {
   private static final long serialVersionUID = 1L;
 
-  public LockFailureException(String message) {
+  private final ImmutableList<String> refs;
+
+  public LockFailureException(String message, RefUpdate refUpdate) {
     super(message);
+    refs = ImmutableList.of(refUpdate.getName());
+  }
+
+  public LockFailureException(String message, BatchRefUpdate batchRefUpdate) {
+    super(message);
+    refs =
+        batchRefUpdate
+            .getCommands()
+            .stream()
+            .filter(c -> c.getResult() == ReceiveCommand.Result.LOCK_FAILURE)
+            .map(ReceiveCommand::getRefName)
+            .collect(toImmutableList());
+  }
+
+  /** Subset of ref names that caused the lock failure. */
+  public ImmutableList<String> getFailedRefs() {
+    return refs;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
index 1511da0..b6fbbe2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
@@ -20,6 +20,8 @@
 import static java.util.Comparator.comparing;
 import static java.util.stream.Collectors.toSet;
 
+import com.github.rholder.retry.Attempt;
+import com.github.rholder.retry.RetryListener;
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableListMultimap;
@@ -40,6 +42,9 @@
 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.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -58,7 +63,8 @@
 import com.google.gerrit.server.git.strategy.SubmitStrategyListener;
 import com.google.gerrit.server.git.validators.MergeValidationException;
 import com.google.gerrit.server.git.validators.MergeValidators;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -66,10 +72,13 @@
 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.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -80,6 +89,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
@@ -103,14 +113,17 @@
   private static final Logger log = LoggerFactory.getLogger(MergeOp.class);
 
   private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS = SubmitRuleOptions.defaults().build();
+  private static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_ALLOW_CLOSED =
+      SUBMIT_RULE_OPTIONS.toBuilder().allowClosed(true).build();
 
   public static class CommitStatus {
     private final ImmutableMap<Change.Id, ChangeData> changes;
     private final ImmutableSetMultimap<Branch.NameKey, Change.Id> byBranch;
     private final Map<Change.Id, CodeReviewCommit> commits;
     private final ListMultimap<Change.Id, String> problems;
+    private final boolean allowClosed;
 
-    private CommitStatus(ChangeSet cs) throws OrmException {
+    private CommitStatus(ChangeSet cs, boolean allowClosed) throws OrmException {
       checkArgument(
           !cs.furtherHiddenChanges(), "CommitStatus must not be called with hidden changes");
       changes = cs.changesById();
@@ -121,6 +134,7 @@
       byBranch = bb.build();
       commits = new HashMap<>();
       problems = MultimapBuilder.treeKeys(comparing(Change.Id::get)).arrayListValues(1).build();
+      this.allowClosed = allowClosed;
     }
 
     public ImmutableSet<Change.Id> getChangeIds() {
@@ -173,7 +187,7 @@
       // date by this point.
       ChangeData cd = checkNotNull(changes.get(id), "ChangeData for %s", id);
       return checkNotNull(
-          cd.getSubmitRecords(SUBMIT_RULE_OPTIONS),
+          cd.getSubmitRecords(submitRuleOptions(allowClosed)),
           "getSubmitRecord only valid after submit rules are evalutated");
     }
 
@@ -214,22 +228,25 @@
   private final InternalUser.Factory internalUserFactory;
   private final MergeSuperSet mergeSuperSet;
   private final MergeValidators.Factory mergeValidatorsFactory;
-  private final InternalChangeQuery internalChangeQuery;
+  private final Provider<InternalChangeQuery> queryProvider;
   private final SubmitStrategyFactory submitStrategyFactory;
   private final SubmoduleOp.Factory subOpFactory;
-  private final MergeOpRepoManager orm;
+  private final Provider<MergeOpRepoManager> ormProvider;
   private final NotifyUtil notifyUtil;
+  private final RetryHelper retryHelper;
 
   private Timestamp ts;
   private RequestId submissionId;
   private IdentifiedUser caller;
 
+  private MergeOpRepoManager orm;
   private CommitStatus commitStatus;
   private ReviewDb db;
   private SubmitInput submitInput;
   private ListMultimap<RecipientType, Account.Id> accountsToNotify;
   private Set<Project.NameKey> allProjects;
   private boolean dryrun;
+  private TopicMetrics topicMetrics;
 
   @Inject
   MergeOp(
@@ -238,34 +255,41 @@
       InternalUser.Factory internalUserFactory,
       MergeSuperSet mergeSuperSet,
       MergeValidators.Factory mergeValidatorsFactory,
-      InternalChangeQuery internalChangeQuery,
+      Provider<InternalChangeQuery> queryProvider,
       SubmitStrategyFactory submitStrategyFactory,
       SubmoduleOp.Factory subOpFactory,
-      MergeOpRepoManager orm,
-      NotifyUtil notifyUtil) {
+      Provider<MergeOpRepoManager> ormProvider,
+      NotifyUtil notifyUtil,
+      TopicMetrics topicMetrics,
+      RetryHelper retryHelper) {
     this.cmUtil = cmUtil;
     this.batchUpdateFactory = batchUpdateFactory;
     this.internalUserFactory = internalUserFactory;
     this.mergeSuperSet = mergeSuperSet;
     this.mergeValidatorsFactory = mergeValidatorsFactory;
-    this.internalChangeQuery = internalChangeQuery;
+    this.queryProvider = queryProvider;
     this.submitStrategyFactory = submitStrategyFactory;
     this.subOpFactory = subOpFactory;
-    this.orm = orm;
+    this.ormProvider = ormProvider;
     this.notifyUtil = notifyUtil;
+    this.retryHelper = retryHelper;
+    this.topicMetrics = topicMetrics;
   }
 
   @Override
   public void close() {
-    orm.close();
+    if (orm != null) {
+      orm.close();
+    }
   }
 
-  public static void checkSubmitRule(ChangeData cd) throws ResourceConflictException, OrmException {
+  public static void checkSubmitRule(ChangeData cd, boolean allowClosed)
+      throws ResourceConflictException, OrmException {
     PatchSet patchSet = cd.currentPatchSet();
     if (patchSet == null) {
       throw new ResourceConflictException("missing current patch set for change " + cd.getId());
     }
-    List<SubmitRecord> results = getSubmitRecords(cd);
+    List<SubmitRecord> results = getSubmitRecords(cd, allowClosed);
     if (SubmitRecord.findOkRecord(results).isPresent()) {
       // Rules supplied a valid solution.
       return;
@@ -299,8 +323,13 @@
     throw new IllegalStateException();
   }
 
-  private static List<SubmitRecord> getSubmitRecords(ChangeData cd) throws OrmException {
-    return cd.submitRecords(SUBMIT_RULE_OPTIONS);
+  private static SubmitRuleOptions submitRuleOptions(boolean allowClosed) {
+    return allowClosed ? SUBMIT_RULE_OPTIONS_ALLOW_CLOSED : SUBMIT_RULE_OPTIONS;
+  }
+
+  private static List<SubmitRecord> getSubmitRecords(ChangeData cd, boolean allowClosed)
+      throws OrmException {
+    return cd.submitRecords(submitRuleOptions(allowClosed));
   }
 
   private static String describeLabels(ChangeData cd, List<SubmitRecord.Label> labels)
@@ -334,17 +363,23 @@
     return Joiner.on("; ").join(labelResults);
   }
 
-  private void checkSubmitRulesAndState(ChangeSet cs) throws ResourceConflictException {
+  private void checkSubmitRulesAndState(ChangeSet cs, boolean allowMerged)
+      throws ResourceConflictException {
     checkArgument(
         !cs.furtherHiddenChanges(), "checkSubmitRulesAndState called for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
       try {
-        if (cd.change().getStatus() != Change.Status.NEW) {
-          commitStatus.problem(
-              cd.getId(),
-              "Change " + cd.getId() + " is " + cd.change().getStatus().toString().toLowerCase());
+        Change.Status status = cd.change().getStatus();
+        if (status != Change.Status.NEW) {
+          if (!(status == Change.Status.MERGED && allowMerged)) {
+            commitStatus.problem(
+                cd.getId(),
+                "Change " + cd.getId() + " is " + cd.change().getStatus().toString().toLowerCase());
+          }
+        } else if (cd.change().isWorkInProgress()) {
+          commitStatus.problem(cd.getId(), "Change " + cd.getId() + " is work in progress");
         } else {
-          checkSubmitRule(cd);
+          checkSubmitRule(cd, allowMerged);
         }
       } catch (ResourceConflictException e) {
         commitStatus.problem(cd.getId(), e.getMessage());
@@ -357,21 +392,15 @@
     commitStatus.maybeFailVerbose();
   }
 
-  private void bypassSubmitRules(ChangeSet cs) {
+  private void bypassSubmitRules(ChangeSet cs, boolean allowClosed) throws OrmException {
     checkArgument(
         !cs.furtherHiddenChanges(), "cannot bypass submit rules for topic with hidden change");
     for (ChangeData cd : cs.changes()) {
-      List<SubmitRecord> records;
-      try {
-        records = new ArrayList<>(getSubmitRecords(cd));
-      } catch (OrmException e) {
-        log.warn("Error checking submit rules for change " + cd.getId(), e);
-        records = new ArrayList<>(1);
-      }
+      List<SubmitRecord> records = new ArrayList<>(getSubmitRecords(cd, allowClosed));
       SubmitRecord forced = new SubmitRecord();
       forced.status = SubmitRecord.Status.FORCED;
       records.add(forced);
-      cd.setSubmitRecords(SUBMIT_RULE_OPTIONS, records);
+      cd.setSubmitRecords(submitRuleOptions(allowClosed), records);
     }
   }
 
@@ -389,6 +418,8 @@
    * @param submitInput parameters regarding the merge
    * @throws OrmException an error occurred reading or writing the database.
    * @throws RestApiException if an error occurred.
+   * @throws PermissionBackendException if permissions can't be checked
+   * @throws IOException an error occurred reading from NoteDb.
    */
   public void merge(
       ReviewDb db,
@@ -397,7 +428,8 @@
       boolean checkSubmitRules,
       SubmitInput submitInput,
       boolean dryrun)
-      throws OrmException, RestApiException {
+      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     this.submitInput = submitInput;
     this.accountsToNotify = notifyUtil.resolveAccounts(submitInput.notifyDetails);
     this.dryrun = dryrun;
@@ -405,7 +437,7 @@
     this.ts = TimeUtil.nowTs();
     submissionId = RequestId.forChange(change);
     this.db = db;
-    orm.setContext(db, ts, caller, submissionId);
+    openRepoManager();
 
     logDebug("Beginning integration of {}", change);
     try {
@@ -416,21 +448,52 @@
         throw new AuthException(
             "A change to be submitted with " + change.getId() + " is not visible");
       }
-      this.commitStatus = new CommitStatus(cs);
-      MergeSuperSet.reloadChanges(cs);
       logDebug("Calculated to merge {}", cs);
-      if (checkSubmitRules) {
-        logDebug("Checking submit rules and state");
-        checkSubmitRulesAndState(cs);
-      } else {
-        logDebug("Bypassing submit rules");
-        bypassSubmitRules(cs);
+
+      // Count cross-project submissions outside of the retry loop. The chance of a single project
+      // failing increases with the number of projects, so the failure count would be inflated if
+      // this metric were incremented inside of integrateIntoHistory.
+      int projects = cs.projects().size();
+      if (projects > 1) {
+        topicMetrics.topicSubmissions.increment();
       }
-      try {
-        integrateIntoHistory(cs);
-      } catch (IntegrationException e) {
-        logError("Error from integrateIntoHistory", e);
-        throw new ResourceConflictException(e.getMessage(), e);
+
+      RetryTracker retryTracker = new RetryTracker();
+      retryHelper.execute(
+          updateFactory -> {
+            long attempt = retryTracker.lastAttemptNumber + 1;
+            boolean isRetry = attempt > 1;
+            if (isRetry) {
+              logDebug("Retrying, attempt #{}; skipping merged changes", attempt);
+              this.ts = TimeUtil.nowTs();
+              openRepoManager();
+            }
+            this.commitStatus = new CommitStatus(cs, isRetry);
+            MergeSuperSet.reloadChanges(cs);
+            if (checkSubmitRules) {
+              logDebug("Checking submit rules and state");
+              checkSubmitRulesAndState(cs, isRetry);
+            } else {
+              logDebug("Bypassing submit rules");
+              bypassSubmitRules(cs, isRetry);
+            }
+            try {
+              integrateIntoHistory(cs);
+            } catch (IntegrationException e) {
+              logError("Error from integrateIntoHistory", e);
+              throw new ResourceConflictException(e.getMessage(), e);
+            }
+            return null;
+          },
+          RetryHelper.options()
+              .listener(retryTracker)
+              // Up to the entire submit operation is retried, including possibly many projects.
+              // Multiply the timeout by the number of projects we're actually attempting to submit.
+              .timeout(retryHelper.getDefaultTimeout().multipliedBy(cs.projects().size()))
+              .build());
+
+      if (projects > 1) {
+        topicMetrics.topicSubmissionsCompleted.increment();
       }
     } catch (IOException e) {
       // Anything before the merge attempt is an error
@@ -438,7 +501,44 @@
     }
   }
 
-  private void integrateIntoHistory(ChangeSet cs) throws IntegrationException, RestApiException {
+  private void openRepoManager() {
+    if (orm != null) {
+      orm.close();
+    }
+    orm = ormProvider.get();
+    orm.setContext(db, ts, caller, submissionId);
+  }
+
+  private class RetryTracker implements RetryListener {
+    long lastAttemptNumber;
+
+    @Override
+    public <V> void onRetry(Attempt<V> attempt) {
+      lastAttemptNumber = attempt.getAttemptNumber();
+    }
+  }
+
+  @Singleton
+  private static class TopicMetrics {
+    final Counter0 topicSubmissions;
+    final Counter0 topicSubmissionsCompleted;
+
+    @Inject
+    TopicMetrics(MetricMaker metrics) {
+      topicSubmissions =
+          metrics.newCounter(
+              "topic/cross_project_submit",
+              new Description("Attempts at cross project topic submission").setRate());
+      topicSubmissionsCompleted =
+          metrics.newCounter(
+              "topic/cross_project_submit_completed",
+              new Description("Cross project topic submissions that concluded successfully")
+                  .setRate());
+    }
+  }
+
+  private void integrateIntoHistory(ChangeSet cs)
+      throws IntegrationException, RestApiException, UpdateException {
     checkArgument(!cs.furtherHiddenChanges(), "cannot integrate hidden changes into history");
     logDebug("Beginning merge attempt on {}", cs);
     Map<Branch.NameKey, BranchBatch> toSubmit = new HashMap<>();
@@ -450,16 +550,19 @@
       throw new IntegrationException("Error reading changes to submit", e);
     }
     Set<Branch.NameKey> branches = cbb.keySet();
+
     for (Branch.NameKey branch : branches) {
       OpenRepo or = openRepo(branch.getParentKey());
       if (or != null) {
         toSubmit.put(branch, validateChangeList(or, cbb.get(branch)));
       }
     }
+
     // Done checks that don't involve running submit strategies.
     commitStatus.maybeFailVerbose();
-    SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
+
     try {
+      SubmoduleOp submoduleOp = subOpFactory.create(branches, orm);
       List<SubmitStrategy> strategies = getSubmitStrategies(toSubmit, submoduleOp, dryrun);
       this.allProjects = submoduleOp.getProjectsInOrder();
       batchUpdateFactory.execute(
@@ -472,6 +575,15 @@
     } catch (IOException | SubmoduleException e) {
       throw new IntegrationException(e);
     } catch (UpdateException e) {
+      if (e.getCause() instanceof LockFailureException) {
+        // Lock failures are a special case: RetryHelper depends on this specific causal chain in
+        // order to trigger a retry. The downside of throwing here is we will not get the nicer
+        // error message constructed below, in the case where this is the final attempt and the
+        // operation is not retried further. This is not a huge downside, and is hopefully so rare
+        // as to be unnoticeable, assuming RetryHelper is retrying sufficiently.
+        throw e;
+      }
+
       // BatchUpdate may have inadvertently wrapped an IntegrationException
       // thrown by some legacy SubmitStrategyOp code that intended the error
       // message to be user-visible. Copy the message from the wrapped
@@ -483,7 +595,7 @@
       if (e.getCause() instanceof IntegrationException) {
         msg = e.getCause().getMessage();
       } else {
-        msg = "Error submitting change" + (cs.size() != 1 ? "s" : "");
+        msg = genericMergeError(cs);
       }
       throw new IntegrationException(msg, e);
     }
@@ -519,9 +631,7 @@
             submitStrategyFactory.create(
                 submitting.submitType(),
                 db,
-                or.repo,
                 or.rw,
-                or.ins,
                 or.canMergeFlag,
                 getAlreadyAccepted(or, ob.oldTip),
                 allCommits,
@@ -530,7 +640,7 @@
                 ob.mergeTip,
                 commitStatus,
                 submissionId,
-                submitInput.notify,
+                submitInput,
                 accountsToNotify,
                 submoduleOp,
                 dryrun);
@@ -594,17 +704,18 @@
     ChangeData choseSubmitTypeFrom = null;
     for (ChangeData cd : submitted) {
       Change.Id changeId = cd.getId();
-      ChangeControl ctl;
+      ChangeNotes notes;
       Change chg;
+      SubmitType st;
       try {
-        ctl = cd.changeControl();
+        notes = cd.notes();
         chg = cd.change();
+        st = getSubmitType(cd);
       } catch (OrmException e) {
         commitStatus.logProblem(changeId, e);
         continue;
       }
 
-      SubmitType st = getSubmitType(cd);
       if (st == null) {
         commitStatus.logProblem(changeId, "No submit type for change");
         continue;
@@ -675,8 +786,7 @@
         continue;
       }
 
-      // TODO(dborowitz): Consider putting ChangeData in CodeReviewCommit.
-      commit.setControl(ctl);
+      commit.setNotes(notes);
       commit.setPatchsetId(ps.getId());
       commitStatus.put(commit);
 
@@ -720,14 +830,9 @@
     }
   }
 
-  private SubmitType getSubmitType(ChangeData cd) {
-    try {
-      SubmitTypeRecord str = cd.submitTypeRecord();
-      return str.isOk() ? str.type : null;
-    } catch (OrmException e) {
-      logError("Failed to get submit type for " + cd.getId(), e);
-      return null;
-    }
+  private SubmitType getSubmitType(ChangeData cd) throws OrmException {
+    SubmitTypeRecord str = cd.submitTypeRecord();
+    return str.isOk() ? str.type : null;
   }
 
   private OpenRepo openRepo(Project.NameKey project) throws IntegrationException {
@@ -744,7 +849,7 @@
 
   private void abandonAllOpenChangeForDeletedProject(Project.NameKey destProject) {
     try {
-      for (ChangeData cd : internalChangeQuery.byProjectOpen(destProject)) {
+      for (ChangeData cd : queryProvider.get().byProjectOpen(destProject)) {
         try (BatchUpdate bu =
             batchUpdateFactory.create(db, destProject, internalUserFactory.create(), ts)) {
           bu.setRequestId(submissionId);
@@ -785,6 +890,27 @@
     }
   }
 
+  private String genericMergeError(ChangeSet cs) {
+    int c = cs.size();
+    if (c == 1) {
+      return "Error submitting change";
+    }
+    int p = cs.projects().size();
+    if (p == 1) {
+      // Fused updates: it's correct to say that none of the n changes were submitted.
+      return "Error submitting " + c + " changes";
+    }
+    // Multiple projects involved, but we don't know at this point what failed. At least give the
+    // user a heads up that some changes may be unsubmitted, even if the change screen they land on
+    // after the error message says that this particular change was submitted.
+    return "Error submitting some of the "
+        + c
+        + " changes to one or more of the "
+        + p
+        + " projects involved; some projects may have submitted successfully, but others may have"
+        + " failed";
+  }
+
   private void logDebug(String msg, Object... args) {
     if (log.isDebugEnabled()) {
       log.debug(submissionId + msg, args);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
index ad205f8..29b2548 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOpRepoManager.java
@@ -19,6 +19,7 @@
 import com.google.common.collect.Maps;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -94,7 +95,7 @@
     }
 
     Project.NameKey getProjectName() {
-      return project.getProject().getNameKey();
+      return project.getNameKey();
     }
 
     public CodeReviewRevWalk getCodeReviewRevWalk() {
@@ -135,7 +136,8 @@
         update = or.repo.updateRef(name.get());
         if (update.getOldObjectId() != null) {
           oldTip = or.rw.parseCommit(update.getOldObjectId());
-        } else if (Objects.equals(or.repo.getFullBranch(), name.get())) {
+        } else if (Objects.equals(or.repo.getFullBranch(), name.get())
+            || Objects.equals(RefNames.REFS_CONFIG, name.get())) {
           oldTip = null;
           update.setExpectedOldObjectId(ObjectId.zeroId());
         } else {
@@ -173,7 +175,7 @@
     openRepos = new HashMap<>();
   }
 
-  void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller, RequestId submissionId) {
+  public void setContext(ReviewDb db, Timestamp ts, IdentifiedUser caller, RequestId submissionId) {
     this.db = db;
     this.ts = ts;
     this.caller = caller;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
index 78fc495..d547d7f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSorter.java
@@ -29,20 +29,20 @@
   private final CodeReviewRevWalk rw;
   private final RevFlag canMergeFlag;
   private final Set<RevCommit> accepted;
+  private final Set<CodeReviewCommit> incoming;
 
-  public MergeSorter(CodeReviewRevWalk rw, Set<RevCommit> alreadyAccepted, RevFlag canMergeFlag) {
+  public MergeSorter(
+      CodeReviewRevWalk rw,
+      Set<RevCommit> alreadyAccepted,
+      RevFlag canMergeFlag,
+      Set<CodeReviewCommit> incoming) {
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
     this.accepted = alreadyAccepted;
+    this.incoming = incoming;
   }
 
-  Collection<CodeReviewCommit> sort(final Collection<CodeReviewCommit> toMerge) throws IOException {
-    return sort(toMerge, toMerge);
-  }
-
-  Collection<CodeReviewCommit> sort(
-      final Collection<CodeReviewCommit> toMerge, final Collection<CodeReviewCommit> incoming)
-      throws IOException {
+  Collection<CodeReviewCommit> sort(Collection<CodeReviewCommit> toMerge) throws IOException {
     final Set<CodeReviewCommit> heads = new HashSet<>();
     final Set<CodeReviewCommit> sort = new HashSet<>(toMerge);
     while (!sort.isEmpty()) {
@@ -82,7 +82,7 @@
     return heads;
   }
 
-  private static <T> T removeOne(final Collection<T> c) {
+  private static <T> T removeOne(Collection<T> c) {
     final Iterator<T> i = c.iterator();
     final T r = i.next();
     i.remove();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
index 9dc13d0..58c183b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeSuperSet.java
@@ -35,7 +35,9 @@
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.MergeOpRepoManager.OpenRepo;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.project.NoSuchChangeException;
+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.NoSuchProjectException;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -97,9 +99,11 @@
   private final ChangeData.Factory changeDataFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<MergeOpRepoManager> repoManagerProvider;
+  private final PermissionBackend permissionBackend;
   private final Config cfg;
   private final Map<QueryKey, List<ChangeData>> queryCache;
   private final Map<Branch.NameKey, Optional<RevCommit>> heads;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   private MergeOpRepoManager orm;
   private boolean closeOrm;
@@ -109,11 +113,15 @@
       @GerritServerConfig Config cfg,
       ChangeData.Factory changeDataFactory,
       Provider<InternalChangeQuery> queryProvider,
-      Provider<MergeOpRepoManager> repoManagerProvider) {
+      Provider<MergeOpRepoManager> repoManagerProvider,
+      PermissionBackend permissionBackend,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
     this.cfg = cfg;
     this.changeDataFactory = changeDataFactory;
     this.queryProvider = queryProvider;
     this.repoManagerProvider = repoManagerProvider;
+    this.permissionBackend = permissionBackend;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
     queryCache = new HashMap<>();
     heads = new HashMap<>();
   }
@@ -126,11 +134,12 @@
   }
 
   public ChangeSet completeChangeSet(ReviewDb db, Change change, CurrentUser user)
-      throws IOException, OrmException {
+      throws IOException, OrmException, PermissionBackendException {
     try {
       ChangeData cd = changeDataFactory.create(db, change.getProject(), change.getId());
-      cd.changeControl(user);
-      ChangeSet cs = new ChangeSet(cd, cd.changeControl().isVisible(db, cd));
+      ChangeSet cs =
+          new ChangeSet(
+              cd, permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ));
       if (Submit.wholeTopicEnabled(cfg)) {
         return completeChangeSetIncludingTopics(db, cs, user);
       }
@@ -143,7 +152,7 @@
     }
   }
 
-  private SubmitType submitType(ChangeData cd, PatchSet ps, boolean visible) throws OrmException {
+  private SubmitType submitType(CurrentUser user, ChangeData cd, PatchSet ps) throws OrmException {
     // Submit type prolog rules mean that the submit type can depend on the
     // submitting user and the content of the change.
     //
@@ -154,14 +163,10 @@
     // doesn't match that, we may pick the wrong submit type and produce a
     // misleading (but still nonzero) count of the non visible changes that
     // would be submitted together with the visible ones.
-    if (!visible) {
-      return cd.changeControl().getProject().getSubmitType();
-    }
-
     SubmitTypeRecord str =
         ps == cd.currentPatchSet()
             ? cd.submitTypeRecord()
-            : new SubmitRuleEvaluator(cd).setPatchSet(ps).getSubmitType();
+            : submitRuleEvaluatorFactory.create(user, cd).setPatchSet(ps).getSubmitType();
     if (!str.isOk()) {
       logErrorAndThrow("Failed to get submit type for " + cd.getId() + ": " + str.errorMessage);
     }
@@ -204,7 +209,7 @@
   }
 
   private ChangeSet completeChangeSetWithoutTopic(ReviewDb db, ChangeSet changes, CurrentUser user)
-      throws IOException, OrmException {
+      throws IOException, OrmException, PermissionBackendException {
     Collection<ChangeData> visibleChanges = new ArrayList<>();
     Collection<ChangeData> nonVisibleChanges = new ArrayList<>();
 
@@ -217,35 +222,19 @@
       List<RevCommit> visibleCommits = new ArrayList<>();
       List<RevCommit> nonVisibleCommits = new ArrayList<>();
       for (ChangeData cd : bc.get(b)) {
-        checkState(
-            cd.hasChangeControl(),
-            "completeChangeSet forgot to set changeControl for current user"
-                + " at ChangeData creation time");
-
         boolean visible = changes.ids().contains(cd.getId());
-        if (visible && !cd.changeControl().isVisible(db, cd)) {
+        if (visible && !canRead(db, user, cd)) {
           // We thought the change was visible, but it isn't.
           // This can happen if the ACL changes during the
           // completeChangeSet computation, for example.
           visible = false;
         }
-        Collection<RevCommit> toWalk = visible ? visibleCommits : nonVisibleCommits;
 
         // Pick a revision to use for traversal.  If any of the patch sets
         // is visible, we use the most recent one.  Otherwise, use the current
         // patch set.
         PatchSet ps = cd.currentPatchSet();
-        boolean visiblePatchSet = visible;
-        if (!cd.changeControl().isPatchVisible(ps, cd)) {
-          Iterable<PatchSet> visiblePatchSets = cd.visiblePatchSets();
-          if (Iterables.isEmpty(visiblePatchSets)) {
-            visiblePatchSet = false;
-          } else {
-            ps = Iterables.getLast(visiblePatchSets);
-          }
-        }
-
-        if (submitType(cd, ps, visiblePatchSet) == SubmitType.CHERRY_PICK) {
+        if (submitType(user, cd, ps) == SubmitType.CHERRY_PICK) {
           if (visible) {
             visibleChanges.add(cd);
           } else {
@@ -262,21 +251,19 @@
         // Always include the input, even if merged. This allows
         // SubmitStrategyOp to correct the situation later, assuming it gets
         // returned by byCommitsOnBranchNotMerged below.
-        toWalk.add(commit);
+        if (visible) {
+          visibleCommits.add(commit);
+        } else {
+          nonVisibleCommits.add(commit);
+        }
       }
 
-      Set<String> emptySet = Collections.emptySet();
-      Set<String> visibleHashes = walkChangesByHashes(visibleCommits, emptySet, or, b);
-
-      List<ChangeData> cds = byCommitsOnBranchNotMerged(or, db, user, b, visibleHashes);
-      for (ChangeData chd : cds) {
-        chd.changeControl(user);
-        visibleChanges.add(chd);
-      }
+      Set<String> visibleHashes =
+          walkChangesByHashes(visibleCommits, Collections.emptySet(), or, b);
+      Iterables.addAll(visibleChanges, byCommitsOnBranchNotMerged(or, db, b, visibleHashes));
 
       Set<String> nonVisibleHashes = walkChangesByHashes(nonVisibleCommits, visibleHashes, or, b);
-      Iterables.addAll(
-          nonVisibleChanges, byCommitsOnBranchNotMerged(or, db, user, b, nonVisibleHashes));
+      Iterables.addAll(nonVisibleChanges, byCommitsOnBranchNotMerged(or, db, b, nonVisibleHashes));
     }
 
     return new ChangeSet(visibleChanges, nonVisibleChanges);
@@ -309,7 +296,7 @@
   }
 
   private List<ChangeData> byCommitsOnBranchNotMerged(
-      OpenRepo or, ReviewDb db, CurrentUser user, Branch.NameKey branch, Set<String> hashes)
+      OpenRepo or, ReviewDb db, Branch.NameKey branch, Set<String> hashes)
       throws OrmException, IOException {
     if (hashes.isEmpty()) {
       return ImmutableList.of();
@@ -324,7 +311,6 @@
     Iterable<ChangeData> destChanges =
         query().byCommitsOnBranchNotMerged(or.repo, db, branch, hashes);
     for (ChangeData chd : destChanges) {
-      chd.changeControl(user);
       result.add(chd);
     }
     queryCache.put(k, result);
@@ -349,7 +335,7 @@
       CurrentUser user,
       Set<String> topicsSeen,
       Set<String> visibleTopicsSeen)
-      throws OrmException {
+      throws OrmException, PermissionBackendException {
     List<ChangeData> visibleChanges = new ArrayList<>();
     List<ChangeData> nonVisibleChanges = new ArrayList<>();
 
@@ -360,19 +346,10 @@
         continue;
       }
       for (ChangeData topicCd : query().byTopicOpen(topic)) {
-        try {
-          topicCd.changeControl(user);
-          if (topicCd.changeControl().isVisible(db, topicCd)) {
-            visibleChanges.add(topicCd);
-          } else {
-            nonVisibleChanges.add(topicCd);
-          }
-        } catch (OrmException e) {
-          if (e.getCause() instanceof NoSuchChangeException) {
-            // Ignore and skip this change
-          } else {
-            throw e;
-          }
+        if (canRead(db, user, topicCd)) {
+          visibleChanges.add(topicCd);
+        } else {
+          nonVisibleChanges.add(topicCd);
         }
       }
       topicsSeen.add(topic);
@@ -385,7 +362,6 @@
         continue;
       }
       for (ChangeData topicCd : query().byTopicOpen(topic)) {
-        topicCd.changeControl(user);
         nonVisibleChanges.add(topicCd);
       }
       topicsSeen.add(topic);
@@ -394,7 +370,8 @@
   }
 
   private ChangeSet completeChangeSetIncludingTopics(
-      ReviewDb db, ChangeSet changes, CurrentUser user) throws IOException, OrmException {
+      ReviewDb db, ChangeSet changes, CurrentUser user)
+      throws IOException, OrmException, PermissionBackendException {
     Set<String> topicsSeen = new HashSet<>();
     Set<String> visibleTopicsSeen = new HashSet<>();
     int oldSeen;
@@ -435,4 +412,9 @@
     logError(msg);
     throw new OrmException(msg);
   }
+
+  private boolean canRead(ReviewDb db, CurrentUser user, ChangeData cd)
+      throws PermissionBackendException {
+    return permissionBackend.user(user).change(cd).database(db).test(ChangePermission.READ);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
index 2526db194..9ea9dcb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
@@ -39,12 +39,13 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.strategy.CommitMergeStatus;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -109,14 +110,13 @@
     }
 
     public String generate(
-        RevCommit original, RevCommit mergeTip, ChangeControl ctl, String current) {
+        RevCommit original, RevCommit mergeTip, Branch.NameKey dest, String current) {
       checkNotNull(original.getRawBuffer());
       if (mergeTip != null) {
         checkNotNull(mergeTip.getRawBuffer());
       }
       for (ChangeMessageModifier changeMessageModifier : changeMessageModifiers) {
-        current =
-            changeMessageModifier.onSubmit(current, original, mergeTip, ctl.getChange().getDest());
+        current = changeMessageModifier.onSubmit(current, original, mergeTip, dest);
         checkNotNull(
             current,
             changeMessageModifier.getClass().getName()
@@ -192,9 +192,9 @@
   }
 
   public CodeReviewCommit getFirstFastForward(
-      final CodeReviewCommit mergeTip, final RevWalk rw, final List<CodeReviewCommit> toMerge)
+      CodeReviewCommit mergeTip, RevWalk rw, List<CodeReviewCommit> toMerge)
       throws IntegrationException {
-    for (final Iterator<CodeReviewCommit> i = toMerge.iterator(); i.hasNext(); ) {
+    for (Iterator<CodeReviewCommit> i = toMerge.iterator(); i.hasNext(); ) {
       try {
         final CodeReviewCommit n = i.next();
         if (mergeTip == null || rw.isMergedInto(mergeTip, n)) {
@@ -209,11 +209,10 @@
   }
 
   public List<CodeReviewCommit> reduceToMinimalMerge(
-      MergeSorter mergeSorter, Collection<CodeReviewCommit> toSort, Set<CodeReviewCommit> incoming)
-      throws IntegrationException {
+      MergeSorter mergeSorter, Collection<CodeReviewCommit> toSort) throws IntegrationException {
     List<CodeReviewCommit> result = new ArrayList<>();
     try {
-      result.addAll(mergeSorter.sort(toSort, incoming));
+      result.addAll(mergeSorter.sort(toSort));
     } catch (IOException e) {
       throw new IntegrationException("Branch head sorting failed", e);
     }
@@ -222,8 +221,8 @@
   }
 
   public CodeReviewCommit createCherryPickFromCommit(
-      Repository repo,
       ObjectInserter inserter,
+      Config repoConfig,
       RevCommit mergeTip,
       RevCommit originalCommit,
       PersonIdent cherryPickCommitterIdent,
@@ -234,7 +233,7 @@
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
           MergeIdenticalTreeException, MergeConflictException {
 
-    final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
+    final ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
 
     m.setBase(originalCommit.getParent(parentIndex));
     if (m.merge(mergeTip, originalCommit)) {
@@ -249,14 +248,15 @@
       mergeCommit.setAuthor(originalCommit.getAuthorIdent());
       mergeCommit.setCommitter(cherryPickCommitterIdent);
       mergeCommit.setMessage(commitMsg);
+      matchAuthorToCommitterDate(project, mergeCommit);
       return rw.parseCommit(inserter.insert(mergeCommit));
     }
     throw new MergeConflictException("merge conflict");
   }
 
   public static RevCommit createMergeCommit(
-      Repository repo,
       ObjectInserter inserter,
+      Config repoConfig,
       RevCommit mergeTip,
       RevCommit originalCommit,
       String mergeStrategy,
@@ -271,7 +271,7 @@
           "'" + originalCommit.getName() + "' has already been merged");
     }
 
-    Merger m = newMerger(repo, inserter, mergeStrategy);
+    Merger m = newMerger(inserter, repoConfig, mergeStrategy);
     if (m.merge(false, mergeTip, originalCommit)) {
       ObjectId tree = m.getResultTreeId();
 
@@ -310,12 +310,14 @@
    * </ul>
    *
    * @param n
-   * @param ctl
+   * @param notes
+   * @param user
    * @param psId
    * @return new message
    */
-  private String createDetailedCommitMessage(RevCommit n, ChangeControl ctl, PatchSet.Id psId) {
-    Change c = ctl.getChange();
+  private String createDetailedCommitMessage(
+      RevCommit n, ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
+    Change c = notes.getChange();
     final List<FooterLine> footers = n.getFooterLines();
     final StringBuilder msgbuf = new StringBuilder();
     msgbuf.append(n.getFullMessage());
@@ -355,7 +357,7 @@
 
     PatchSetApproval submitAudit = null;
 
-    for (final PatchSetApproval a : safeGetApprovals(ctl, psId)) {
+    for (PatchSetApproval a : safeGetApprovals(notes, user, psId)) {
       if (a.getValue() <= 0) {
         // Negative votes aren't counted.
         continue;
@@ -418,7 +420,12 @@
   }
 
   public String createCommitMessageOnSubmit(CodeReviewCommit n, RevCommit mergeTip) {
-    return createCommitMessageOnSubmit(n, mergeTip, n.getControl(), n.getPatchsetId());
+    return createCommitMessageOnSubmit(
+        n,
+        mergeTip,
+        n.notes(),
+        identifiedUserFactory.create(n.notes().getChange().getOwner()),
+        n.getPatchsetId());
   }
 
   /**
@@ -430,14 +437,15 @@
    *
    * @param n
    * @param mergeTip
-   * @param ctl
+   * @param notes
+   * @param user
    * @param id
    * @return new message
    */
   public String createCommitMessageOnSubmit(
-      RevCommit n, RevCommit mergeTip, ChangeControl ctl, Id id) {
+      RevCommit n, RevCommit mergeTip, ChangeNotes notes, CurrentUser user, Id id) {
     return commitMessageGenerator.generate(
-        n, mergeTip, ctl, createDetailedCommitMessage(n, ctl, id));
+        n, mergeTip, notes.getChange().getDest(), createDetailedCommitMessage(n, notes, user, id));
   }
 
   private static boolean isCodeReview(LabelId id) {
@@ -448,9 +456,10 @@
     return "Verified".equalsIgnoreCase(id.get());
   }
 
-  private Iterable<PatchSetApproval> safeGetApprovals(ChangeControl ctl, PatchSet.Id psId) {
+  private Iterable<PatchSetApproval> safeGetApprovals(
+      ChangeNotes notes, CurrentUser user, PatchSet.Id psId) {
     try {
-      return approvalsUtil.byPatchSet(db.get(), ctl, psId);
+      return approvalsUtil.byPatchSet(db.get(), notes, user, psId, null, null);
     } catch (OrmException e) {
       log.error("Can't read approval records for " + psId, e);
       return Collections.emptyList();
@@ -458,7 +467,7 @@
   }
 
   private static boolean contains(List<FooterLine> footers, FooterKey key, String val) {
-    for (final FooterLine line : footers) {
+    for (FooterLine line : footers) {
       if (line.matches(key) && val.equals(line.getValue())) {
         return true;
       }
@@ -467,7 +476,7 @@
   }
 
   private static boolean isSignedOffBy(List<FooterLine> footers, String email) {
-    for (final FooterLine line : footers) {
+    for (FooterLine line : footers) {
       if (line.matches(FooterKey.SIGNED_OFF_BY) && email.equals(line.getEmailAddress())) {
         return true;
       }
@@ -476,17 +485,14 @@
   }
 
   public boolean canMerge(
-      final MergeSorter mergeSorter,
-      final Repository repo,
-      final CodeReviewCommit mergeTip,
-      final CodeReviewCommit toMerge)
+      MergeSorter mergeSorter, Repository repo, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
       throws IntegrationException {
     if (hasMissingDependencies(mergeSorter, toMerge)) {
       return false;
     }
 
     try (ObjectInserter ins = new InMemoryInserter(repo)) {
-      return newThreeWayMerger(repo, ins).merge(new AnyObjectId[] {mergeTip, toMerge});
+      return newThreeWayMerger(ins, repo.getConfig()).merge(new AnyObjectId[] {mergeTip, toMerge});
     } catch (LargeObjectException e) {
       log.warn("Cannot merge due to LargeObjectException: " + toMerge.name());
       return false;
@@ -542,7 +548,7 @@
       // that on the current merge tip.
       //
       try (ObjectInserter ins = new InMemoryInserter(repo)) {
-        ThreeWayMerger m = newThreeWayMerger(repo, ins);
+        ThreeWayMerger m = newThreeWayMerger(ins, repo.getConfig());
         m.setBase(toMerge.getParent(0));
         return m.merge(mergeTip, toMerge);
       } catch (IOException e) {
@@ -563,8 +569,8 @@
         || canMerge(mergeSorter, repo, mergeTip, toMerge);
   }
 
-  public boolean hasMissingDependencies(
-      final MergeSorter mergeSorter, final CodeReviewCommit toMerge) throws IntegrationException {
+  public boolean hasMissingDependencies(MergeSorter mergeSorter, CodeReviewCommit toMerge)
+      throws IntegrationException {
     try {
       return !mergeSorter.sort(Collections.singleton(toMerge)).contains(toMerge);
     } catch (IOException e) {
@@ -575,14 +581,14 @@
   public CodeReviewCommit mergeOneCommit(
       PersonIdent author,
       PersonIdent committer,
-      Repository repo,
       CodeReviewRevWalk rw,
       ObjectInserter inserter,
+      Config repoConfig,
       Branch.NameKey destBranch,
       CodeReviewCommit mergeTip,
       CodeReviewCommit n)
       throws IntegrationException {
-    final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
+    ThreeWayMerger m = newThreeWayMerger(inserter, repoConfig);
     try {
       if (m.merge(new AnyObjectId[] {mergeTip, n})) {
         return writeMergeCommit(
@@ -657,7 +663,7 @@
 
     if (merged.size() > 1) {
       msgbuf.append("\n\n* changes:\n");
-      for (final CodeReviewCommit c : merged) {
+      for (CodeReviewCommit c : merged) {
         rw.parseBody(c);
         msgbuf.append("  ");
         msgbuf.append(c.getShortMessage());
@@ -673,7 +679,7 @@
     mergeCommit.setMessage(msgbuf.toString());
 
     CodeReviewCommit mergeResult = rw.parseCommit(inserter.insert(mergeCommit));
-    mergeResult.setControl(n.getControl());
+    mergeResult.setNotes(n.getNotes());
     return mergeResult;
   }
 
@@ -692,9 +698,9 @@
     }
 
     if (topics.size() == 1) {
-      return String.format("Merge changes from topic '%s'", Iterables.getFirst(topics, null));
+      return String.format("Merge changes from topic \"%s\"", Iterables.getFirst(topics, null));
     } else if (topics.size() > 1) {
-      return String.format("Merge changes from topics '%s'", Joiner.on("', '").join(topics));
+      return String.format("Merge changes from topics \"%s\"", Joiner.on("\", \"").join(topics));
     } else {
       return String.format(
           "Merge changes %s%s",
@@ -706,8 +712,8 @@
     }
   }
 
-  public ThreeWayMerger newThreeWayMerger(final Repository repo, final ObjectInserter inserter) {
-    return newThreeWayMerger(repo, inserter, mergeStrategyName());
+  public ThreeWayMerger newThreeWayMerger(ObjectInserter inserter, Config repoConfig) {
+    return newThreeWayMerger(inserter, repoConfig, mergeStrategyName());
   }
 
   public String mergeStrategyName() {
@@ -730,8 +736,8 @@
   }
 
   public static ThreeWayMerger newThreeWayMerger(
-      Repository repo, final ObjectInserter inserter, String strategyName) {
-    Merger m = newMerger(repo, inserter, strategyName);
+      ObjectInserter inserter, Config repoConfig, String strategyName) {
+    Merger m = newMerger(inserter, repoConfig, strategyName);
     checkArgument(
         m instanceof ThreeWayMerger,
         "merge strategy %s does not support three-way merging",
@@ -739,12 +745,10 @@
     return (ThreeWayMerger) m;
   }
 
-  public static Merger newMerger(
-      Repository repo, final ObjectInserter inserter, String strategyName) {
+  public static Merger newMerger(ObjectInserter inserter, Config repoConfig, String strategyName) {
     MergeStrategy strategy = MergeStrategy.get(strategyName);
     checkArgument(strategy != null, "invalid merge strategy: %s", strategyName);
-    Merger m = strategy.newMerger(repo, true);
-    m.setObjectInserter(
+    return strategy.newMerger(
         new ObjectInserter.Filter() {
           @Override
           protected ObjectInserter delegate() {
@@ -756,15 +760,12 @@
 
           @Override
           public void close() {}
-        });
-    return m;
+        },
+        repoConfig);
   }
 
   public void markCleanMerges(
-      final RevWalk rw,
-      final RevFlag canMergeFlag,
-      final CodeReviewCommit mergeTip,
-      final Set<RevCommit> alreadyAccepted)
+      RevWalk rw, RevFlag canMergeFlag, CodeReviewCommit mergeTip, Set<RevCommit> alreadyAccepted)
       throws IntegrationException {
     if (mergeTip == null) {
       // If mergeTip is null here, branchTip was null, indicating a new branch
@@ -866,4 +867,14 @@
       throw new ResourceNotFoundException(e.getMessage());
     }
   }
+
+  private static void matchAuthorToCommitterDate(ProjectState project, CommitBuilder commit) {
+    if (project.isMatchAuthorToCommitterDate()) {
+      commit.setAuthor(
+          new PersonIdent(
+              commit.getAuthor(),
+              commit.getCommitter().getWhen(),
+              commit.getCommitter().getTimeZone()));
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
index e96fdb8..3584786 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -34,9 +34,9 @@
 import com.google.gerrit.server.update.Context;
 import com.google.gerrit.server.util.RequestScopePropagator;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collections;
 import java.util.concurrent.ExecutorService;
@@ -74,7 +74,7 @@
   private PatchSet patchSet;
   private PatchSetInfo info;
 
-  @AssistedInject
+  @Inject
   MergedByPushOp(
       PatchSetInfoFactory patchSetInfoFactory,
       ChangeMessagesUtil cmUtil,
@@ -162,7 +162,7 @@
   }
 
   @Override
-  public void postUpdate(final Context ctx) {
+  public void postUpdate(Context ctx) {
     if (!correctBranch) {
       return;
     }
@@ -194,7 +194,7 @@
         change, patchSet, ctx.getAccount(), patchSet.getRevision().get(), ctx.getWhen());
   }
 
-  private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException {
+  private PatchSetInfo getPatchSetInfo(ChangeContext ctx) throws IOException, OrmException {
     RevWalk rw = ctx.getRevWalk();
     RevCommit commit =
         rw.parseCommit(ObjectId.fromString(checkNotNull(patchSet).getRevision().get()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
index b16ccef..21f5d3e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MetaDataUpdate.java
@@ -22,7 +22,6 @@
 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 org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
@@ -168,7 +167,7 @@
     }
   }
 
-  interface InternalFactory {
+  public interface InternalFactory {
     MetaDataUpdate create(
         @Assisted Project.NameKey projectName,
         @Assisted Repository repository,
@@ -185,7 +184,7 @@
   private boolean closeRepository;
   private IdentifiedUser author;
 
-  @AssistedInject
+  @Inject
   public MetaDataUpdate(
       GitReferenceUpdated gitRefUpdated,
       @Assisted Project.NameKey projectName,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 710eb7f..694976d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -63,7 +63,7 @@
     private int count;
     private int lastPercent;
 
-    Task(final String subTaskName, final int totalWork) {
+    Task(String subTaskName, int totalWork) {
       this.name = subTaskName;
       this.total = totalWork;
     }
@@ -76,7 +76,7 @@
      * @param completed number of work units completed.
      */
     @Override
-    public void update(final int completed) {
+    public void update(int completed) {
       boolean w = false;
       synchronized (MultiProgressMonitor.this) {
         count += completed;
@@ -141,7 +141,7 @@
    * @param out stream for writing progress messages.
    * @param taskName name of the overall task.
    */
-  public MultiProgressMonitor(final OutputStream out, final String taskName) {
+  public MultiProgressMonitor(OutputStream out, String taskName) {
     this(out, taskName, 500, TimeUnit.MILLISECONDS);
   }
 
@@ -154,10 +154,7 @@
    * @param maxIntervalUnit time unit for progress interval.
    */
   public MultiProgressMonitor(
-      final OutputStream out,
-      final String taskName,
-      long maxIntervalTime,
-      TimeUnit maxIntervalUnit) {
+      OutputStream out, String taskName, long maxIntervalTime, TimeUnit maxIntervalUnit) {
     this.out = out;
     this.taskName = taskName;
     maxIntervalNanos = NANOSECONDS.convert(maxIntervalTime, maxIntervalUnit);
@@ -168,7 +165,7 @@
    *
    * @see #waitFor(Future, long, TimeUnit)
    */
-  public void waitFor(final Future<?> workerFuture) throws ExecutionException {
+  public void waitFor(Future<?> workerFuture) throws ExecutionException {
     waitFor(workerFuture, 0, null);
   }
 
@@ -186,8 +183,7 @@
    * @throws ExecutionException if this thread or a worker thread was interrupted, the worker was
    *     cancelled, or timed out waiting for a worker to call {@link #end()}.
    */
-  public void waitFor(
-      final Future<?> workerFuture, final long timeoutTime, final TimeUnit timeoutUnit)
+  public void waitFor(Future<?> workerFuture, long timeoutTime, TimeUnit timeoutUnit)
       throws ExecutionException {
     long overallStart = System.nanoTime();
     long deadline;
@@ -268,7 +264,7 @@
    * @param subTaskWork total work units in sub-task, or {@link #UNKNOWN}.
    * @return sub-task handle.
    */
-  public Task beginSubTask(final String subTask, final int subTaskWork) {
+  public Task beginSubTask(String subTask, int subTaskWork) {
     Task task = new Task(subTask, subTaskWork);
     tasks.add(task);
     return task;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
index 2020550..24b3727 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
@@ -14,32 +14,29 @@
 
 package com.google.gerrit.server.git;
 
+import static com.google.common.base.MoreObjects.firstNonNull;
+
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.update.RefUpdateUtil;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
-import org.eclipse.jgit.errors.CorruptObjectException;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.CommitBuilder;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.RefUpdate.Result;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.merge.MergeStrategy;
 import org.eclipse.jgit.notes.Note;
 import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.notes.NoteMapMerger;
 import org.eclipse.jgit.notes.NoteMerger;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** A utility class for updating a notes branch with automatic merge of note trees. */
 public class NotesBranchUtil {
@@ -47,9 +44,6 @@
     NotesBranchUtil create(Project.NameKey project, Repository db, ObjectInserter inserter);
   }
 
-  private static final int MAX_LOCK_FAILURE_CALLS = 10;
-  private static final int SLEEP_ON_LOCK_FAILURE_MS = 25;
-
   private final PersonIdent gerritIdent;
   private final GitReferenceUpdated gitRefUpdated;
   private final Project.NameKey project;
@@ -70,8 +64,8 @@
 
   @Inject
   public NotesBranchUtil(
-      @GerritPersonIdent final PersonIdent gerritIdent,
-      final GitReferenceUpdated gitRefUpdated,
+      @GerritPersonIdent PersonIdent gerritIdent,
+      GitReferenceUpdated gitRefUpdated,
       @Assisted Project.NameKey project,
       @Assisted Repository db,
       @Assisted ObjectInserter inserter) {
@@ -86,16 +80,20 @@
    * Create a new commit in the {@code notesBranch} by updating existing or creating new notes from
    * the {@code notes} map.
    *
+   * <p>Does not retry in the case of lock failure; callers may use {@link
+   * com.google.gerrit.server.update.RetryHelper}.
+   *
    * @param notes map of notes
    * @param notesBranch notes branch to update
    * @param commitAuthor author of the commit in the notes branch
    * @param commitMessage for the commit in the notes branch
-   * @throws IOException
-   * @throws ConcurrentRefUpdateException
+   * @throws LockFailureException if committing the notes failed due to a lock failure on the notes
+   *     branch
+   * @throws IOException if committing the notes failed for any other reason
    */
   public final void commitAllNotes(
       NoteMap notes, String notesBranch, PersonIdent commitAuthor, String commitMessage)
-      throws IOException, ConcurrentRefUpdateException {
+      throws IOException {
     this.overwrite = true;
     commitNotes(notes, notesBranch, commitAuthor, commitMessage);
   }
@@ -105,17 +103,21 @@
    * {@code notes} map. The notes from the {@code notes} map which already exist in the note-tree of
    * the tip of the {@code notesBranch} will not be updated.
    *
+   * <p>Does not retry in the case of lock failure; callers may use {@link
+   * com.google.gerrit.server.update.RetryHelper}.
+   *
    * @param notes map of notes
    * @param notesBranch notes branch to update
    * @param commitAuthor author of the commit in the notes branch
    * @param commitMessage for the commit in the notes branch
    * @return map with those notes from the {@code notes} that were newly created
-   * @throws IOException
-   * @throws ConcurrentRefUpdateException
+   * @throws LockFailureException if committing the notes failed due to a lock failure on the notes
+   *     branch
+   * @throws IOException if committing the notes failed for any other reason
    */
   public final NoteMap commitNewNotes(
       NoteMap notes, String notesBranch, PersonIdent commitAuthor, String commitMessage)
-      throws IOException, ConcurrentRefUpdateException {
+      throws IOException {
     this.overwrite = false;
     commitNotes(notes, notesBranch, commitAuthor, commitMessage);
     NoteMap newlyCreated = NoteMap.newEmptyMap();
@@ -129,7 +131,7 @@
 
   private void commitNotes(
       NoteMap notes, String notesBranch, PersonIdent commitAuthor, String commitMessage)
-      throws IOException, ConcurrentRefUpdateException {
+      throws LockFailureException, IOException {
     try {
       revWalk = new RevWalk(db);
       reader = db.newObjectReader();
@@ -209,61 +211,16 @@
     return revWalk.parseCommit(commitId);
   }
 
-  private void updateRef(String notesBranch)
-      throws IOException, MissingObjectException, IncorrectObjectTypeException,
-          CorruptObjectException, ConcurrentRefUpdateException {
+  private void updateRef(String notesBranch) throws LockFailureException, IOException {
     if (baseCommit != null && oursCommit.getTree().equals(baseCommit.getTree())) {
       // If the trees are identical, there is no change in the notes.
       // Avoid saving this commit as it has no new information.
       return;
     }
-
-    int remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
-    RefUpdate refUpdate = createRefUpdate(notesBranch, oursCommit, baseCommit);
-
-    for (; ; ) {
-      Result result = refUpdate.update();
-
-      if (result == Result.LOCK_FAILURE) {
-        if (--remainingLockFailureCalls > 0) {
-          try {
-            Thread.sleep(SLEEP_ON_LOCK_FAILURE_MS);
-          } catch (InterruptedException e) {
-            // ignore
-          }
-        } else {
-          throw new ConcurrentRefUpdateException(
-              "Failed to lock the ref: " + notesBranch, refUpdate.getRef(), result);
-        }
-
-      } else if (result == Result.REJECTED) {
-        RevCommit theirsCommit = revWalk.parseCommit(refUpdate.getOldObjectId());
-        NoteMap theirs = NoteMap.read(revWalk.getObjectReader(), theirsCommit);
-        NoteMapMerger merger = new NoteMapMerger(db, getNoteMerger(), MergeStrategy.RESOLVE);
-        NoteMap merged = merger.merge(base, ours, theirs);
-        RevCommit mergeCommit =
-            createCommit(merged, gerritIdent, "Merged note commits\n", theirsCommit, oursCommit);
-        refUpdate = createRefUpdate(notesBranch, mergeCommit, theirsCommit);
-        remainingLockFailureCalls = MAX_LOCK_FAILURE_CALLS;
-
-      } else if (result == Result.IO_FAILURE) {
-        throw new IOException("Couldn't update " + notesBranch + ". " + result.name());
-      } else {
-        gitRefUpdated.fire(project, refUpdate, null);
-        break;
-      }
-    }
-  }
-
-  private RefUpdate createRefUpdate(
-      String notesBranch, ObjectId newObjectId, ObjectId expectedOldObjectId) throws IOException {
-    RefUpdate refUpdate = db.updateRef(notesBranch);
-    refUpdate.setNewObjectId(newObjectId);
-    if (expectedOldObjectId == null) {
-      refUpdate.setExpectedOldObjectId(ObjectId.zeroId());
-    } else {
-      refUpdate.setExpectedOldObjectId(expectedOldObjectId);
-    }
-    return refUpdate;
+    BatchRefUpdate bru = db.getRefDatabase().newBatchUpdate();
+    bru.addCommand(
+        new ReceiveCommand(firstNonNull(baseCommit, ObjectId.zeroId()), oursCommit, notesBranch));
+    RefUpdateUtil.executeChecked(bru, revWalk);
+    gitRefUpdated.fire(project, bru, null);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
index 20f053a..a4719a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/PerThreadRequestScope.java
@@ -64,18 +64,15 @@
     }
 
     public <T> Callable<T> scope(RequestContext requestContext, Callable<T> callable) {
-      final Context ctx = new Context();
-      final Callable<T> wrapped = context(requestContext, cleanup(callable));
-      return new Callable<T>() {
-        @Override
-        public T call() throws Exception {
-          Context old = current.get();
-          current.set(ctx);
-          try {
-            return wrapped.call();
-          } finally {
-            current.set(old);
-          }
+      Context ctx = new Context();
+      Callable<T> wrapped = context(requestContext, cleanup(callable));
+      return () -> {
+        Context old = current.get();
+        current.set(ctx);
+        try {
+          return wrapped.call();
+        } finally {
+          current.set(old);
         }
       };
     }
@@ -94,7 +91,7 @@
   public 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-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
index 12a62f9..ca61f80 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -19,11 +19,9 @@
 
 import com.google.common.base.CharMatcher;
 import com.google.common.base.Joiner;
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.common.collect.Sets;
@@ -33,6 +31,7 @@
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.Permission;
@@ -68,6 +67,7 @@
 import java.util.Locale;
 import java.util.Map;
 import java.util.Map.Entry;
+import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
 import java.util.regex.PatternSyntaxException;
@@ -88,6 +88,8 @@
 
   private static final String PROJECT = "project";
   private static final String KEY_DESCRIPTION = "description";
+  private static final String KEY_MATCH_AUTHOR_DATE_WITH_COMMITTER_DATE =
+      "matchAuthorToCommitterDate";
 
   public static final String ACCESS = "access";
   private static final String KEY_INHERIT_FROM = "inheritFrom";
@@ -123,6 +125,10 @@
   private static final String KEY_REQUIRE_SIGNED_PUSH = "requireSignedPush";
   private static final String KEY_REJECT_IMPLICIT_MERGES = "rejectImplicitMerges";
 
+  private static final String CHANGE = "change";
+  private static final String KEY_PRIVATE_BY_DEFAULT = "privateByDefault";
+  private static final String KEY_WORK_IN_PROGRESS_BY_DEFAULT = "workInProgressByDefault";
+
   private static final String SUBMIT = "submit";
   private static final String KEY_ACTION = "action";
   private static final String KEY_MERGE_CONTENT = "mergeContent";
@@ -151,9 +157,9 @@
   private static final String KEY_VALUE = "value";
   private static final String KEY_CAN_OVERRIDE = "canOverride";
   private static final String KEY_BRANCH = "branch";
-  private static final ImmutableSet<String> LABEL_FUNCTIONS =
-      ImmutableSet.of(
-          "MaxWithBlock", "AnyWithBlock", "MaxNoBlock", "NoBlock", "NoOp", "PatchSetLock");
+
+  private static final String REVIEWER = "reviewer";
+  private static final String KEY_ENABLE_REVIEWER_BY_EMAIL = "enableByEmail";
 
   private static final String LEGACY_PERMISSION_PUSH_TAG = "pushTag";
   private static final String LEGACY_PERMISSION_PUSH_SIGNED_TAG = "pushSignedTag";
@@ -163,6 +169,9 @@
   private static final SubmitType DEFAULT_SUBMIT_ACTION = SubmitType.MERGE_IF_NECESSARY;
   private static final ProjectState DEFAULT_STATE_VALUE = ProjectState.ACTIVE;
 
+  private static final String EXTENSION_PANELS = "extension-panels";
+  private static final String KEY_PANEL = "panel";
+
   private Project.NameKey projectName;
   private Project project;
   private AccountsSection accountsSection;
@@ -182,6 +191,7 @@
   private boolean checkReceivedObjects;
   private Set<String> sectionsWithUnknownPermissions;
   private boolean hasLegacyPermissions;
+  private Map<String, List<String>> extensionPanelSections;
   private Map<String, GroupReference> groupsByName;
 
   public static ProjectConfig read(MetaDataUpdate update)
@@ -251,6 +261,10 @@
     return accountsSection;
   }
 
+  public Map<String, List<String>> getExtensionPanelSections() {
+    return extensionPanelSections;
+  }
+
   public AccessSection getAccessSection(String name) {
     return getAccessSection(name, false);
   }
@@ -524,8 +538,24 @@
     p.setRejectImplicitMerges(
         getEnum(rc, RECEIVE, null, KEY_REJECT_IMPLICIT_MERGES, InheritableBoolean.INHERIT));
 
+    p.setPrivateByDefault(
+        getEnum(rc, CHANGE, null, KEY_PRIVATE_BY_DEFAULT, InheritableBoolean.INHERIT));
+
+    p.setWorkInProgressByDefault(
+        getEnum(rc, CHANGE, null, KEY_WORK_IN_PROGRESS_BY_DEFAULT, InheritableBoolean.INHERIT));
+
+    p.setEnableReviewerByEmail(
+        getEnum(rc, REVIEWER, null, KEY_ENABLE_REVIEWER_BY_EMAIL, InheritableBoolean.INHERIT));
+
     p.setSubmitType(getEnum(rc, SUBMIT, null, KEY_ACTION, DEFAULT_SUBMIT_ACTION));
     p.setUseContentMerge(getEnum(rc, SUBMIT, null, KEY_MERGE_CONTENT, InheritableBoolean.INHERIT));
+    p.setMatchAuthorToCommitterDate(
+        getEnum(
+            rc,
+            SUBMIT,
+            null,
+            KEY_MATCH_AUTHOR_DATE_WITH_COMMITTER_DATE,
+            InheritableBoolean.INHERIT));
     p.setState(getEnum(rc, PROJECT, null, KEY_STATE, DEFAULT_STATE_VALUE));
 
     p.setDefaultDashboard(rc.getString(DASHBOARD, null, KEY_DEFAULT));
@@ -542,6 +572,7 @@
     mimeTypes = new ConfiguredMimeTypes(projectName.get(), rc);
     loadPluginSections(rc);
     loadReceiveSection(rc);
+    loadExtensionPanelSections(rc);
   }
 
   private void loadAccountsSection(Config rc) {
@@ -550,6 +581,25 @@
         loadPermissionRules(rc, ACCOUNTS, null, KEY_SAME_GROUP_VISIBILITY, groupsByName, false));
   }
 
+  private void loadExtensionPanelSections(Config rc) {
+    Map<String, String> lowerNames = Maps.newHashMapWithExpectedSize(2);
+    extensionPanelSections = Maps.newLinkedHashMap();
+    for (String name : rc.getSubsections(EXTENSION_PANELS)) {
+      String lower = name.toLowerCase();
+      if (lowerNames.containsKey(lower)) {
+        error(
+            new ValidationError(
+                PROJECT_CONFIG,
+                String.format(
+                    "Extension Panels \"%s\" conflicts with \"%s\"", name, lowerNames.get(lower))));
+      }
+      lowerNames.put(lower, name);
+      extensionPanelSections.put(
+          name,
+          new ArrayList<>(Arrays.asList(rc.getStringList(EXTENSION_PANELS, name, KEY_PANEL))));
+    }
+  }
+
   private void loadContributorAgreements(Config rc) {
     contributorAgreements = new HashMap<>();
     for (String name : rc.getSubsections(CONTRIBUTOR_AGREEMENT)) {
@@ -820,19 +870,20 @@
         continue;
       }
 
-      String functionName =
-          MoreObjects.firstNonNull(rc.getString(LABEL, name, KEY_FUNCTION), "MaxWithBlock");
-      if (LABEL_FUNCTIONS.contains(functionName)) {
-        label.setFunctionName(functionName);
-      } else {
+      String functionName = rc.getString(LABEL, name, KEY_FUNCTION);
+      Optional<LabelFunction> function =
+          functionName != null
+              ? LabelFunction.parse(functionName)
+              : Optional.of(LabelFunction.MAX_WITH_BLOCK);
+      if (!function.isPresent()) {
         error(
             new ValidationError(
                 PROJECT_CONFIG,
                 String.format(
                     "Invalid %s for label \"%s\". Valid names are: %s",
-                    KEY_FUNCTION, name, Joiner.on(", ").join(LABEL_FUNCTIONS))));
-        label.setFunctionName(null);
+                    KEY_FUNCTION, name, Joiner.on(", ").join(LabelFunction.ALL.keySet()))));
       }
+      label.setFunction(function.orElse(null));
 
       if (!values.isEmpty()) {
         short dv = (short) rc.getInt(LABEL, name, KEY_DEFAULT_VALUE, 0);
@@ -1009,7 +1060,6 @@
       rc.unset(PROJECT, null, KEY_DESCRIPTION);
     }
     set(rc, ACCESS, null, KEY_INHERIT_FROM, p.getParentName());
-
     set(
         rc,
         RECEIVE,
@@ -1066,8 +1116,39 @@
         p.getRejectImplicitMerges(),
         InheritableBoolean.INHERIT);
 
+    set(
+        rc,
+        CHANGE,
+        null,
+        KEY_PRIVATE_BY_DEFAULT,
+        p.getPrivateByDefault(),
+        InheritableBoolean.INHERIT);
+
+    set(
+        rc,
+        CHANGE,
+        null,
+        KEY_WORK_IN_PROGRESS_BY_DEFAULT,
+        p.getWorkInProgressByDefault(),
+        InheritableBoolean.INHERIT);
+
+    set(
+        rc,
+        REVIEWER,
+        null,
+        KEY_ENABLE_REVIEWER_BY_EMAIL,
+        p.getEnableReviewerByEmail(),
+        InheritableBoolean.INHERIT);
+
     set(rc, SUBMIT, null, KEY_ACTION, p.getSubmitType(), DEFAULT_SUBMIT_ACTION);
     set(rc, SUBMIT, null, KEY_MERGE_CONTENT, p.getUseContentMerge(), InheritableBoolean.INHERIT);
+    set(
+        rc,
+        SUBMIT,
+        null,
+        KEY_MATCH_AUTHOR_DATE_WITH_COMMITTER_DATE,
+        p.getMatchAuthorToCommitterDate(),
+        InheritableBoolean.INHERIT);
 
     set(rc, PROJECT, null, KEY_STATE, p.getState(), DEFAULT_STATE_VALUE);
 
@@ -1090,7 +1171,7 @@
     return true;
   }
 
-  public static final String validMaxObjectSizeLimit(String value) throws ConfigInvalidException {
+  public static String validMaxObjectSizeLimit(String value) throws ConfigInvalidException {
     if (value == null) {
       return null;
     }
@@ -1297,45 +1378,60 @@
       String name = e.getKey();
       LabelType label = e.getValue();
       toUnset.remove(name);
-      rc.setString(LABEL, name, KEY_FUNCTION, label.getFunctionName());
+      rc.setString(LABEL, name, KEY_FUNCTION, label.getFunction().getFunctionName());
       rc.setInt(LABEL, name, KEY_DEFAULT_VALUE, label.getDefaultValue());
 
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_ALLOW_POST_SUBMIT,
           label.allowPostSubmit(),
           LabelType.DEF_ALLOW_POST_SUBMIT);
       setBooleanConfigKey(
-          rc, name, KEY_COPY_MIN_SCORE, label.isCopyMinScore(), LabelType.DEF_COPY_MIN_SCORE);
-      setBooleanConfigKey(
-          rc, name, KEY_COPY_MAX_SCORE, label.isCopyMaxScore(), LabelType.DEF_COPY_MAX_SCORE);
+          rc,
+          LABEL,
+          name,
+          KEY_COPY_MIN_SCORE,
+          label.isCopyMinScore(),
+          LabelType.DEF_COPY_MIN_SCORE);
       setBooleanConfigKey(
           rc,
+          LABEL,
+          name,
+          KEY_COPY_MAX_SCORE,
+          label.isCopyMaxScore(),
+          LabelType.DEF_COPY_MAX_SCORE);
+      setBooleanConfigKey(
+          rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
           label.isCopyAllScoresOnTrivialRebase(),
           LabelType.DEF_COPY_ALL_SCORES_ON_TRIVIAL_REBASE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
           label.isCopyAllScoresIfNoCodeChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CODE_CHANGE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_IF_NO_CHANGE,
           label.isCopyAllScoresIfNoChange(),
           LabelType.DEF_COPY_ALL_SCORES_IF_NO_CHANGE);
       setBooleanConfigKey(
           rc,
+          LABEL,
           name,
           KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
           label.isCopyAllScoresOnMergeFirstParentUpdate(),
           LabelType.DEF_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE);
       setBooleanConfigKey(
-          rc, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
+          rc, LABEL, name, KEY_CAN_OVERRIDE, label.canOverride(), LabelType.DEF_CAN_OVERRIDE);
       List<String> values = Lists.newArrayListWithCapacity(label.getValues().size());
       for (LabelValue value : label.getValues()) {
         values.add(value.format());
@@ -1371,11 +1467,11 @@
   }
 
   private static void setBooleanConfigKey(
-      Config rc, String name, String key, boolean value, boolean defaultValue) {
+      Config rc, String section, String name, String key, boolean value, boolean defaultValue) {
     if (value == defaultValue) {
-      rc.unset(LABEL, name, key);
+      rc.unset(section, name, key);
     } else {
-      rc.setBoolean(LABEL, name, key, value);
+      rc.setBoolean(section, name, key, value);
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
index 28425e0..89bbf0f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/QueueProvider.java
@@ -14,11 +14,13 @@
 
 package com.google.gerrit.server.git;
 
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+
 public interface QueueProvider {
   enum QueueType {
     INTERACTIVE,
     BATCH
   }
 
-  WorkQueue.Executor getQueue(QueueType type);
+  ScheduledThreadPoolExecutor getQueue(QueueType type);
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
index 94e78f8..6fc5eaa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RebaseSorter.java
@@ -21,6 +21,7 @@
 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 java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -41,24 +42,27 @@
   private final RevFlag canMergeFlag;
   private final RevCommit initialTip;
   private final Set<RevCommit> alreadyAccepted;
-  private final InternalChangeQuery internalChangeQuery;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Set<CodeReviewCommit> incoming;
 
   public RebaseSorter(
       CodeReviewRevWalk rw,
       RevCommit initialTip,
       Set<RevCommit> alreadyAccepted,
       RevFlag canMergeFlag,
-      InternalChangeQuery internalChangeQuery) {
+      Provider<InternalChangeQuery> queryProvider,
+      Set<CodeReviewCommit> incoming) {
     this.rw = rw;
     this.canMergeFlag = canMergeFlag;
     this.initialTip = initialTip;
     this.alreadyAccepted = alreadyAccepted;
-    this.internalChangeQuery = internalChangeQuery;
+    this.queryProvider = queryProvider;
+    this.incoming = incoming;
   }
 
-  public List<CodeReviewCommit> sort(Collection<CodeReviewCommit> incoming) throws IOException {
+  public List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort) throws IOException {
     final List<CodeReviewCommit> sorted = new ArrayList<>();
-    final Set<CodeReviewCommit> sort = new HashSet<>(incoming);
+    final Set<CodeReviewCommit> sort = new HashSet<>(toSort);
     while (!sort.isEmpty()) {
       final CodeReviewCommit n = removeOne(sort);
 
@@ -113,7 +117,7 @@
       }
 
       // check if the commit associated change is merged in the same branch
-      List<ChangeData> changes = internalChangeQuery.byCommit(commit);
+      List<ChangeData> changes = queryProvider.get().byCommit(commit);
       for (ChangeData change : changes) {
         if (change.change().getStatus() == Status.MERGED
             && change.change().getDest().equals(dest)) {
@@ -128,7 +132,7 @@
     }
   }
 
-  private static <T> T removeOne(final Collection<T> c) {
+  private static <T> T removeOne(Collection<T> c) {
     final Iterator<T> i = c.iterator();
     final T r = i.next();
     i.remove();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
deleted file mode 100644
index 4bd51cf..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommits.java
+++ /dev/null
@@ -1,2925 +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.server.git;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
-import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
-import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
-import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
-import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
-import static java.util.Comparator.comparingInt;
-import static java.util.stream.Collectors.joining;
-import static java.util.stream.Collectors.toList;
-import static org.eclipse.jgit.lib.Constants.R_HEADS;
-import static org.eclipse.jgit.lib.RefDatabase.ALL;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.OK;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_MISSING_OBJECT;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_NONFASTFORWARD;
-import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
-
-import com.google.common.base.Function;
-import com.google.common.base.Splitter;
-import com.google.common.base.Strings;
-import com.google.common.collect.BiMap;
-import com.google.common.collect.HashBiMap;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableListMultimap;
-import com.google.common.collect.Iterables;
-import com.google.common.collect.LinkedListMultimap;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.common.collect.MultimapBuilder;
-import com.google.common.collect.Sets;
-import com.google.common.collect.SortedSetMultimap;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.TimeUtil;
-import com.google.gerrit.common.data.Capable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.extensions.api.changes.HashtagsInput;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.api.changes.RecipientType;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
-import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
-import com.google.gerrit.extensions.registration.DynamicItem;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.registration.DynamicMap.Entry;
-import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.client.RevId;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.Sequences;
-import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.change.ChangeInserter;
-import com.google.gerrit.server.change.SetHashtagsOp;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.config.PluginConfig;
-import com.google.gerrit.server.config.ProjectConfigEntry;
-import com.google.gerrit.server.edit.ChangeEdit;
-import com.google.gerrit.server.edit.ChangeEditUtil;
-import com.google.gerrit.server.events.CommitReceivedEvent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.MultiProgressMonitor.Task;
-import com.google.gerrit.server.git.validators.CommitValidationException;
-import com.google.gerrit.server.git.validators.CommitValidationMessage;
-import com.google.gerrit.server.git.validators.CommitValidators;
-import com.google.gerrit.server.git.validators.RefOperationValidationException;
-import com.google.gerrit.server.git.validators.RefOperationValidators;
-import com.google.gerrit.server.git.validators.ValidationMessage;
-import com.google.gerrit.server.index.change.ChangeIndexer;
-import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.project.RefControl;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.ssh.SshInfo;
-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.UpdateException;
-import com.google.gerrit.server.util.LabelVote;
-import com.google.gerrit.server.util.MagicBranch;
-import com.google.gerrit.server.util.RequestId;
-import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.gerrit.util.cli.CmdLineParser;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.assistedinject.Assisted;
-import java.io.IOException;
-import java.io.StringWriter;
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.LinkedHashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Future;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-import org.eclipse.jgit.errors.IncorrectObjectTypeException;
-import org.eclipse.jgit.errors.MissingObjectException;
-import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.notes.NoteMap;
-import org.eclipse.jgit.revwalk.FooterLine;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevSort;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.revwalk.filter.RevFilter;
-import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.AdvertiseRefsHookChain;
-import org.eclipse.jgit.transport.BaseReceivePack;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.eclipse.jgit.transport.ReceiveCommand.Result;
-import org.eclipse.jgit.transport.ReceivePack;
-import org.eclipse.jgit.transport.RefFilter;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
-import org.eclipse.jgit.transport.UploadPack;
-import org.kohsuke.args4j.CmdLineException;
-import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Receives change upload using the Git receive-pack protocol. */
-public class ReceiveCommits {
-  private static final Logger log = LoggerFactory.getLogger(ReceiveCommits.class);
-
-  public static final Pattern NEW_PATCHSET =
-      Pattern.compile("^" + REFS_CHANGES + "(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/[1-9][0-9]*)?$");
-
-  private static final String COMMAND_REJECTION_MESSAGE_FOOTER =
-      "Please read the documentation and contact an administrator\n"
-          + "if you feel the configuration is incorrect";
-
-  private static final String SAME_CHANGE_ID_IN_MULTIPLE_CHANGES =
-      "same Change-Id in multiple changes.\n"
-          + "Squash the commits with the same Change-Id or "
-          + "ensure Change-Ids are unique for each commit";
-
-  private enum Error {
-    CONFIG_UPDATE(
-        "You are not allowed to perform this operation.\n"
-            + "Configuration changes can only be pushed by project owners\n"
-            + "who also have 'Push' rights on "
-            + RefNames.REFS_CONFIG),
-    UPDATE(
-        "You are not allowed to perform this operation.\n"
-            + "To push into this reference you need 'Push' rights."),
-    DELETE(
-        "You need 'Delete Reference' rights or 'Push' rights with the \n"
-            + "'Force Push' flag set to delete references."),
-    DELETE_CHANGES("Cannot delete from '" + REFS_CHANGES + "'"),
-    CODE_REVIEW(
-        "You need 'Push' rights to upload code review requests.\n"
-            + "Verify that you are pushing to the right branch.");
-
-    private final String value;
-
-    Error(String value) {
-      this.value = value;
-    }
-
-    public String get() {
-      return value;
-    }
-  }
-
-  interface Factory {
-    ReceiveCommits create(ProjectControl projectControl, Repository repository);
-  }
-
-  public interface MessageSender {
-    void sendMessage(String what);
-
-    void sendError(String what);
-
-    void sendBytes(byte[] what);
-
-    void sendBytes(byte[] what, int off, int len);
-
-    void flush();
-  }
-
-  private class ReceivePackMessageSender implements MessageSender {
-    @Override
-    public void sendMessage(String what) {
-      rp.sendMessage(what);
-    }
-
-    @Override
-    public void sendError(String what) {
-      rp.sendError(what);
-    }
-
-    @Override
-    public void sendBytes(byte[] what) {
-      sendBytes(what, 0, what.length);
-    }
-
-    @Override
-    public void sendBytes(byte[] what, int off, int len) {
-      try {
-        rp.getMessageOutputStream().write(what, off, len);
-      } catch (IOException e) {
-        // Ignore write failures (matching JGit behavior).
-      }
-    }
-
-    @Override
-    public void flush() {
-      try {
-        rp.getMessageOutputStream().flush();
-      } catch (IOException e) {
-        // Ignore write failures (matching JGit behavior).
-      }
-    }
-  }
-
-  private static final Function<Exception, RestApiException> INSERT_EXCEPTION =
-      new Function<Exception, RestApiException>() {
-        @Override
-        public RestApiException apply(Exception input) {
-          if (input instanceof RestApiException) {
-            return (RestApiException) input;
-          } else if ((input instanceof ExecutionException)
-              && (input.getCause() instanceof RestApiException)) {
-            return (RestApiException) input.getCause();
-          }
-          return new RestApiException("Error inserting change/patchset", input);
-        }
-      };
-
-  private Set<Account.Id> reviewersFromCommandLine = Sets.newLinkedHashSet();
-  private Set<Account.Id> ccFromCommandLine = Sets.newLinkedHashSet();
-
-  private final IdentifiedUser user;
-  private final ReviewDb db;
-  private final Sequences seq;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeNotes.Factory notesFactory;
-  private final AccountResolver accountResolver;
-  private final CmdLineParser.Factory optionParserFactory;
-  private final GitReferenceUpdated gitRefUpdated;
-  private final PatchSetInfoFactory patchSetInfoFactory;
-  private final PatchSetUtil psUtil;
-  private final ProjectCache projectCache;
-  private final CommitValidators.Factory commitValidatorsFactory;
-  private final RefOperationValidators.Factory refValidatorsFactory;
-  private final TagCache tagCache;
-  private final AccountCache accountCache;
-  private final ChangeInserter.Factory changeInserterFactory;
-  private final RequestScopePropagator requestScopePropagator;
-  private final SshInfo sshInfo;
-  private final AllProjectsName allProjectsName;
-  private final ReceiveConfig receiveConfig;
-  private final DynamicSet<ReceivePackInitializer> initializers;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final SetHashtagsOp.Factory hashtagsFactory;
-  private final ReplaceOp.Factory replaceOpFactory;
-  private final MergedByPushOp.Factory mergedByPushOpFactory;
-
-  private final ProjectControl projectControl;
-  private final Project project;
-  private final LabelTypes labelTypes;
-  private final Repository repo;
-  private final ReceivePack rp;
-  private final NoteMap rejectCommits;
-  private final RequestId receiveId;
-  private MagicBranchInput magicBranch;
-  private boolean newChangeForAllNotInTarget;
-  private final ListMultimap<String, String> pushOptions = LinkedListMultimap.create();
-
-  private List<CreateRequest> newChanges = Collections.emptyList();
-  private final Map<Change.Id, ReplaceRequest> replaceByChange = new LinkedHashMap<>();
-  private final List<UpdateGroupsRequest> updateGroups = new ArrayList<>();
-  private final Set<ObjectId> validCommits = new HashSet<>();
-
-  private ListMultimap<Change.Id, Ref> refsByChange;
-  private ListMultimap<ObjectId, Ref> refsById;
-  private Map<String, Ref> allRefs;
-
-  private final SubmoduleOp.Factory subOpFactory;
-  private final Provider<MergeOp> mergeOpProvider;
-  private final Provider<MergeOpRepoManager> ormProvider;
-  private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
-  private final NotesMigration notesMigration;
-  private final ChangeEditUtil editUtil;
-  private final ChangeIndexer indexer;
-
-  private final List<ValidationMessage> messages = new ArrayList<>();
-  private ListMultimap<Error, String> errors = LinkedListMultimap.create();
-  private Task newProgress;
-  private Task replaceProgress;
-  private Task closeProgress;
-  private Task commandProgress;
-  private MessageSender messageSender;
-  private BatchRefUpdate batch;
-  private final ChangeReportFormatter changeFormatter;
-
-  @Inject
-  ReceiveCommits(
-      ReviewDb db,
-      Sequences seq,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeNotes.Factory notesFactory,
-      AccountResolver accountResolver,
-      CmdLineParser.Factory optionParserFactory,
-      GitReferenceUpdated gitRefUpdated,
-      PatchSetInfoFactory patchSetInfoFactory,
-      PatchSetUtil psUtil,
-      ProjectCache projectCache,
-      TagCache tagCache,
-      AccountCache accountCache,
-      @Nullable SearchingChangeCacheImpl changeCache,
-      ChangeInserter.Factory changeInserterFactory,
-      CommitValidators.Factory commitValidatorsFactory,
-      RefOperationValidators.Factory refValidatorsFactory,
-      RequestScopePropagator requestScopePropagator,
-      SshInfo sshInfo,
-      AllProjectsName allProjectsName,
-      ReceiveConfig receiveConfig,
-      TransferConfig transferConfig,
-      DynamicSet<ReceivePackInitializer> initializers,
-      Provider<LazyPostReceiveHookChain> lazyPostReceive,
-      @Assisted ProjectControl projectControl,
-      @Assisted Repository repo,
-      SubmoduleOp.Factory subOpFactory,
-      Provider<MergeOp> mergeOpProvider,
-      Provider<MergeOpRepoManager> ormProvider,
-      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
-      NotesMigration notesMigration,
-      ChangeEditUtil editUtil,
-      ChangeIndexer indexer,
-      BatchUpdate.Factory batchUpdateFactory,
-      SetHashtagsOp.Factory hashtagsFactory,
-      ReplaceOp.Factory replaceOpFactory,
-      MergedByPushOp.Factory mergedByPushOpFactory,
-      DynamicItem<ChangeReportFormatter> changeFormatterProvider)
-      throws IOException {
-    this.user = projectControl.getUser().asIdentifiedUser();
-    this.db = db;
-    this.seq = seq;
-    this.queryProvider = queryProvider;
-    this.notesFactory = notesFactory;
-    this.accountResolver = accountResolver;
-    this.optionParserFactory = optionParserFactory;
-    this.gitRefUpdated = gitRefUpdated;
-    this.patchSetInfoFactory = patchSetInfoFactory;
-    this.psUtil = psUtil;
-    this.projectCache = projectCache;
-    this.tagCache = tagCache;
-    this.accountCache = accountCache;
-    this.changeInserterFactory = changeInserterFactory;
-    this.commitValidatorsFactory = commitValidatorsFactory;
-    this.refValidatorsFactory = refValidatorsFactory;
-    this.requestScopePropagator = requestScopePropagator;
-    this.sshInfo = sshInfo;
-    this.allProjectsName = allProjectsName;
-    this.receiveConfig = receiveConfig;
-    this.initializers = initializers;
-    this.batchUpdateFactory = batchUpdateFactory;
-    this.hashtagsFactory = hashtagsFactory;
-    this.replaceOpFactory = replaceOpFactory;
-    this.mergedByPushOpFactory = mergedByPushOpFactory;
-
-    this.projectControl = projectControl;
-    this.labelTypes = projectControl.getLabelTypes();
-    this.project = projectControl.getProject();
-    this.repo = repo;
-    this.rp = new ReceivePack(repo);
-    this.rejectCommits = BanCommit.loadRejectCommitsMap(repo, rp.getRevWalk());
-    this.receiveId = RequestId.forProject(project.getNameKey());
-
-    this.subOpFactory = subOpFactory;
-    this.mergeOpProvider = mergeOpProvider;
-    this.ormProvider = ormProvider;
-    this.pluginConfigEntries = pluginConfigEntries;
-    this.notesMigration = notesMigration;
-
-    this.editUtil = editUtil;
-    this.indexer = indexer;
-
-    this.messageSender = new ReceivePackMessageSender();
-    this.changeFormatter = changeFormatterProvider.get();
-
-    ProjectState ps = projectControl.getProjectState();
-
-    this.newChangeForAllNotInTarget = ps.isCreateNewChangeForAllNotInTarget();
-    rp.setAllowCreates(true);
-    rp.setAllowDeletes(true);
-    rp.setAllowNonFastForwards(true);
-    rp.setRefLogIdent(user.newRefLogIdent());
-    rp.setTimeout(transferConfig.getTimeout());
-    rp.setMaxObjectSizeLimit(
-        projectControl.getProjectState().getEffectiveMaxObjectSizeLimit().value);
-    rp.setCheckReceivedObjects(ps.getConfig().getCheckReceivedObjects());
-    rp.setRefFilter(
-        new RefFilter() {
-          @Override
-          public Map<String, Ref> filter(Map<String, Ref> refs) {
-            Map<String, Ref> filteredRefs = Maps.newHashMapWithExpectedSize(refs.size());
-            for (Map.Entry<String, Ref> e : refs.entrySet()) {
-              String name = e.getKey();
-              if (!name.startsWith(REFS_CHANGES)
-                  && !name.startsWith(RefNames.REFS_CACHE_AUTOMERGE)) {
-                filteredRefs.put(name, e.getValue());
-              }
-            }
-            return filteredRefs;
-          }
-        });
-
-    if (!projectControl.allRefsAreVisible()) {
-      rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
-    }
-    rp.setAdvertiseRefsHook(
-        new VisibleRefFilter(tagCache, notesFactory, changeCache, repo, projectControl, db, false));
-    List<AdvertiseRefsHook> advHooks = new ArrayList<>(3);
-    advHooks.add(
-        new AdvertiseRefsHook() {
-          @Override
-          public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
-            allRefs = rp.getAdvertisedRefs();
-            if (allRefs == null) {
-              try {
-                allRefs = rp.getRepository().getRefDatabase().getRefs(ALL);
-              } catch (ServiceMayNotContinueException e) {
-                throw e;
-              } catch (IOException e) {
-                ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
-                ex.initCause(e);
-                throw ex;
-              }
-            }
-            rp.setAdvertisedRefs(allRefs, rp.getAdvertisedObjects());
-          }
-
-          @Override
-          public void advertiseRefs(UploadPack uploadPack) {}
-        });
-    advHooks.add(rp.getAdvertiseRefsHook());
-    advHooks.add(
-        new ReceiveCommitsAdvertiseRefsHook(
-            queryProvider, projectControl.getProject().getNameKey()));
-    advHooks.add(new HackPushNegotiateHook());
-    rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
-    rp.setPostReceiveHook(lazyPostReceive.get());
-    rp.setAllowPushOptions(true);
-  }
-
-  public void init() {
-    for (ReceivePackInitializer i : initializers) {
-      i.init(projectControl.getProject().getNameKey(), rp);
-    }
-  }
-
-  /** Add reviewers for new (or updated) changes. */
-  public void addReviewers(Collection<Account.Id> who) {
-    reviewersFromCommandLine.addAll(who);
-  }
-
-  /** Add reviewers for new (or updated) changes. */
-  public void addExtraCC(Collection<Account.Id> who) {
-    ccFromCommandLine.addAll(who);
-  }
-
-  /** Set a message sender for this operation. */
-  public void setMessageSender(MessageSender ms) {
-    messageSender = ms != null ? ms : new ReceivePackMessageSender();
-  }
-
-  MessageSender getMessageSender() {
-    if (messageSender == null) {
-      setMessageSender(null);
-    }
-    return messageSender;
-  }
-
-  Project getProject() {
-    return project;
-  }
-
-  /** @return the ReceivePack instance to speak the native Git protocol. */
-  public ReceivePack getReceivePack() {
-    return rp;
-  }
-
-  /** Determine if the user can upload commits. */
-  public Capable canUpload() {
-    Capable result = projectControl.canPushToAtLeastOneRef();
-    if (result != Capable.OK) {
-      return result;
-    }
-    if (receiveConfig.checkMagicRefs) {
-      result = MagicBranch.checkMagicBranchRefs(repo, project);
-    }
-    return result;
-  }
-
-  private void addMessage(String message) {
-    messages.add(new CommitValidationMessage(message, false));
-  }
-
-  void addError(String error) {
-    messages.add(new CommitValidationMessage(error, true));
-  }
-
-  void sendMessages() {
-    for (ValidationMessage m : messages) {
-      if (m.isError()) {
-        messageSender.sendError(m.getMessage());
-      } else {
-        messageSender.sendMessage(m.getMessage());
-      }
-    }
-  }
-
-  void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
-    newProgress = progress.beginSubTask("new", UNKNOWN);
-    replaceProgress = progress.beginSubTask("updated", UNKNOWN);
-    closeProgress = progress.beginSubTask("closed", UNKNOWN);
-    commandProgress = progress.beginSubTask("refs", UNKNOWN);
-
-    batch = repo.getRefDatabase().newBatchUpdate();
-    batch.setPushCertificate(rp.getPushCertificate());
-    batch.setRefLogIdent(rp.getRefLogIdent());
-    batch.setRefLogMessage("push", true);
-
-    parseCommands(commands);
-    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
-      selectNewAndReplacedChangesFromMagicBranch();
-    }
-    preparePatchSetsForReplace();
-
-    logDebug("Executing batch with {} commands", batch.getCommands().size());
-    if (!batch.getCommands().isEmpty()) {
-      try {
-        if (!batch.isAllowNonFastForwards() && magicBranch != null && magicBranch.edit) {
-          logDebug("Allowing non-fast-forward for edit ref");
-          batch.setAllowNonFastForwards(true);
-        }
-        batch.execute(rp.getRevWalk(), commandProgress);
-      } catch (IOException err) {
-        int cnt = 0;
-        for (ReceiveCommand cmd : batch.getCommands()) {
-          if (cmd.getResult() == NOT_ATTEMPTED) {
-            cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
-            cnt++;
-          }
-        }
-        logError(String.format("Failed to store %d refs in %s", cnt, project.getName()), err);
-      }
-    }
-
-    insertChangesAndPatchSets();
-    newProgress.end();
-    replaceProgress.end();
-
-    if (!errors.isEmpty()) {
-      logDebug("Handling error conditions: {}", errors.keySet());
-      for (Error error : errors.keySet()) {
-        rp.sendMessage(buildError(error, errors.get(error)));
-      }
-      rp.sendMessage(String.format("User: %s", displayName(user)));
-      rp.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
-    }
-
-    Set<Branch.NameKey> branches = new HashSet<>();
-    for (ReceiveCommand c : batch.getCommands()) {
-      if (c.getResult() == OK) {
-        String refName = c.getRefName();
-        if (c.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
-          logDebug("Updating tag cache on fast-forward of {}", c.getRefName());
-          tagCache.updateFastForward(project.getNameKey(), refName, c.getOldId(), c.getNewId());
-        }
-
-        if (isHead(c) || isConfig(c)) {
-          switch (c.getType()) {
-            case CREATE:
-            case UPDATE:
-            case UPDATE_NONFASTFORWARD:
-              autoCloseChanges(c);
-              branches.add(new Branch.NameKey(project.getNameKey(), refName));
-              break;
-
-            case DELETE:
-              break;
-          }
-        }
-
-        if (isConfig(c)) {
-          logDebug("Reloading project in cache");
-          projectCache.evict(project);
-          ProjectState ps = projectCache.get(project.getNameKey());
-          try {
-            repo.setGitwebDescription(ps.getProject().getDescription());
-          } catch (IOException e) {
-            log.warn("cannot update description of {}", project.getName(), e);
-          }
-        }
-
-        if (!MagicBranch.isMagicBranch(refName)) {
-          logDebug("Firing ref update for {}", c.getRefName());
-          gitRefUpdated.fire(project.getNameKey(), c, user.getAccount());
-        } else {
-          logDebug("Assuming ref update event for {} has fired", c.getRefName());
-        }
-      }
-    }
-
-    // Update superproject gitlinks if required.
-    if (!branches.isEmpty()) {
-      try (MergeOpRepoManager orm = ormProvider.get()) {
-        orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
-        SubmoduleOp op = subOpFactory.create(branches, orm);
-        op.updateSuperProjects();
-      } catch (SubmoduleException e) {
-        logError("Can't update the superprojects", e);
-      }
-    }
-
-    closeProgress.end();
-    commandProgress.end();
-    progress.end();
-    reportMessages();
-  }
-
-  private void reportMessages() {
-    List<CreateRequest> created =
-        newChanges.stream().filter(r -> r.change != null).collect(toList());
-    if (!created.isEmpty()) {
-      addMessage("");
-      addMessage("New Changes:");
-      for (CreateRequest c : created) {
-        addMessage(
-            changeFormatter.newChange(
-                ChangeReportFormatter.Input.builder().setChange(c.change).build()));
-      }
-      addMessage("");
-    }
-
-    List<ReplaceRequest> updated =
-        replaceByChange
-            .values()
-            .stream()
-            .filter(r -> !r.skip && r.inputCommand.getResult() == OK)
-            .sorted(comparingInt(r -> r.notes.getChangeId().get()))
-            .collect(toList());
-    if (!updated.isEmpty()) {
-      addMessage("");
-      addMessage("Updated Changes:");
-      boolean edit = magicBranch != null && magicBranch.edit;
-      for (ReplaceRequest u : updated) {
-        String subject;
-        if (edit) {
-          try {
-            subject = rp.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
-          } catch (IOException e) {
-            // Log and fall back to original change subject
-            logWarn("failed to get subject for edit patch set", e);
-            subject = u.notes.getChange().getSubject();
-          }
-        } else {
-          subject = u.info.getSubject();
-        }
-
-        ChangeReportFormatter.Input input =
-            ChangeReportFormatter.Input.builder()
-                .setChange(u.notes.getChange())
-                .setSubject(subject)
-                .setIsDraft(u.replaceOp != null && u.replaceOp.getPatchSet().isDraft())
-                .setIsEdit(edit)
-                .build();
-        addMessage(changeFormatter.changeUpdated(input));
-      }
-      addMessage("");
-    }
-  }
-
-  private void insertChangesAndPatchSets() {
-    int replaceCount = 0;
-    int okToInsert = 0;
-
-    for (Map.Entry<Change.Id, ReplaceRequest> e : replaceByChange.entrySet()) {
-      ReplaceRequest replace = e.getValue();
-      if (magicBranch != null && replace.inputCommand == magicBranch.cmd) {
-        replaceCount++;
-
-        if (replace.cmd != null && replace.cmd.getResult() == OK) {
-          okToInsert++;
-        }
-      } else if (replace.cmd != null && replace.cmd.getResult() == OK) {
-        String refName = replace.inputCommand.getRefName();
-        checkState(
-            NEW_PATCHSET.matcher(refName).matches(),
-            "expected a new patch set command as input when creating %s; got %s",
-            replace.cmd.getRefName(),
-            refName);
-        try {
-          logDebug("One-off insertion of patch set for {}", refName);
-          replace.insertPatchSetWithoutBatchUpdate();
-          replace.inputCommand.setResult(OK);
-        } catch (IOException | UpdateException | RestApiException err) {
-          reject(replace.inputCommand, "internal server error");
-          logError(
-              String.format(
-                  "Cannot add patch set to change %d in project %s",
-                  e.getKey().get(), project.getName()),
-              err);
-        }
-      } else if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
-        reject(replace.inputCommand, "internal server error");
-        logError(String.format("Replacement for project %s was not attempted", project.getName()));
-      }
-    }
-
-    // refs/for/ or refs/drafts/ not used, or it already failed earlier.
-    // No need to continue.
-    if (magicBranch == null) {
-      logDebug("No magic branch, nothing more to do");
-      return;
-    } else if (magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
-      logWarn(
-          String.format(
-              "Skipping change updates on %s because ref update failed: %s %s",
-              project.getName(),
-              magicBranch.cmd.getResult(),
-              Strings.nullToEmpty(magicBranch.cmd.getMessage())));
-      return;
-    }
-
-    List<String> lastCreateChangeErrors = new ArrayList<>();
-    for (CreateRequest create : newChanges) {
-      if (create.cmd.getResult() == OK) {
-        okToInsert++;
-      } else {
-        String createChangeResult =
-            String.format(
-                    "%s %s", create.cmd.getResult(), Strings.nullToEmpty(create.cmd.getMessage()))
-                .trim();
-        lastCreateChangeErrors.add(createChangeResult);
-        logError(
-            String.format(
-                "Command %s on %s:%s not completed: %s",
-                create.cmd.getType(),
-                project.getName(),
-                create.cmd.getRefName(),
-                createChangeResult));
-      }
-    }
-
-    logDebug(
-        "Counted {} ok to insert, out of {} to replace and {} new",
-        okToInsert,
-        replaceCount,
-        newChanges.size());
-
-    if (okToInsert != replaceCount + newChanges.size()) {
-      // One or more new references failed to create. Assume the
-      // system isn't working correctly anymore and abort.
-      reject(
-          magicBranch.cmd,
-          "Unable to create changes: " + lastCreateChangeErrors.stream().collect(joining(" ")));
-      logError(
-          String.format(
-              "Only %d of %d new change refs created in %s; aborting",
-              okToInsert, replaceCount + newChanges.size(), project.getName()));
-      return;
-    }
-
-    try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                db, magicBranch.dest.getParentKey(), user.materializedCopy(), TimeUtil.nowTs());
-        ObjectInserter ins = repo.newObjectInserter()) {
-      bu.setRepository(repo, rp.getRevWalk(), ins).updateChangesInParallel();
-      bu.setRequestId(receiveId);
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        if (replace.inputCommand == magicBranch.cmd) {
-          replace.addOps(bu, replaceProgress);
-        }
-      }
-
-      for (CreateRequest create : newChanges) {
-        create.addOps(bu);
-      }
-
-      for (UpdateGroupsRequest update : updateGroups) {
-        update.addOps(bu);
-      }
-
-      logDebug("Executing batch");
-      try {
-        bu.execute();
-      } catch (UpdateException e) {
-        throw INSERT_EXCEPTION.apply(e);
-      }
-      magicBranch.cmd.setResult(OK);
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        String rejectMessage = replace.getRejectMessage();
-        if (rejectMessage != null) {
-          logDebug("Rejecting due to message from ReplaceOp");
-          reject(replace.inputCommand, rejectMessage);
-        }
-      }
-
-    } catch (ResourceConflictException e) {
-      addMessage(e.getMessage());
-      reject(magicBranch.cmd, "conflict");
-    } catch (RestApiException | IOException err) {
-      logError("Can't insert change/patch set for " + project.getName(), err);
-      reject(magicBranch.cmd, "internal server error: " + err.getMessage());
-    }
-
-    if (magicBranch != null && magicBranch.submit) {
-      try {
-        submit(newChanges, replaceByChange.values());
-      } catch (ResourceConflictException e) {
-        addMessage(e.getMessage());
-        reject(magicBranch.cmd, "conflict");
-      } catch (RestApiException | OrmException e) {
-        logError("Error submitting changes to " + project.getName(), e);
-        reject(magicBranch.cmd, "error during submit");
-      }
-    }
-  }
-
-  private String buildError(Error error, List<String> branches) {
-    StringBuilder sb = new StringBuilder();
-    if (branches.size() == 1) {
-      sb.append("Branch ").append(branches.get(0)).append(":\n");
-      sb.append(error.get());
-      return sb.toString();
-    }
-    sb.append("Branches");
-    String delim = " ";
-    for (String branch : branches) {
-      sb.append(delim).append(branch);
-      delim = ", ";
-    }
-    return sb.append(":\n").append(error.get()).toString();
-  }
-
-  private static String displayName(IdentifiedUser user) {
-    String displayName = user.getUserName();
-    if (displayName == null) {
-      displayName = user.getAccount().getPreferredEmail();
-    }
-    return displayName;
-  }
-
-  private void parseCommands(Collection<ReceiveCommand> commands) {
-    List<String> optionList = rp.getPushOptions();
-    if (optionList != null) {
-      for (String option : optionList) {
-        int e = option.indexOf('=');
-        if (e > 0) {
-          pushOptions.put(option.substring(0, e), option.substring(e + 1));
-        } else {
-          pushOptions.put(option, "");
-        }
-      }
-    }
-
-    logDebug("Parsing {} commands", commands.size());
-    for (ReceiveCommand cmd : commands) {
-      if (cmd.getResult() != NOT_ATTEMPTED) {
-        // Already rejected by the core receive process.
-        logDebug("Already processed by core: {} {}", cmd.getResult(), cmd);
-        continue;
-      }
-
-      if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
-        reject(cmd, "not valid ref");
-        continue;
-      }
-
-      if (MagicBranch.isMagicBranch(cmd.getRefName())) {
-        parseMagicBranch(cmd);
-        continue;
-      }
-
-      if (projectControl.getProjectState().isAllUsers()
-          && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
-        String newName = RefNames.refsUsers(user.getAccountId());
-        logDebug("Swapping out command for {} to {}", RefNames.REFS_USERS_SELF, newName);
-        final ReceiveCommand orgCmd = cmd;
-        cmd =
-            new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), newName, cmd.getType()) {
-              @Override
-              public void setResult(Result s, String m) {
-                super.setResult(s, m);
-                orgCmd.setResult(s, m);
-              }
-            };
-      }
-
-      Matcher m = NEW_PATCHSET.matcher(cmd.getRefName());
-      if (m.matches()) {
-        // The referenced change must exist and must still be open.
-        //
-        Change.Id changeId = Change.Id.parse(m.group(1));
-        parseReplaceCommand(cmd, changeId);
-        continue;
-      }
-
-      switch (cmd.getType()) {
-        case CREATE:
-          parseCreate(cmd);
-          break;
-
-        case UPDATE:
-          parseUpdate(cmd);
-          break;
-
-        case DELETE:
-          parseDelete(cmd);
-          break;
-
-        case UPDATE_NONFASTFORWARD:
-          parseRewind(cmd);
-          break;
-
-        default:
-          reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
-          continue;
-      }
-
-      if (cmd.getResult() != NOT_ATTEMPTED) {
-        continue;
-      }
-
-      if (isConfig(cmd)) {
-        logDebug("Processing {} command", cmd.getRefName());
-        if (!projectControl.isOwner()) {
-          reject(cmd, "not project owner");
-          continue;
-        }
-
-        switch (cmd.getType()) {
-          case CREATE:
-          case UPDATE:
-          case UPDATE_NONFASTFORWARD:
-            try {
-              ProjectConfig cfg = new ProjectConfig(project.getNameKey());
-              cfg.load(rp.getRevWalk(), cmd.getNewId());
-              if (!cfg.getValidationErrors().isEmpty()) {
-                addError("Invalid project configuration:");
-                for (ValidationError err : cfg.getValidationErrors()) {
-                  addError("  " + err.getMessage());
-                }
-                reject(cmd, "invalid project configuration");
-                logError(
-                    "User "
-                        + user.getUserName()
-                        + " tried to push invalid project configuration "
-                        + cmd.getNewId().name()
-                        + " for "
-                        + project.getName());
-                continue;
-              }
-              Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
-              Project.NameKey oldParent = project.getParent(allProjectsName);
-              if (oldParent == null) {
-                // update of the 'All-Projects' project
-                if (newParent != null) {
-                  reject(cmd, "invalid project configuration: root project cannot have parent");
-                  continue;
-                }
-              } else {
-                if (!oldParent.equals(newParent)
-                    && !user.getCapabilities().canAdministrateServer()) {
-                  reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
-                  continue;
-                }
-
-                if (projectCache.get(newParent) == null) {
-                  reject(cmd, "invalid project configuration: parent does not exist");
-                  continue;
-                }
-              }
-
-              for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
-                PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
-                ProjectConfigEntry configEntry = e.getProvider().get();
-                String value = pluginCfg.getString(e.getExportName());
-                String oldValue =
-                    projectControl
-                        .getProjectState()
-                        .getConfig()
-                        .getPluginConfig(e.getPluginName())
-                        .getString(e.getExportName());
-                if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
-                  oldValue =
-                      Arrays.stream(
-                              projectControl
-                                  .getProjectState()
-                                  .getConfig()
-                                  .getPluginConfig(e.getPluginName())
-                                  .getStringList(e.getExportName()))
-                          .collect(joining("\n"));
-                }
-
-                if ((value == null ? oldValue != null : !value.equals(oldValue))
-                    && !configEntry.isEditable(projectControl.getProjectState())) {
-                  reject(
-                      cmd,
-                      String.format(
-                          "invalid project configuration: Not allowed to set parameter"
-                              + " '%s' of plugin '%s' on project '%s'.",
-                          e.getExportName(), e.getPluginName(), project.getName()));
-                  continue;
-                }
-
-                if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
-                    && value != null
-                    && !configEntry.getPermittedValues().contains(value)) {
-                  reject(
-                      cmd,
-                      String.format(
-                          "invalid project configuration: The value '%s' is "
-                              + "not permitted for parameter '%s' of plugin '%s'.",
-                          value, e.getExportName(), e.getPluginName()));
-                }
-              }
-            } catch (Exception e) {
-              reject(cmd, "invalid project configuration");
-              logError(
-                  "User "
-                      + user.getUserName()
-                      + " tried to push invalid project configuration "
-                      + cmd.getNewId().name()
-                      + " for "
-                      + project.getName(),
-                  e);
-              continue;
-            }
-            break;
-
-          case DELETE:
-            break;
-
-          default:
-            reject(
-                cmd,
-                "prohibited by Gerrit: don't know how to handle config update of type "
-                    + cmd.getType());
-            continue;
-        }
-      }
-    }
-  }
-
-  private void parseCreate(ReceiveCommand cmd) {
-    RevObject obj;
-    try {
-      obj = rp.getRevWalk().parseAny(cmd.getNewId());
-    } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " creation",
-          err);
-      reject(cmd, "invalid object");
-      return;
-    }
-    logDebug("Creating {}", cmd);
-
-    if (isHead(cmd) && !isCommit(cmd)) {
-      return;
-    }
-
-    RefControl ctl = projectControl.controlForRef(cmd.getRefName());
-    if (ctl.canCreate(db, rp.getRepository(), obj)) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      validateNewCommits(ctl, cmd);
-      batch.addCommand(cmd);
-    } else {
-      reject(cmd, "prohibited by Gerrit: create access denied for " + cmd.getRefName());
-    }
-  }
-
-  private void parseUpdate(ReceiveCommand cmd) {
-    logDebug("Updating {}", cmd);
-    RefControl ctl = projectControl.controlForRef(cmd.getRefName());
-    if (ctl.canUpdate()) {
-      if (isHead(cmd) && !isCommit(cmd)) {
-        return;
-      }
-
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      validateNewCommits(ctl, cmd);
-      batch.addCommand(cmd);
-    } else {
-      if (RefNames.REFS_CONFIG.equals(ctl.getRefName())) {
-        errors.put(Error.CONFIG_UPDATE, RefNames.REFS_CONFIG);
-      } else {
-        errors.put(Error.UPDATE, ctl.getRefName());
-      }
-      reject(cmd, "prohibited by Gerrit: ref update access denied");
-    }
-  }
-
-  private boolean isCommit(ReceiveCommand cmd) {
-    RevObject obj;
-    try {
-      obj = rp.getRevWalk().parseAny(cmd.getNewId());
-    } catch (IOException err) {
-      logError("Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName(), err);
-      reject(cmd, "invalid object");
-      return false;
-    }
-
-    if (obj instanceof RevCommit) {
-      return true;
-    }
-    reject(cmd, "not a commit");
-    return false;
-  }
-
-  private void parseDelete(ReceiveCommand cmd) {
-    logDebug("Deleting {}", cmd);
-    RefControl ctl = projectControl.controlForRef(cmd.getRefName());
-    if (ctl.getRefName().startsWith(REFS_CHANGES)) {
-      errors.put(Error.DELETE_CHANGES, ctl.getRefName());
-      reject(cmd, "cannot delete changes");
-    } else if (ctl.canDelete()) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      batch.addCommand(cmd);
-    } else {
-      if (RefNames.REFS_CONFIG.equals(ctl.getRefName())) {
-        reject(cmd, "cannot delete project configuration");
-      } else {
-        errors.put(Error.DELETE, ctl.getRefName());
-        reject(cmd, "cannot delete references");
-      }
-    }
-  }
-
-  private void parseRewind(ReceiveCommand cmd) {
-    RevCommit newObject;
-    try {
-      newObject = rp.getRevWalk().parseCommit(cmd.getNewId());
-    } catch (IncorrectObjectTypeException notCommit) {
-      newObject = null;
-    } catch (IOException err) {
-      logError(
-          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " forced update",
-          err);
-      reject(cmd, "invalid object");
-      return;
-    }
-    logDebug("Rewinding {}", cmd);
-
-    RefControl ctl = projectControl.controlForRef(cmd.getRefName());
-    if (newObject != null) {
-      validateNewCommits(ctl, cmd);
-      if (cmd.getResult() != NOT_ATTEMPTED) {
-        return;
-      }
-    }
-
-    if (ctl.canForceUpdate()) {
-      if (!validRefOperation(cmd)) {
-        return;
-      }
-      batch.setAllowNonFastForwards(true).addCommand(cmd);
-    } else {
-      cmd.setResult(
-          REJECTED_NONFASTFORWARD, " need '" + PermissionRule.FORCE_PUSH + "' privilege.");
-    }
-  }
-
-  static class MagicBranchInput {
-    private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
-
-    final ReceiveCommand cmd;
-    Branch.NameKey dest;
-    RefControl ctl;
-    Set<Account.Id> reviewer = Sets.newLinkedHashSet();
-    Set<Account.Id> cc = Sets.newLinkedHashSet();
-    Map<String, Short> labels = new HashMap<>();
-    String message;
-    List<RevCommit> baseCommit;
-    LabelTypes labelTypes;
-    CmdLineParser clp;
-    Set<String> hashtags = new HashSet<>();
-    NotesMigration notesMigration;
-
-    @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
-    List<ObjectId> base;
-
-    @Option(name = "--topic", metaVar = "NAME", usage = "attach topic to changes")
-    String topic;
-
-    @Option(name = "--draft", usage = "mark new/updated changes as draft")
-    boolean draft;
-
-    @Option(
-        name = "--edit",
-        aliases = {"-e"},
-        usage = "upload as change edit")
-    boolean edit;
-
-    @Option(name = "--submit", usage = "immediately submit the change")
-    boolean submit;
-
-    @Option(name = "--merged", usage = "create single change for a merged commit")
-    boolean merged;
-
-    @Option(
-        name = "--notify",
-        usage =
-            "Notify handling that defines to whom email notifications "
-                + "should be sent. Allowed values are NONE, OWNER, "
-                + "OWNER_REVIEWERS, ALL. If not set, the default is ALL.")
-    NotifyHandling notify = NotifyHandling.ALL;
-
-    @Option(name = "--notify-to", metaVar = "USER", usage = "user that should be notified")
-    List<Account.Id> tos = new ArrayList<>();
-
-    @Option(name = "--notify-cc", metaVar = "USER", usage = "user that should be CC'd")
-    List<Account.Id> ccs = new ArrayList<>();
-
-    @Option(name = "--notify-bcc", metaVar = "USER", usage = "user that should be BCC'd")
-    List<Account.Id> bccs = new ArrayList<>();
-
-    @Option(
-        name = "--reviewer",
-        aliases = {"-r"},
-        metaVar = "EMAIL",
-        usage = "add reviewer to changes")
-    void reviewer(Account.Id id) {
-      reviewer.add(id);
-    }
-
-    @Option(name = "--cc", metaVar = "EMAIL", usage = "notify user by CC")
-    void cc(Account.Id id) {
-      cc.add(id);
-    }
-
-    @Option(name = "--publish", usage = "publish new/updated changes")
-    void publish(boolean publish) {
-      draft = !publish;
-    }
-
-    @Option(
-        name = "--label",
-        aliases = {"-l"},
-        metaVar = "LABEL+VALUE",
-        usage = "label(s) to assign (defaults to +1 if no value provided")
-    void addLabel(String token) throws CmdLineException {
-      LabelVote v = LabelVote.parse(token);
-      try {
-        LabelType.checkName(v.label());
-        ApprovalsUtil.checkLabel(labelTypes, v.label(), v.value());
-      } catch (BadRequestException e) {
-        throw clp.reject(e.getMessage());
-      }
-      labels.put(v.label(), v.value());
-    }
-
-    @Option(
-        name = "--message",
-        aliases = {"-m"},
-        metaVar = "MESSAGE",
-        usage = "Comment message to apply to the review")
-    void addMessage(final String token) {
-      // git push does not allow spaces in refs.
-      message = token.replace("_", " ");
-    }
-
-    @Option(
-        name = "--hashtag",
-        aliases = {"-t"},
-        metaVar = "HASHTAG",
-        usage = "add hashtag to changes")
-    void addHashtag(String token) throws CmdLineException {
-      if (!notesMigration.readChanges()) {
-        throw clp.reject("cannot add hashtags; noteDb is disabled");
-      }
-      String hashtag = cleanupHashtag(token);
-      if (!hashtag.isEmpty()) {
-        hashtags.add(hashtag);
-      }
-      // TODO(dpursehouse): validate hashtags
-    }
-
-    MagicBranchInput(ReceiveCommand cmd, LabelTypes labelTypes, NotesMigration notesMigration) {
-      this.cmd = cmd;
-      this.draft = cmd.getRefName().startsWith(MagicBranch.NEW_DRAFT_CHANGE);
-      this.labelTypes = labelTypes;
-      this.notesMigration = notesMigration;
-    }
-
-    MailRecipients getMailRecipients() {
-      return new MailRecipients(reviewer, cc);
-    }
-
-    ListMultimap<RecipientType, Account.Id> getAccountsToNotify() {
-      ListMultimap<RecipientType, Account.Id> accountsToNotify =
-          MultimapBuilder.hashKeys().arrayListValues().build();
-      accountsToNotify.putAll(RecipientType.TO, tos);
-      accountsToNotify.putAll(RecipientType.CC, ccs);
-      accountsToNotify.putAll(RecipientType.BCC, bccs);
-      return accountsToNotify;
-    }
-
-    String parse(
-        CmdLineParser clp,
-        Repository repo,
-        Set<String> refs,
-        ListMultimap<String, String> pushOptions)
-        throws CmdLineException {
-      String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
-
-      ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
-      int optionStart = ref.indexOf('%');
-      if (0 < optionStart) {
-        for (String s : COMMAS.split(ref.substring(optionStart + 1))) {
-          int e = s.indexOf('=');
-          if (0 < e) {
-            options.put(s.substring(0, e), s.substring(e + 1));
-          } else {
-            options.put(s, "");
-          }
-        }
-        ref = ref.substring(0, optionStart);
-      }
-
-      if (!options.isEmpty()) {
-        clp.parseOptionMap(options);
-      }
-
-      // Split the destination branch by branch and topic. The topic
-      // suffix is entirely optional, so it might not even exist.
-      String head = readHEAD(repo);
-      int split = ref.length();
-      for (; ; ) {
-        String name = ref.substring(0, split);
-        if (refs.contains(name) || name.equals(head)) {
-          break;
-        }
-
-        split = name.lastIndexOf('/', split - 1);
-        if (split <= Constants.R_REFS.length()) {
-          return ref;
-        }
-      }
-      if (split < ref.length()) {
-        topic = Strings.emptyToNull(ref.substring(split + 1));
-      }
-      return ref.substring(0, split);
-    }
-  }
-
-  /**
-   * Gets an unmodifiable view of the pushOptions.
-   *
-   * <p>The collection is empty if the client does not support push options, or if the client did
-   * not send any options.
-   *
-   * @return an unmodifiable view of pushOptions.
-   */
-  @Nullable
-  public ListMultimap<String, String> getPushOptions() {
-    return ImmutableListMultimap.copyOf(pushOptions);
-  }
-
-  private void parseMagicBranch(ReceiveCommand cmd) {
-    // Permit exactly one new change request per push.
-    if (magicBranch != null) {
-      reject(cmd, "duplicate request");
-      return;
-    }
-
-    logDebug("Found magic branch {}", cmd.getRefName());
-    magicBranch = new MagicBranchInput(cmd, labelTypes, notesMigration);
-    magicBranch.reviewer.addAll(reviewersFromCommandLine);
-    magicBranch.cc.addAll(ccFromCommandLine);
-
-    String ref;
-    CmdLineParser clp = optionParserFactory.create(magicBranch);
-    magicBranch.clp = clp;
-
-    try {
-      ref = magicBranch.parse(clp, repo, rp.getAdvertisedRefs().keySet(), pushOptions);
-    } catch (CmdLineException e) {
-      if (!clp.wasHelpRequestedByOption()) {
-        logDebug("Invalid branch syntax");
-        reject(cmd, e.getMessage());
-        return;
-      }
-      ref = null; // never happen
-    }
-    if (clp.wasHelpRequestedByOption()) {
-      StringWriter w = new StringWriter();
-      w.write("\nHelp for refs/for/branch:\n\n");
-      clp.printUsage(w, null);
-      addMessage(w.toString());
-      reject(cmd, "see help");
-      return;
-    }
-    if (projectControl.getProjectState().isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
-      logDebug("Handling {}", RefNames.REFS_USERS_SELF);
-      ref = RefNames.refsUsers(user.getAccountId());
-    }
-    if (!rp.getAdvertisedRefs().containsKey(ref) && !ref.equals(readHEAD(repo))) {
-      logDebug("Ref {} not found", ref);
-      if (ref.startsWith(Constants.R_HEADS)) {
-        String n = ref.substring(Constants.R_HEADS.length());
-        reject(cmd, "branch " + n + " not found");
-      } else {
-        reject(cmd, ref + " not found");
-      }
-      return;
-    }
-
-    magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
-    magicBranch.ctl = projectControl.controlForRef(ref);
-    if (!magicBranch.ctl.canWrite()) {
-      reject(cmd, "project is read only");
-      return;
-    }
-
-    if (magicBranch.draft) {
-      if (!receiveConfig.allowDrafts) {
-        errors.put(Error.CODE_REVIEW, ref);
-        reject(cmd, "draft workflow is disabled");
-        return;
-      } else if (projectControl
-          .controlForRef(MagicBranch.NEW_DRAFT_CHANGE + ref)
-          .isBlocked(Permission.PUSH)) {
-        errors.put(Error.CODE_REVIEW, ref);
-        reject(cmd, "cannot upload drafts");
-        return;
-      }
-    }
-
-    if (!magicBranch.ctl.canUpload()) {
-      errors.put(Error.CODE_REVIEW, ref);
-      reject(cmd, "cannot upload review");
-      return;
-    }
-
-    if (magicBranch.draft && magicBranch.submit) {
-      reject(cmd, "cannot submit draft");
-      return;
-    }
-
-    if (magicBranch.submit
-        && !projectControl.controlForRef(MagicBranch.NEW_CHANGE + ref).canSubmit(true)) {
-      reject(cmd, "submit not allowed");
-      return;
-    }
-
-    RevWalk walk = rp.getRevWalk();
-    RevCommit tip;
-    try {
-      tip = walk.parseCommit(magicBranch.cmd.getNewId());
-      logDebug("Tip of push: {}", tip.name());
-    } catch (IOException ex) {
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", ex);
-      return;
-    }
-
-    String destBranch = magicBranch.dest.get();
-    try {
-      if (magicBranch.merged) {
-        if (magicBranch.draft) {
-          reject(cmd, "cannot be draft & merged");
-          return;
-        }
-        if (magicBranch.base != null) {
-          reject(cmd, "cannot use merged with base");
-          return;
-        }
-        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
-        if (branchTip == null) {
-          return; // readBranchTip already rejected cmd.
-        }
-        if (!walk.isMergedInto(tip, branchTip)) {
-          reject(cmd, "not merged into branch");
-          return;
-        }
-      }
-
-      // If tip is a merge commit, or the root commit or
-      // if %base or %merged was specified, ignore newChangeForAllNotInTarget.
-      if (tip.getParentCount() > 1
-          || magicBranch.base != null
-          || magicBranch.merged
-          || tip.getParentCount() == 0) {
-        logDebug("Forcing newChangeForAllNotInTarget = false");
-        newChangeForAllNotInTarget = false;
-      }
-
-      if (magicBranch.base != null) {
-        logDebug("Handling %base: {}", magicBranch.base);
-        magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
-        for (ObjectId id : magicBranch.base) {
-          try {
-            magicBranch.baseCommit.add(walk.parseCommit(id));
-          } catch (IncorrectObjectTypeException notCommit) {
-            reject(cmd, "base must be a commit");
-            return;
-          } catch (MissingObjectException e) {
-            reject(cmd, "base not found");
-            return;
-          } catch (IOException e) {
-            logWarn(String.format("Project %s cannot read %s", project.getName(), id.name()), e);
-            reject(cmd, "internal server error");
-            return;
-          }
-        }
-      } else if (newChangeForAllNotInTarget) {
-        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
-        if (branchTip == null) {
-          return; // readBranchTip already rejected cmd.
-        }
-        magicBranch.baseCommit = Collections.singletonList(branchTip);
-        logDebug("Set baseCommit = {}", magicBranch.baseCommit.get(0).name());
-      }
-    } catch (IOException ex) {
-      logWarn(
-          String.format("Error walking to %s in project %s", destBranch, project.getName()), ex);
-      reject(cmd, "internal server error");
-      return;
-    }
-
-    // Validate that the new commits are connected with the target
-    // branch.  If they aren't, we want to abort. We do this check by
-    // looking to see if we can compute a merge base between the new
-    // commits and the target branch head.
-    //
-    try {
-      Ref targetRef = rp.getAdvertisedRefs().get(magicBranch.ctl.getRefName());
-      if (targetRef == null || targetRef.getObjectId() == null) {
-        // The destination branch does not yet exist. Assume the
-        // history being sent for review will start it and thus
-        // is "connected" to the branch.
-        logDebug("Branch is unborn");
-        return;
-      }
-      RevCommit h = walk.parseCommit(targetRef.getObjectId());
-      logDebug("Current branch tip: {}", h.name());
-      RevFilter oldRevFilter = walk.getRevFilter();
-      try {
-        walk.reset();
-        walk.setRevFilter(RevFilter.MERGE_BASE);
-        walk.markStart(tip);
-        walk.markStart(h);
-        if (walk.next() == null) {
-          reject(magicBranch.cmd, "no common ancestry");
-        }
-      } finally {
-        walk.reset();
-        walk.setRevFilter(oldRevFilter);
-      }
-    } catch (IOException e) {
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
-    }
-  }
-
-  private static String readHEAD(Repository repo) {
-    try {
-      return repo.getFullBranch();
-    } catch (IOException e) {
-      log.error("Cannot read HEAD symref", e);
-      return null;
-    }
-  }
-
-  private RevCommit readBranchTip(ReceiveCommand cmd, Branch.NameKey branch) throws IOException {
-    Ref r = allRefs.get(branch.get());
-    if (r == null) {
-      reject(cmd, branch.get() + " not found");
-      return null;
-    }
-    return rp.getRevWalk().parseCommit(r.getObjectId());
-  }
-
-  private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
-    logDebug("Parsing replace command");
-    if (cmd.getType() != ReceiveCommand.Type.CREATE) {
-      reject(cmd, "invalid usage");
-      return;
-    }
-
-    RevCommit newCommit;
-    try {
-      newCommit = rp.getRevWalk().parseCommit(cmd.getNewId());
-      logDebug("Replacing with {}", newCommit);
-    } catch (IOException e) {
-      logError("Cannot parse " + cmd.getNewId().name() + " as commit", e);
-      reject(cmd, "invalid commit");
-      return;
-    }
-
-    Change changeEnt;
-    try {
-      changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId).getChange();
-    } catch (NoSuchChangeException e) {
-      logError("Change not found " + changeId, e);
-      reject(cmd, "change " + changeId + " not found");
-      return;
-    } catch (OrmException e) {
-      logError("Cannot lookup existing change " + changeId, e);
-      reject(cmd, "database error");
-      return;
-    }
-    if (!project.getNameKey().equals(changeEnt.getProject())) {
-      reject(cmd, "change " + changeId + " does not belong to project " + project.getName());
-      return;
-    }
-
-    logDebug("Replacing change {}", changeEnt.getId());
-    requestReplace(cmd, true, changeEnt, newCommit);
-  }
-
-  private boolean requestReplace(
-      ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
-    if (change.getStatus().isClosed()) {
-      reject(
-          cmd,
-          changeFormatter.changeClosed(
-              ChangeReportFormatter.Input.builder().setChange(change).build()));
-      return false;
-    }
-
-    ReplaceRequest req = new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto);
-    if (replaceByChange.containsKey(req.ontoChange)) {
-      reject(cmd, "duplicate request");
-      return false;
-    }
-    replaceByChange.put(req.ontoChange, req);
-    return true;
-  }
-
-  private void selectNewAndReplacedChangesFromMagicBranch() {
-    logDebug("Finding new and replaced changes");
-    newChanges = new ArrayList<>();
-
-    ListMultimap<ObjectId, Ref> existing = changeRefsById();
-    GroupCollector groupCollector =
-        GroupCollector.create(changeRefsById(), db, psUtil, notesFactory, project.getNameKey());
-
-    try {
-      RevCommit start = setUpWalkForSelectingChanges();
-      if (start == null) {
-        return;
-      }
-
-      LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
-      Set<Change.Key> newChangeIds = new HashSet<>();
-      int maxBatchChanges = receiveConfig.getEffectiveMaxBatchChangesLimit(user);
-      int total = 0;
-      int alreadyTracked = 0;
-      boolean rejectImplicitMerges =
-          start.getParentCount() == 1
-              && projectCache.get(project.getNameKey()).isRejectImplicitMerges()
-              // Don't worry about implicit merges when creating changes for
-              // already-merged commits; they're already in history, so it's too
-              // late.
-              && !magicBranch.merged;
-      Set<RevCommit> mergedParents;
-      if (rejectImplicitMerges) {
-        mergedParents = new HashSet<>();
-      } else {
-        mergedParents = null;
-      }
-
-      for (; ; ) {
-        RevCommit c = rp.getRevWalk().next();
-        if (c == null) {
-          break;
-        }
-        total++;
-        rp.getRevWalk().parseBody(c);
-        String name = c.name();
-        groupCollector.visit(c);
-        Collection<Ref> existingRefs = existing.get(c);
-
-        if (rejectImplicitMerges) {
-          Collections.addAll(mergedParents, c.getParents());
-          mergedParents.remove(c);
-        }
-
-        boolean commitAlreadyTracked = !existingRefs.isEmpty();
-        if (commitAlreadyTracked) {
-          alreadyTracked++;
-          // Corner cases where an existing commit might need a new group:
-          // A) Existing commit has a null group; wasn't assigned during schema
-          //    upgrade, or schema upgrade is performed on a running server.
-          // B) Let A<-B<-C, then:
-          //      1. Push A to refs/heads/master
-          //      2. Push B to refs/for/master
-          //      3. Force push A~ to refs/heads/master
-          //      4. Push C to refs/for/master.
-          //      B will be in existing so we aren't replacing the patch set. It
-          //      used to have its own group, but now needs to to be changed to
-          //      A's group.
-          // C) Commit is a PatchSet of a pre-existing change uploaded with a
-          //    different target branch.
-          for (Ref ref : existingRefs) {
-            updateGroups.add(new UpdateGroupsRequest(ref, c));
-          }
-          if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
-            continue;
-          }
-        }
-
-        List<String> idList = c.getFooterLines(CHANGE_ID);
-
-        String idStr = !idList.isEmpty() ? idList.get(idList.size() - 1).trim() : null;
-
-        if (idStr != null) {
-          pending.put(c, new ChangeLookup(c, new Change.Key(idStr)));
-        } else {
-          pending.put(c, new ChangeLookup(c));
-        }
-        int n = pending.size() + newChanges.size();
-        if (maxBatchChanges != 0 && n > maxBatchChanges) {
-          logDebug("{} changes exceeds limit of {}", n, maxBatchChanges);
-          reject(
-              magicBranch.cmd,
-              "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        if (commitAlreadyTracked) {
-          boolean changeExistsOnDestBranch = false;
-          for (ChangeData cd : pending.get(c).destChanges) {
-            if (cd.change().getDest().equals(magicBranch.dest)) {
-              changeExistsOnDestBranch = true;
-              break;
-            }
-          }
-          if (changeExistsOnDestBranch) {
-            continue;
-          }
-
-          logDebug("Creating new change for {} even though it is already tracked", name);
-        }
-
-        if (!validCommit(rp.getRevWalk(), magicBranch.ctl, magicBranch.cmd, c)) {
-          // Not a change the user can propose? Abort as early as possible.
-          newChanges = Collections.emptyList();
-          logDebug("Aborting early due to invalid commit");
-          return;
-        }
-
-        // Don't allow merges to be uploaded in commit chain via all-not-in-target
-        if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
-          reject(
-              magicBranch.cmd,
-              "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
-                  + "to override please set the base manually");
-          logDebug("Rejecting merge commit {} with newChangeForAllNotInTarget", name);
-          // TODO(dborowitz): Should we early return here?
-        }
-
-        if (idList.isEmpty()) {
-          newChanges.add(new CreateRequest(c, magicBranch.dest.get()));
-          continue;
-        }
-      }
-      logDebug(
-          "Finished initial RevWalk with {} commits total: {} already"
-              + " tracked, {} new changes with no Change-Id, and {} deferred"
-              + " lookups",
-          total,
-          alreadyTracked,
-          newChanges.size(),
-          pending.size());
-
-      if (rejectImplicitMerges) {
-        rejectImplicitMerges(mergedParents);
-      }
-
-      for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) {
-        ChangeLookup p = itr.next();
-        if (p.changeKey == null) {
-          continue;
-        }
-
-        if (newChangeIds.contains(p.changeKey)) {
-          logDebug("Multiple commits with Change-Id {}", p.changeKey);
-          reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        List<ChangeData> changes = p.destChanges;
-        if (changes.size() > 1) {
-          logDebug(
-              "Multiple changes in branch {} with Change-Id {}: {}",
-              magicBranch.dest,
-              p.changeKey,
-              changes.stream().map(cd -> cd.getId().toString()).collect(joining()));
-          // WTF, multiple changes in this branch have the same key?
-          // Since the commit is new, the user should recreate it with
-          // a different Change-Id. In practice, we should never see
-          // this error message as Change-Id should be unique per branch.
-          //
-          reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        if (changes.size() == 1) {
-          // Schedule as a replacement to this one matching change.
-          //
-
-          RevId currentPs = changes.get(0).currentPatchSet().getRevision();
-          // If Commit is already current PatchSet of target Change.
-          if (p.commit.name().equals(currentPs.get())) {
-            if (pending.size() == 1) {
-              // There are no commits left to check, all commits in pending were already
-              // current PatchSet of the corresponding target changes.
-              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-            } else {
-              // Commit is already current PatchSet.
-              // Remove from pending and try next commit.
-              itr.remove();
-              continue;
-            }
-          }
-          if (requestReplace(magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
-            continue;
-          }
-          newChanges = Collections.emptyList();
-          return;
-        }
-
-        if (changes.size() == 0) {
-          if (!isValidChangeId(p.changeKey.get())) {
-            reject(magicBranch.cmd, "invalid Change-Id");
-            newChanges = Collections.emptyList();
-            return;
-          }
-
-          // In case the change look up from the index failed,
-          // double check against the existing refs
-          if (foundInExistingRef(existing.get(p.commit))) {
-            if (pending.size() == 1) {
-              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
-              newChanges = Collections.emptyList();
-              return;
-            }
-            itr.remove();
-            continue;
-          }
-          newChangeIds.add(p.changeKey);
-        }
-        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
-      }
-      logDebug(
-          "Finished deferred lookups with {} updates and {} new changes",
-          replaceByChange.size(),
-          newChanges.size());
-    } catch (IOException e) {
-      // Should never happen, the core receive process would have
-      // identified the missing object earlier before we got control.
-      //
-      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", e);
-      newChanges = Collections.emptyList();
-      return;
-    } catch (OrmException e) {
-      logError("Cannot query database to locate prior changes", e);
-      reject(magicBranch.cmd, "database error");
-      newChanges = Collections.emptyList();
-      return;
-    }
-
-    if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
-      reject(magicBranch.cmd, "no new changes");
-      return;
-    }
-    if (!newChanges.isEmpty() && magicBranch.edit) {
-      reject(magicBranch.cmd, "edit is not supported for new changes");
-      return;
-    }
-
-    try {
-      SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
-      List<Integer> newIds = seq.nextChangeIds(newChanges.size());
-      for (int i = 0; i < newChanges.size(); i++) {
-        CreateRequest create = newChanges.get(i);
-        create.setChangeId(newIds.get(i));
-        batch.addCommand(create.cmd);
-        create.groups = ImmutableList.copyOf(groups.get(create.commit));
-      }
-      for (ReplaceRequest replace : replaceByChange.values()) {
-        replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
-      }
-      for (UpdateGroupsRequest update : updateGroups) {
-        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
-      }
-      logDebug("Finished updating groups from GroupCollector");
-    } catch (OrmException e) {
-      logError("Error collecting groups for changes", e);
-      reject(magicBranch.cmd, "internal server error");
-      return;
-    }
-  }
-
-  private boolean foundInExistingRef(Collection<Ref> existingRefs) throws OrmException {
-    for (Ref ref : existingRefs) {
-      ChangeNotes notes =
-          notesFactory.create(db, project.getNameKey(), Change.Id.fromRef(ref.getName()));
-      Change change = notes.getChange();
-      if (change.getDest().equals(magicBranch.dest)) {
-        logDebug("Found change {} from existing refs.", change.getKey());
-        // Reindex the change asynchronously, ignoring errors.
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
-        return true;
-      }
-    }
-    return false;
-  }
-
-  private RevCommit setUpWalkForSelectingChanges() throws IOException {
-    RevWalk rw = rp.getRevWalk();
-    RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
-
-    rw.reset();
-    rw.sort(RevSort.TOPO);
-    rw.sort(RevSort.REVERSE, true);
-    rp.getRevWalk().markStart(start);
-    if (magicBranch.baseCommit != null) {
-      markExplicitBasesUninteresting();
-    } else if (magicBranch.merged) {
-      logDebug("Marking parents of merged commit {} uninteresting", start.name());
-      for (RevCommit c : start.getParents()) {
-        rw.markUninteresting(c);
-      }
-    } else {
-      markHeadsAsUninteresting(rw, magicBranch.ctl != null ? magicBranch.ctl.getRefName() : null);
-    }
-    return start;
-  }
-
-  private void markExplicitBasesUninteresting() throws IOException {
-    logDebug("Marking {} base commits uninteresting", magicBranch.baseCommit.size());
-    for (RevCommit c : magicBranch.baseCommit) {
-      rp.getRevWalk().markUninteresting(c);
-    }
-    Ref targetRef = allRefs.get(magicBranch.ctl.getRefName());
-    if (targetRef != null) {
-      logDebug(
-          "Marking target ref {} ({}) uninteresting",
-          magicBranch.ctl.getRefName(),
-          targetRef.getObjectId().name());
-      rp.getRevWalk().markUninteresting(rp.getRevWalk().parseCommit(targetRef.getObjectId()));
-    }
-  }
-
-  private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
-    if (!mergedParents.isEmpty()) {
-      Ref targetRef = allRefs.get(magicBranch.ctl.getRefName());
-      if (targetRef != null) {
-        RevWalk rw = rp.getRevWalk();
-        RevCommit tip = rw.parseCommit(targetRef.getObjectId());
-        boolean containsImplicitMerges = true;
-        for (RevCommit p : mergedParents) {
-          containsImplicitMerges &= !rw.isMergedInto(p, tip);
-        }
-
-        if (containsImplicitMerges) {
-          rw.reset();
-          for (RevCommit p : mergedParents) {
-            rw.markStart(p);
-          }
-          rw.markUninteresting(tip);
-          RevCommit c;
-          while ((c = rw.next()) != null) {
-            rw.parseBody(c);
-            messages.add(
-                new CommitValidationMessage(
-                    "ERROR: Implicit Merge of "
-                        + c.abbreviate(7).name()
-                        + " "
-                        + c.getShortMessage(),
-                    false));
-          }
-          reject(magicBranch.cmd, "implicit merges detected");
-        }
-      }
-    }
-  }
-
-  private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) {
-    int i = 0;
-    for (Ref ref : allRefs.values()) {
-      if ((ref.getName().startsWith(R_HEADS) || ref.getName().equals(forRef))
-          && ref.getObjectId() != null) {
-        try {
-          rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
-          i++;
-        } catch (IOException e) {
-          logWarn(String.format("Invalid ref %s in %s", ref.getName(), project.getName()), e);
-        }
-      }
-    }
-    logDebug("Marked {} heads as uninteresting", i);
-  }
-
-  private static boolean isValidChangeId(String idStr) {
-    return idStr.matches("^I[0-9a-fA-F]{40}$") && !idStr.matches("^I00*$");
-  }
-
-  private class ChangeLookup {
-    final RevCommit commit;
-    final Change.Key changeKey;
-    final List<ChangeData> destChanges;
-
-    ChangeLookup(RevCommit c, Change.Key key) throws OrmException {
-      commit = c;
-      changeKey = key;
-      destChanges = queryProvider.get().byBranchKey(magicBranch.dest, key);
-    }
-
-    ChangeLookup(RevCommit c) throws OrmException {
-      commit = c;
-      destChanges = queryProvider.get().byBranchCommit(magicBranch.dest, c.getName());
-      changeKey = null;
-    }
-  }
-
-  private class CreateRequest {
-    final RevCommit commit;
-    private final String refName;
-
-    Change.Id changeId;
-    ReceiveCommand cmd;
-    ChangeInserter ins;
-    List<String> groups = ImmutableList.of();
-
-    Change change;
-
-    CreateRequest(RevCommit commit, String refName) {
-      this.commit = commit;
-      this.refName = refName;
-    }
-
-    private void setChangeId(int id) {
-      changeId = new Change.Id(id);
-      ins =
-          changeInserterFactory
-              .create(changeId, commit, refName)
-              .setTopic(magicBranch.topic)
-              // Changes already validated in validateNewCommits.
-              .setValidatePolicy(CommitValidators.Policy.NONE);
-
-      if (magicBranch.draft) {
-        ins.setDraft(magicBranch.draft);
-      } else if (magicBranch.merged) {
-        ins.setStatus(Change.Status.MERGED);
-      }
-      cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName());
-      if (rp.getPushCertificate() != null) {
-        ins.setPushCertificate(rp.getPushCertificate().toTextWithSignature());
-      }
-    }
-
-    private void addOps(BatchUpdate bu) throws RestApiException {
-      checkState(changeId != null, "must call setChangeId before addOps");
-      try {
-        RevWalk rw = rp.getRevWalk();
-        rw.parseBody(commit);
-        final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
-        Account.Id me = user.getAccountId();
-        List<FooterLine> footerLines = commit.getFooterLines();
-        MailRecipients recipients = new MailRecipients();
-        Map<String, Short> approvals = new HashMap<>();
-        checkNotNull(magicBranch);
-        recipients.add(magicBranch.getMailRecipients());
-        approvals = magicBranch.labels;
-        recipients.add(
-            getRecipientsFromFooters(db, accountResolver, magicBranch.draft, footerLines));
-        recipients.remove(me);
-        StringBuilder msg =
-            new StringBuilder(
-                ApprovalsUtil.renderMessageWithApprovals(
-                    psId.get(), approvals, Collections.<String, PatchSetApproval>emptyMap()));
-        msg.append('.');
-        if (!Strings.isNullOrEmpty(magicBranch.message)) {
-          msg.append("\n").append(magicBranch.message);
-        }
-
-        bu.insertChange(
-            ins.setReviewers(recipients.getReviewers())
-                .setExtraCC(recipients.getCcOnly())
-                .setApprovals(approvals)
-                .setMessage(msg.toString())
-                .setNotify(magicBranch.notify)
-                .setAccountsToNotify(magicBranch.getAccountsToNotify())
-                .setRequestScopePropagator(requestScopePropagator)
-                .setSendMail(true)
-                .setUpdateRef(false)
-                .setPatchSetDescription(magicBranch.message));
-        if (!magicBranch.hashtags.isEmpty()) {
-          bu.addOp(
-              changeId,
-              hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags)).setFireEvent(false));
-        }
-        if (!Strings.isNullOrEmpty(magicBranch.topic)) {
-          bu.addOp(
-              changeId,
-              new BatchUpdateOp() {
-                @Override
-                public boolean updateChange(ChangeContext ctx) {
-                  ctx.getUpdate(psId).setTopic(magicBranch.topic);
-                  return true;
-                }
-              });
-        }
-        bu.addOp(
-            changeId,
-            new BatchUpdateOp() {
-              @Override
-              public boolean updateChange(ChangeContext ctx) {
-                change = ctx.getChange();
-                return false;
-              }
-            });
-        bu.addOp(changeId, new ChangeProgressOp(newProgress));
-      } catch (Exception e) {
-        throw INSERT_EXCEPTION.apply(e);
-      }
-    }
-  }
-
-  private void submit(Collection<CreateRequest> create, Collection<ReplaceRequest> replace)
-      throws OrmException, RestApiException {
-    Map<ObjectId, Change> bySha = Maps.newHashMapWithExpectedSize(create.size() + replace.size());
-    for (CreateRequest r : create) {
-      checkNotNull(r.change, "cannot submit new change %s; op may not have run", r.changeId);
-      bySha.put(r.commit, r.change);
-    }
-    for (ReplaceRequest r : replace) {
-      bySha.put(r.newCommitId, r.notes.getChange());
-    }
-    Change tipChange = bySha.get(magicBranch.cmd.getNewId());
-    checkNotNull(
-        tipChange, "tip of push does not correspond to a change; found these changes: %s", bySha);
-    logDebug(
-        "Processing submit with tip change {} ({})", tipChange.getId(), magicBranch.cmd.getNewId());
-    try (MergeOp op = mergeOpProvider.get()) {
-      op.merge(db, tipChange, user, false, new SubmitInput(), false);
-    }
-  }
-
-  private void preparePatchSetsForReplace() {
-    try {
-      readChangesForReplace();
-      for (Iterator<ReplaceRequest> itr = replaceByChange.values().iterator(); itr.hasNext(); ) {
-        ReplaceRequest req = itr.next();
-        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.validate(false);
-          if (req.skip && req.cmd == null) {
-            itr.remove();
-          }
-        }
-      }
-    } catch (OrmException err) {
-      logError(
-          String.format(
-              "Cannot read database before replacement for project %s", project.getName()),
-          err);
-      for (ReplaceRequest req : replaceByChange.values()) {
-        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
-        }
-      }
-    } catch (IOException err) {
-      logError(
-          String.format(
-              "Cannot read repository before replacement for project %s", project.getName()),
-          err);
-      for (ReplaceRequest req : replaceByChange.values()) {
-        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
-          req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
-        }
-      }
-    }
-    logDebug("Read {} changes to replace", replaceByChange.size());
-
-    for (ReplaceRequest req : replaceByChange.values()) {
-      if (req.inputCommand.getResult() == NOT_ATTEMPTED && req.cmd != null) {
-        if (req.prev != null) {
-          batch.addCommand(req.prev);
-        }
-        batch.addCommand(req.cmd);
-      }
-    }
-
-    if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
-      // Cancel creations tied to refs/for/ or refs/drafts/ command.
-      for (ReplaceRequest req : replaceByChange.values()) {
-        if (req.inputCommand == magicBranch.cmd && req.cmd != null) {
-          req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
-        }
-      }
-      for (CreateRequest req : newChanges) {
-        req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
-      }
-    }
-  }
-
-  private void readChangesForReplace() throws OrmException {
-    Collection<ChangeNotes> allNotes =
-        notesFactory.create(
-            db, replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
-    for (ChangeNotes notes : allNotes) {
-      replaceByChange.get(notes.getChangeId()).notes = notes;
-    }
-  }
-
-  private class ReplaceRequest {
-    final Change.Id ontoChange;
-    final ObjectId newCommitId;
-    final ReceiveCommand inputCommand;
-    final boolean checkMergedInto;
-    ChangeNotes notes;
-    ChangeControl changeCtl;
-    BiMap<RevCommit, PatchSet.Id> revisions;
-    PatchSet.Id psId;
-    ReceiveCommand prev;
-    ReceiveCommand cmd;
-    PatchSetInfo info;
-    boolean skip;
-    private PatchSet.Id priorPatchSet;
-    List<String> groups = ImmutableList.of();
-    private ReplaceOp replaceOp;
-
-    ReplaceRequest(
-        Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
-      this.ontoChange = toChange;
-      this.newCommitId = newCommit.copy();
-      this.inputCommand = cmd;
-      this.checkMergedInto = checkMergedInto;
-
-      revisions = HashBiMap.create();
-      for (Ref ref : refs(toChange)) {
-        try {
-          revisions.forcePut(
-              rp.getRevWalk().parseCommit(ref.getObjectId()), PatchSet.Id.fromRef(ref.getName()));
-        } catch (IOException err) {
-          logWarn(
-              String.format(
-                  "Project %s contains invalid change ref %s", project.getName(), ref.getName()),
-              err);
-        }
-      }
-    }
-
-    /**
-     * Validate the new patch set commit for this change.
-     *
-     * <p><strong>Side effects:</strong>
-     *
-     * <ul>
-     *   <li>May add error or warning messages to the progress monitor
-     *   <li>Will reject {@code cmd} prior to returning false
-     *   <li>May reset {@code rp.getRevWalk()}; do not call in the middle of a walk.
-     * </ul>
-     *
-     * @param autoClose whether the caller intends to auto-close the change after adding a new patch
-     *     set.
-     * @return whether the new commit is valid
-     * @throws IOException
-     * @throws OrmException
-     */
-    boolean validate(boolean autoClose) throws IOException, OrmException {
-      if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
-        return false;
-      } else if (notes == null) {
-        reject(inputCommand, "change " + ontoChange + " not found");
-        return false;
-      }
-
-      priorPatchSet = notes.getChange().currentPatchSetId();
-      if (!revisions.containsValue(priorPatchSet)) {
-        reject(inputCommand, "change " + ontoChange + " missing revisions");
-        return false;
-      }
-
-      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
-
-      changeCtl = projectControl.controlFor(notes);
-      if (!changeCtl.canAddPatchSet(db)) {
-        String locked = ".";
-        if (changeCtl.isPatchSetLocked(db)) {
-          locked = ". Change is patch set locked.";
-        }
-        reject(inputCommand, "cannot add patch set to " + ontoChange + locked);
-        return false;
-      } else if (notes.getChange().getStatus().isClosed()) {
-        reject(inputCommand, "change " + ontoChange + " closed");
-        return false;
-      } else if (revisions.containsKey(newCommit)) {
-        reject(inputCommand, "commit already exists (in the change)");
-        return false;
-      }
-
-      for (Ref r : rp.getRepository().getRefDatabase().getRefs("refs/changes").values()) {
-        if (r.getObjectId().equals(newCommit)) {
-          reject(inputCommand, "commit already exists (in the project)");
-          return false;
-        }
-      }
-
-      for (RevCommit prior : revisions.keySet()) {
-        // Don't allow a change to directly depend upon itself. This is a
-        // very common error due to users making a new commit rather than
-        // amending when trying to address review comments.
-        if (rp.getRevWalk().isMergedInto(prior, newCommit)) {
-          reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
-          return false;
-        }
-      }
-
-      if (!validCommit(rp.getRevWalk(), changeCtl.getRefControl(), inputCommand, newCommit)) {
-        return false;
-      }
-      rp.getRevWalk().parseBody(priorCommit);
-
-      // Don't allow the same tree if the commit message is unmodified
-      // or no parents were updated (rebase), else warn that only part
-      // of the commit was modified.
-      if (newCommit.getTree().equals(priorCommit.getTree())) {
-        boolean messageEq = eq(newCommit.getFullMessage(), priorCommit.getFullMessage());
-        boolean parentsEq = parentsEqual(newCommit, priorCommit);
-        boolean authorEq = authorEqual(newCommit, priorCommit);
-        ObjectReader reader = rp.getRevWalk().getObjectReader();
-
-        if (messageEq && parentsEq && authorEq && !autoClose) {
-          addMessage(
-              String.format(
-                  "(W) No changes between prior commit %s and new commit %s",
-                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
-        } else {
-          StringBuilder msg = new StringBuilder();
-          msg.append("(I) ");
-          msg.append(reader.abbreviate(newCommit).name());
-          msg.append(":");
-          msg.append(" no files changed");
-          if (!authorEq) {
-            msg.append(", author changed");
-          }
-          if (!messageEq) {
-            msg.append(", message updated");
-          }
-          if (!parentsEq) {
-            msg.append(", was rebased");
-          }
-          addMessage(msg.toString());
-        }
-      }
-
-      if (magicBranch != null && magicBranch.edit) {
-        return newEdit();
-      }
-
-      newPatchSet();
-      return true;
-    }
-
-    private boolean newEdit() {
-      psId = notes.getChange().currentPatchSetId();
-      Optional<ChangeEdit> edit = null;
-
-      try {
-        edit = editUtil.byChange(changeCtl);
-      } catch (AuthException | IOException e) {
-        logError("Cannot retrieve edit", e);
-        return false;
-      }
-
-      if (edit.isPresent()) {
-        if (edit.get().getBasePatchSet().getId().equals(psId)) {
-          // replace edit
-          cmd =
-              new ReceiveCommand(
-                  edit.get().getRef().getObjectId(), newCommitId, edit.get().getRefName());
-        } else {
-          // delete old edit ref on rebase
-          prev =
-              new ReceiveCommand(
-                  edit.get().getRef().getObjectId(), ObjectId.zeroId(), edit.get().getRefName());
-          createEditCommand();
-        }
-      } else {
-        createEditCommand();
-      }
-
-      return true;
-    }
-
-    private void createEditCommand() {
-      // create new edit
-      cmd =
-          new ReceiveCommand(
-              ObjectId.zeroId(),
-              newCommitId,
-              RefNames.refsEdit(user.getAccountId(), notes.getChangeId(), psId));
-    }
-
-    private void newPatchSet() throws IOException {
-      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
-      psId = ChangeUtil.nextPatchSetId(allRefs, notes.getChange().currentPatchSetId());
-      info = patchSetInfoFactory.get(rp.getRevWalk(), newCommit, psId);
-      cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
-    }
-
-    void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
-      if (cmd.getResult() == NOT_ATTEMPTED) {
-        // TODO(dborowitz): When does this happen? Only when an edit ref is
-        // involved?
-        cmd.execute(rp);
-      }
-      if (magicBranch != null && magicBranch.edit) {
-        bu.addOp(
-            notes.getChangeId(),
-            new BatchUpdateOp() {
-              @Override
-              public boolean updateChange(ChangeContext ctx) throws Exception {
-                // return pseudo dirty state to trigger reindexing
-                return true;
-              }
-            });
-        return;
-      }
-      RevWalk rw = rp.getRevWalk();
-      // TODO(dborowitz): Move to ReplaceOp#updateRepo.
-      RevCommit newCommit = rw.parseCommit(newCommitId);
-      rw.parseBody(newCommit);
-
-      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
-      replaceOp =
-          replaceOpFactory
-              .create(
-                  projectControl,
-                  notes.getChange().getDest(),
-                  checkMergedInto,
-                  priorPatchSet,
-                  priorCommit,
-                  psId,
-                  newCommit,
-                  info,
-                  groups,
-                  magicBranch,
-                  rp.getPushCertificate())
-              .setRequestScopePropagator(requestScopePropagator)
-              .setUpdateRef(false);
-      bu.addOp(notes.getChangeId(), replaceOp);
-      if (progress != null) {
-        bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
-      }
-    }
-
-    void insertPatchSetWithoutBatchUpdate() throws IOException, UpdateException, RestApiException {
-      try (BatchUpdate bu =
-              batchUpdateFactory.create(
-                  db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
-          ObjectInserter ins = repo.newObjectInserter()) {
-        bu.setRepository(repo, rp.getRevWalk(), ins);
-        bu.setRequestId(receiveId);
-        addOps(bu, replaceProgress);
-        bu.execute();
-      }
-    }
-
-    String getRejectMessage() {
-      return replaceOp != null ? replaceOp.getRejectMessage() : null;
-    }
-  }
-
-  private class UpdateGroupsRequest {
-    private final PatchSet.Id psId;
-    private final RevCommit commit;
-    List<String> groups = ImmutableList.of();
-
-    UpdateGroupsRequest(Ref ref, RevCommit commit) {
-      this.psId = checkNotNull(PatchSet.Id.fromRef(ref.getName()));
-      this.commit = commit;
-    }
-
-    private void addOps(BatchUpdate bu) {
-      bu.addOp(
-          psId.getParentKey(),
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) throws OrmException {
-              PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
-              List<String> oldGroups = ps.getGroups();
-              if (oldGroups == null) {
-                if (groups == null) {
-                  return false;
-                }
-              } else if (sameGroups(oldGroups, groups)) {
-                return false;
-              }
-              psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, groups);
-              return true;
-            }
-          });
-    }
-
-    private boolean sameGroups(List<String> a, List<String> b) {
-      return Sets.newHashSet(a).equals(Sets.newHashSet(b));
-    }
-  }
-
-  private List<Ref> refs(Change.Id changeId) {
-    return refsByChange().get(changeId);
-  }
-
-  private void initChangeRefMaps() {
-    if (refsByChange == null) {
-      int estRefsPerChange = 4;
-      refsById = MultimapBuilder.hashKeys().arrayListValues().build();
-      refsByChange =
-          MultimapBuilder.hashKeys(allRefs.size() / estRefsPerChange)
-              .arrayListValues(estRefsPerChange)
-              .build();
-      for (Ref ref : allRefs.values()) {
-        ObjectId obj = ref.getObjectId();
-        if (obj != null) {
-          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-          if (psId != null) {
-            refsById.put(obj, ref);
-            refsByChange.put(psId.getParentKey(), ref);
-          }
-        }
-      }
-    }
-  }
-
-  private ListMultimap<Change.Id, Ref> refsByChange() {
-    initChangeRefMaps();
-    return refsByChange;
-  }
-
-  private ListMultimap<ObjectId, Ref> changeRefsById() {
-    initChangeRefMaps();
-    return refsById;
-  }
-
-  static boolean parentsEqual(RevCommit a, RevCommit b) {
-    if (a.getParentCount() != b.getParentCount()) {
-      return false;
-    }
-    for (int i = 0; i < a.getParentCount(); i++) {
-      if (!a.getParent(i).equals(b.getParent(i))) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  static boolean authorEqual(RevCommit a, RevCommit b) {
-    PersonIdent aAuthor = a.getAuthorIdent();
-    PersonIdent bAuthor = b.getAuthorIdent();
-
-    if (aAuthor == null && bAuthor == null) {
-      return true;
-    } else if (aAuthor == null || bAuthor == null) {
-      return false;
-    }
-
-    return eq(aAuthor.getName(), bAuthor.getName())
-        && eq(aAuthor.getEmailAddress(), bAuthor.getEmailAddress());
-  }
-
-  static boolean eq(String a, String b) {
-    if (a == null && b == null) {
-      return true;
-    } else if (a == null || b == null) {
-      return false;
-    } else {
-      return a.equals(b);
-    }
-  }
-
-  private boolean validRefOperation(ReceiveCommand cmd) {
-    RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
-
-    try {
-      messages.addAll(refValidators.validateForRefOperation());
-    } catch (RefOperationValidationException e) {
-      messages.addAll(Lists.newArrayList(e.getMessages()));
-      reject(cmd, e.getMessage());
-      return false;
-    }
-
-    return true;
-  }
-
-  private void validateNewCommits(RefControl ctl, ReceiveCommand cmd) {
-    if (ctl.canForgeAuthor()
-        && ctl.canForgeCommitter()
-        && ctl.canForgeGerritServerIdentity()
-        && ctl.canUploadMerges()
-        && !projectControl.getProjectState().isUseSignedOffBy()
-        && Iterables.isEmpty(rejectCommits)
-        && !RefNames.REFS_CONFIG.equals(ctl.getRefName())
-        && !(MagicBranch.isMagicBranch(cmd.getRefName())
-            || NEW_PATCHSET.matcher(cmd.getRefName()).matches())) {
-      logDebug("Short-circuiting new commit validation");
-      return;
-    }
-
-    boolean defaultName = Strings.isNullOrEmpty(user.getAccount().getFullName());
-    RevWalk walk = rp.getRevWalk();
-    walk.reset();
-    walk.sort(RevSort.NONE);
-    try {
-      RevObject parsedObject = walk.parseAny(cmd.getNewId());
-      if (!(parsedObject instanceof RevCommit)) {
-        return;
-      }
-      ListMultimap<ObjectId, Ref> existing = changeRefsById();
-      walk.markStart((RevCommit) parsedObject);
-      markHeadsAsUninteresting(walk, cmd.getRefName());
-      int i = 0;
-      for (RevCommit c; (c = walk.next()) != null; ) {
-        i++;
-        if (existing.keySet().contains(c)) {
-          continue;
-        } else if (!validCommit(walk, ctl, cmd, c)) {
-          break;
-        }
-
-        if (defaultName && user.hasEmailAddress(c.getCommitterIdent().getEmailAddress())) {
-          try {
-            Account a = db.accounts().get(user.getAccountId());
-            if (a != null && Strings.isNullOrEmpty(a.getFullName())) {
-              a.setFullName(c.getCommitterIdent().getName());
-              db.accounts().update(Collections.singleton(a));
-              user.getAccount().setFullName(a.getFullName());
-              accountCache.evict(a.getId());
-            }
-          } catch (OrmException e) {
-            logWarn("Cannot default full_name", e);
-          } finally {
-            defaultName = false;
-          }
-        }
-      }
-      logDebug("Validated {} new commits", i);
-    } catch (IOException err) {
-      cmd.setResult(REJECTED_MISSING_OBJECT);
-      logError("Invalid pack upload; one or more objects weren't sent", err);
-    }
-  }
-
-  private boolean validCommit(RevWalk rw, RefControl ctl, ReceiveCommand cmd, ObjectId id)
-      throws IOException {
-
-    if (validCommits.contains(id)) {
-      return true;
-    }
-
-    RevCommit c = rw.parseCommit(id);
-    rw.parseBody(c);
-    CommitReceivedEvent receiveEvent =
-        new CommitReceivedEvent(cmd, project, ctl.getRefName(), c, user);
-
-    CommitValidators.Policy policy;
-    if (magicBranch != null
-        && cmd.getRefName().equals(magicBranch.cmd.getRefName())
-        && magicBranch.merged) {
-      policy = CommitValidators.Policy.MERGED;
-    } else {
-      policy = CommitValidators.Policy.RECEIVE_COMMITS;
-    }
-
-    try {
-      messages.addAll(
-          commitValidatorsFactory.create(policy, ctl, sshInfo, repo).validate(receiveEvent));
-    } catch (CommitValidationException e) {
-      logDebug("Commit validation failed on {}", c.name());
-      messages.addAll(e.getMessages());
-      reject(cmd, e.getMessage());
-      return false;
-    }
-    validCommits.add(c.copy());
-    return true;
-  }
-
-  private void autoCloseChanges(final ReceiveCommand cmd) {
-    logDebug("Starting auto-closing of changes");
-    String refName = cmd.getRefName();
-    checkState(
-        !MagicBranch.isMagicBranch(refName),
-        "shouldn't be auto-closing changes on magic branch %s",
-        refName);
-    RevWalk rw = rp.getRevWalk();
-    // TODO(dborowitz): Combine this BatchUpdate with the main one in
-    // insertChangesAndPatchSets.
-    try (BatchUpdate bu =
-            batchUpdateFactory.create(
-                db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
-        ObjectInserter ins = repo.newObjectInserter()) {
-      bu.setRepository(repo, rp.getRevWalk(), ins).updateChangesInParallel();
-      bu.setRequestId(receiveId);
-      // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
-
-      RevCommit newTip = rw.parseCommit(cmd.getNewId());
-      Branch.NameKey branch = new Branch.NameKey(project.getNameKey(), refName);
-
-      rw.reset();
-      rw.markStart(newTip);
-      if (!ObjectId.zeroId().equals(cmd.getOldId())) {
-        rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
-      }
-
-      ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
-      Map<Change.Key, ChangeNotes> byKey = null;
-      List<ReplaceRequest> replaceAndClose = new ArrayList<>();
-
-      int existingPatchSets = 0;
-      int newPatchSets = 0;
-      COMMIT:
-      for (RevCommit c; (c = rw.next()) != null; ) {
-        rw.parseBody(c);
-
-        for (Ref ref : byCommit.get(c.copy())) {
-          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
-          Optional<ChangeData> cd = byLegacyId(psId.getParentKey());
-          if (cd.isPresent() && cd.get().change().getDest().equals(branch)) {
-            existingPatchSets++;
-            bu.addOp(
-                psId.getParentKey(),
-                mergedByPushOpFactory.create(requestScopePropagator, psId, refName));
-            continue COMMIT;
-          }
-        }
-
-        for (String changeId : c.getFooterLines(CHANGE_ID)) {
-          if (byKey == null) {
-            byKey = openChangesByKeyByBranch(branch);
-          }
-
-          ChangeNotes onto = byKey.get(new Change.Key(changeId.trim()));
-          if (onto != null) {
-            newPatchSets++;
-            // Hold onto this until we're done with the walk, as the call to
-            // req.validate below calls isMergedInto which resets the walk.
-            ReplaceRequest req = new ReplaceRequest(onto.getChangeId(), c, cmd, false);
-            req.notes = onto;
-            replaceAndClose.add(req);
-            continue COMMIT;
-          }
-        }
-      }
-
-      for (final ReplaceRequest req : replaceAndClose) {
-        Change.Id id = req.notes.getChangeId();
-        if (!req.validate(true)) {
-          logDebug("Not closing {} because validation failed", id);
-          continue;
-        }
-        req.addOps(bu, null);
-        bu.addOp(
-            id,
-            mergedByPushOpFactory
-                .create(requestScopePropagator, req.psId, refName)
-                .setPatchSetProvider(
-                    new Provider<PatchSet>() {
-                      @Override
-                      public PatchSet get() {
-                        return req.replaceOp.getPatchSet();
-                      }
-                    }));
-        bu.addOp(id, new ChangeProgressOp(closeProgress));
-      }
-
-      logDebug(
-          "Auto-closing {} changes with existing patch sets and {} with new patch sets",
-          existingPatchSets,
-          newPatchSets);
-      bu.execute();
-    } catch (RestApiException e) {
-      logError("Can't insert patchset", e);
-    } catch (IOException | OrmException | UpdateException e) {
-      logError("Can't scan for changes to close", e);
-    }
-  }
-
-  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(Branch.NameKey branch)
-      throws OrmException {
-    Map<Change.Key, ChangeNotes> r = new HashMap<>();
-    for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
-      try {
-        r.put(cd.change().getKey(), cd.notes());
-      } catch (NoSuchChangeException e) {
-        // Ignore deleted change
-      }
-    }
-    return r;
-  }
-
-  private Optional<ChangeData> byLegacyId(Change.Id legacyId) throws OrmException {
-    List<ChangeData> res = queryProvider.get().byLegacyChangeId(legacyId);
-    if (res.isEmpty()) {
-      return Optional.empty();
-    }
-    return Optional.of(res.get(0));
-  }
-
-  private void reject(ReceiveCommand cmd, String why) {
-    cmd.setResult(REJECTED_OTHER_REASON, why);
-    commandProgress.update(1);
-  }
-
-  private static boolean isHead(ReceiveCommand cmd) {
-    return cmd.getRefName().startsWith(Constants.R_HEADS);
-  }
-
-  private static boolean isConfig(ReceiveCommand cmd) {
-    return cmd.getRefName().equals(RefNames.REFS_CONFIG);
-  }
-
-  private void logDebug(String msg, Object... args) {
-    if (log.isDebugEnabled()) {
-      log.debug(receiveId + msg, args);
-    }
-  }
-
-  private void logWarn(String msg, Throwable t) {
-    if (log.isWarnEnabled()) {
-      if (t != null) {
-        log.warn(receiveId + msg, t);
-      } else {
-        log.warn(receiveId + msg);
-      }
-    }
-  }
-
-  private void logWarn(String msg) {
-    logWarn(msg, null);
-  }
-
-  private void logError(String msg, Throwable t) {
-    if (log.isErrorEnabled()) {
-      if (t != null) {
-        log.error(receiveId + msg, t);
-      } else {
-        log.error(receiveId + msg);
-      }
-    }
-  }
-
-  private void logError(String msg) {
-    logError(msg, null);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
deleted file mode 100644
index 2316782..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsAdvertiseRefsHook.java
+++ /dev/null
@@ -1,151 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static org.eclipse.jgit.lib.RefDatabase.ALL;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.annotations.VisibleForTesting;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Maps;
-import com.google.common.collect.Sets;
-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.index.change.ChangeField;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.util.MagicBranch;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.Collections;
-import java.util.Map;
-import java.util.Set;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.transport.AdvertiseRefsHook;
-import org.eclipse.jgit.transport.BaseReceivePack;
-import org.eclipse.jgit.transport.ServiceMayNotContinueException;
-import org.eclipse.jgit.transport.UploadPack;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-/** Exposes only the non refs/changes/ reference names. */
-public class ReceiveCommitsAdvertiseRefsHook implements AdvertiseRefsHook {
-  private static final Logger log = LoggerFactory.getLogger(ReceiveCommitsAdvertiseRefsHook.class);
-
-  @VisibleForTesting
-  @AutoValue
-  public abstract static class Result {
-    public abstract Map<String, Ref> allRefs();
-
-    public abstract Set<ObjectId> additionalHaves();
-  }
-
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Project.NameKey projectName;
-
-  public ReceiveCommitsAdvertiseRefsHook(
-      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName) {
-    this.queryProvider = queryProvider;
-    this.projectName = projectName;
-  }
-
-  @Override
-  public void advertiseRefs(UploadPack us) {
-    throw new UnsupportedOperationException(
-        "ReceiveCommitsAdvertiseRefsHook cannot be used for UploadPack");
-  }
-
-  @Override
-  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
-    Map<String, Ref> oldRefs = rp.getAdvertisedRefs();
-    if (oldRefs == null) {
-      try {
-        oldRefs = rp.getRepository().getRefDatabase().getRefs(ALL);
-      } catch (ServiceMayNotContinueException e) {
-        throw e;
-      } catch (IOException e) {
-        ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
-        ex.initCause(e);
-        throw ex;
-      }
-    }
-    Result r = advertiseRefs(oldRefs);
-    rp.setAdvertisedRefs(r.allRefs(), r.additionalHaves());
-  }
-
-  @VisibleForTesting
-  public Result advertiseRefs(Map<String, Ref> oldRefs) {
-    Map<String, Ref> r = Maps.newHashMapWithExpectedSize(oldRefs.size());
-    Set<ObjectId> allPatchSets = Sets.newHashSetWithExpectedSize(oldRefs.size());
-    for (Map.Entry<String, Ref> e : oldRefs.entrySet()) {
-      String name = e.getKey();
-      if (!skip(name)) {
-        r.put(name, e.getValue());
-      }
-      if (name.startsWith(RefNames.REFS_CHANGES)) {
-        allPatchSets.add(e.getValue().getObjectId());
-      }
-    }
-    return new AutoValue_ReceiveCommitsAdvertiseRefsHook_Result(
-        r, advertiseOpenChanges(allPatchSets));
-  }
-
-  private static final ImmutableSet<String> OPEN_CHANGES_FIELDS =
-      ImmutableSet.of(
-          // Required for ChangeIsVisibleToPrdicate.
-          ChangeField.CHANGE.getName(),
-          ChangeField.REVIEWER.getName(),
-          // Required during advertiseOpenChanges.
-          ChangeField.PATCH_SET.getName());
-
-  private Set<ObjectId> advertiseOpenChanges(Set<ObjectId> allPatchSets) {
-    // Advertise some recent open changes, in case a commit is based on one.
-    int limit = 32;
-    try {
-      Set<ObjectId> r = Sets.newHashSetWithExpectedSize(limit);
-      for (ChangeData cd :
-          queryProvider
-              .get()
-              .setRequestedFields(OPEN_CHANGES_FIELDS)
-              .enforceVisibility(true)
-              .setLimit(limit)
-              .byProjectOpen(projectName)) {
-        PatchSet ps = cd.currentPatchSet();
-        if (ps != null) {
-          ObjectId id = ObjectId.fromString(ps.getRevision().get());
-          // Ensure we actually observed a patch set ref pointing to this
-          // object, in case the database is out of sync with the repo and the
-          // object doesn't actually exist.
-          if (allPatchSets.contains(id)) {
-            r.add(id);
-          }
-        }
-      }
-      return r;
-    } catch (OrmException err) {
-      log.error("Cannot list open changes of " + projectName, err);
-      return Collections.emptySet();
-    }
-  }
-
-  private static boolean skip(String name) {
-    return name.startsWith(RefNames.REFS_CHANGES)
-        || name.startsWith(RefNames.REFS_CACHE_AUTOMERGE)
-        || MagicBranch.isMagicBranch(name);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutor.java
deleted file mode 100644
index ddf24cde..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutor.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static java.lang.annotation.RetentionPolicy.RUNTIME;
-
-import com.google.inject.BindingAnnotation;
-import java.lang.annotation.Retention;
-
-/** Marker on the global {@link WorkQueue.Executor} used by {@link ReceiveCommits}. */
-@Retention(RUNTIME)
-@BindingAnnotation
-public @interface ReceiveCommitsExecutor {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutorModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutorModule.java
deleted file mode 100644
index 7c3dae5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveCommitsExecutorModule.java
+++ /dev/null
@@ -1,78 +0,0 @@
-// Copyright (C) 2012 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.common.util.concurrent.MoreExecutors;
-import com.google.common.util.concurrent.ThreadFactoryBuilder;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.update.ChangeUpdateExecutor;
-import com.google.inject.AbstractModule;
-import com.google.inject.Provides;
-import com.google.inject.Singleton;
-import java.util.concurrent.ArrayBlockingQueue;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.ThreadPoolExecutor;
-import java.util.concurrent.TimeUnit;
-import org.eclipse.jgit.lib.Config;
-
-/** Module providing the {@link ReceiveCommitsExecutor}. */
-public class ReceiveCommitsExecutorModule extends AbstractModule {
-  @Override
-  protected void configure() {}
-
-  @Provides
-  @Singleton
-  @ReceiveCommitsExecutor
-  public WorkQueue.Executor createReceiveCommitsExecutor(
-      @GerritServerConfig Config config, WorkQueue queues) {
-    int poolSize =
-        config.getInt(
-            "receive", null, "threadPoolSize", Runtime.getRuntime().availableProcessors());
-    return queues.createQueue(poolSize, "ReceiveCommits", true);
-  }
-
-  @Provides
-  @Singleton
-  @SendEmailExecutor
-  public ExecutorService createSendEmailExecutor(
-      @GerritServerConfig Config config, WorkQueue queues) {
-    int poolSize = config.getInt("sendemail", null, "threadPoolSize", 1);
-    if (poolSize == 0) {
-      return MoreExecutors.newDirectExecutorService();
-    }
-    return queues.createQueue(poolSize, "SendEmail", true);
-  }
-
-  @Provides
-  @Singleton
-  @ChangeUpdateExecutor
-  public ListeningExecutorService createChangeUpdateExecutor(@GerritServerConfig Config config) {
-    int poolSize = config.getInt("receive", null, "changeUpdateThreads", 1);
-    if (poolSize <= 1) {
-      return MoreExecutors.newDirectExecutorService();
-    }
-    return MoreExecutors.listeningDecorator(
-        MoreExecutors.getExitingExecutorService(
-            new ThreadPoolExecutor(
-                1,
-                poolSize,
-                10,
-                TimeUnit.MINUTES,
-                new ArrayBlockingQueue<Runnable>(poolSize),
-                new ThreadFactoryBuilder().setNameFormat("ChangeUpdate-%d").setDaemon(true).build(),
-                new ThreadPoolExecutor.CallerRunsPolicy())));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
deleted file mode 100644
index 063f395..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReceiveConfig.java
+++ /dev/null
@@ -1,47 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.git;
-
-import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
-
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import org.eclipse.jgit.lib.Config;
-
-@Singleton
-class ReceiveConfig {
-  final boolean checkMagicRefs;
-  final boolean checkReferencedObjectsAreReachable;
-  final boolean allowDrafts;
-  private final int systemMaxBatchChanges;
-
-  @Inject
-  ReceiveConfig(@GerritServerConfig Config config) {
-    checkMagicRefs = config.getBoolean("receive", null, "checkMagicRefs", true);
-    checkReferencedObjectsAreReachable =
-        config.getBoolean("receive", null, "checkReferencedObjectsAreReachable", true);
-    allowDrafts = config.getBoolean("change", null, "allowDrafts", true);
-    systemMaxBatchChanges = config.getInt("receive", "maxBatchChanges", 0);
-  }
-
-  public int getEffectiveMaxBatchChangesLimit(CurrentUser user) {
-    if (user.getCapabilities().canPerform(BATCH_CHANGES_LIMIT)) {
-      return user.getCapabilities().getRange(BATCH_CHANGES_LIMIT).getMax();
-    }
-    return systemMaxBatchChanges;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
deleted file mode 100644
index 50a14e5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
+++ /dev/null
@@ -1,510 +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.server.git;
-
-import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
-import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
-import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
-import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
-import com.google.gerrit.extensions.client.ChangeKind;
-import com.google.gerrit.extensions.restapi.RestApiException;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
-import com.google.gerrit.reviewdb.client.PatchSetInfo;
-import com.google.gerrit.server.ApprovalCopier;
-import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.change.ChangeKindCache;
-import com.google.gerrit.server.extensions.events.CommentAdded;
-import com.google.gerrit.server.extensions.events.RevisionCreated;
-import com.google.gerrit.server.git.ReceiveCommits.MagicBranchInput;
-import com.google.gerrit.server.mail.MailUtil.MailRecipients;
-import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
-import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.update.BatchUpdateOp;
-import com.google.gerrit.server.update.ChangeContext;
-import com.google.gerrit.server.update.Context;
-import com.google.gerrit.server.update.RepoContext;
-import com.google.gerrit.server.util.RequestScopePropagator;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
-import com.google.inject.util.Providers;
-import java.io.IOException;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import java.util.Set;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Future;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.transport.PushCertificate;
-import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ReplaceOp implements BatchUpdateOp {
-  public interface Factory {
-    ReplaceOp create(
-        ProjectControl projectControl,
-        Branch.NameKey dest,
-        boolean checkMergedInto,
-        @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
-        @Assisted("priorCommit") RevCommit priorCommit,
-        @Assisted("patchSetId") PatchSet.Id patchSetId,
-        @Assisted("commit") RevCommit commit,
-        PatchSetInfo info,
-        List<String> groups,
-        @Nullable MagicBranchInput magicBranch,
-        @Nullable PushCertificate pushCertificate);
-  }
-
-  private static final Logger log = LoggerFactory.getLogger(ReplaceOp.class);
-
-  private static final String CHANGE_IS_CLOSED = "change is closed";
-
-  private final AccountResolver accountResolver;
-  private final ApprovalCopier approvalCopier;
-  private final ApprovalsUtil approvalsUtil;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private final ChangeData.Factory changeDataFactory;
-  private final ChangeKindCache changeKindCache;
-  private final ChangeMessagesUtil cmUtil;
-  private final ExecutorService sendEmailExecutor;
-  private final RevisionCreated revisionCreated;
-  private final CommentAdded commentAdded;
-  private final MergedByPushOp.Factory mergedByPushOpFactory;
-  private final PatchSetUtil psUtil;
-  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
-
-  private final ProjectControl projectControl;
-  private final Branch.NameKey dest;
-  private final boolean checkMergedInto;
-  private final PatchSet.Id priorPatchSetId;
-  private final RevCommit priorCommit;
-  private final PatchSet.Id patchSetId;
-  private final RevCommit commit;
-  private final PatchSetInfo info;
-  private final MagicBranchInput magicBranch;
-  private final PushCertificate pushCertificate;
-  private List<String> groups = ImmutableList.of();
-
-  private final Map<String, Short> approvals = new HashMap<>();
-  private final MailRecipients recipients = new MailRecipients();
-  private Change change;
-  private PatchSet newPatchSet;
-  private ChangeKind changeKind;
-  private ChangeMessage msg;
-  private String rejectMessage;
-  private MergedByPushOp mergedByPushOp;
-  private RequestScopePropagator requestScopePropagator;
-  private boolean updateRef;
-
-  @AssistedInject
-  ReplaceOp(
-      AccountResolver accountResolver,
-      ApprovalCopier approvalCopier,
-      ApprovalsUtil approvalsUtil,
-      ChangeControl.GenericFactory changeControlFactory,
-      ChangeData.Factory changeDataFactory,
-      ChangeKindCache changeKindCache,
-      ChangeMessagesUtil cmUtil,
-      RevisionCreated revisionCreated,
-      CommentAdded commentAdded,
-      MergedByPushOp.Factory mergedByPushOpFactory,
-      PatchSetUtil psUtil,
-      ReplacePatchSetSender.Factory replacePatchSetFactory,
-      @SendEmailExecutor ExecutorService sendEmailExecutor,
-      @Assisted ProjectControl projectControl,
-      @Assisted Branch.NameKey dest,
-      @Assisted boolean checkMergedInto,
-      @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
-      @Assisted("priorCommit") RevCommit priorCommit,
-      @Assisted("patchSetId") PatchSet.Id patchSetId,
-      @Assisted("commit") RevCommit commit,
-      @Assisted PatchSetInfo info,
-      @Assisted List<String> groups,
-      @Assisted @Nullable MagicBranchInput magicBranch,
-      @Assisted @Nullable PushCertificate pushCertificate) {
-    this.accountResolver = accountResolver;
-    this.approvalCopier = approvalCopier;
-    this.approvalsUtil = approvalsUtil;
-    this.changeControlFactory = changeControlFactory;
-    this.changeDataFactory = changeDataFactory;
-    this.changeKindCache = changeKindCache;
-    this.cmUtil = cmUtil;
-    this.revisionCreated = revisionCreated;
-    this.commentAdded = commentAdded;
-    this.mergedByPushOpFactory = mergedByPushOpFactory;
-    this.psUtil = psUtil;
-    this.replacePatchSetFactory = replacePatchSetFactory;
-    this.sendEmailExecutor = sendEmailExecutor;
-
-    this.projectControl = projectControl;
-    this.dest = dest;
-    this.checkMergedInto = checkMergedInto;
-    this.priorPatchSetId = priorPatchSetId;
-    this.priorCommit = priorCommit;
-    this.patchSetId = patchSetId;
-    this.commit = commit;
-    this.info = info;
-    this.groups = groups;
-    this.magicBranch = magicBranch;
-    this.pushCertificate = pushCertificate;
-    this.updateRef = true;
-  }
-
-  @Override
-  public void updateRepo(RepoContext ctx) throws Exception {
-    changeKind =
-        changeKindCache.getChangeKind(
-            projectControl.getProject().getNameKey(), ctx.getRepository(), priorCommit, commit);
-
-    if (checkMergedInto) {
-      Ref mergedInto = findMergedInto(ctx, dest.get(), commit);
-      if (mergedInto != null) {
-        mergedByPushOp =
-            mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto.getName());
-      }
-    }
-
-    if (updateRef) {
-      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), commit, patchSetId.toRefName()));
-    }
-  }
-
-  @Override
-  public boolean updateChange(ChangeContext ctx)
-      throws RestApiException, OrmException, IOException {
-    change = ctx.getChange();
-    if (change == null || change.getStatus().isClosed()) {
-      rejectMessage = CHANGE_IS_CLOSED;
-      return false;
-    }
-    if (groups.isEmpty()) {
-      PatchSet prevPs = psUtil.current(ctx.getDb(), ctx.getNotes());
-      groups = prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of();
-    }
-
-    ChangeUpdate update = ctx.getUpdate(patchSetId);
-    update.setSubjectForCommit("Create patch set " + patchSetId.get());
-
-    String reviewMessage = null;
-    String psDescription = null;
-    if (magicBranch != null) {
-      recipients.add(magicBranch.getMailRecipients());
-      reviewMessage = magicBranch.message;
-      psDescription = magicBranch.message;
-      approvals.putAll(magicBranch.labels);
-      Set<String> hashtags = magicBranch.hashtags;
-      if (hashtags != null && !hashtags.isEmpty()) {
-        hashtags.addAll(ctx.getNotes().getHashtags());
-        update.setHashtags(hashtags);
-      }
-      if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
-        update.setTopic(magicBranch.topic);
-      }
-    }
-
-    boolean draft = magicBranch != null && magicBranch.draft;
-    if (change.getStatus() == Change.Status.DRAFT && !draft) {
-      update.setStatus(Change.Status.NEW);
-    }
-    newPatchSet =
-        psUtil.insert(
-            ctx.getDb(),
-            ctx.getRevWalk(),
-            update,
-            patchSetId,
-            commit,
-            draft,
-            groups,
-            pushCertificate != null ? pushCertificate.toTextWithSignature() : null,
-            psDescription);
-
-    update.setPsDescription(psDescription);
-    recipients.add(
-        getRecipientsFromFooters(ctx.getDb(), accountResolver, draft, commit.getFooterLines()));
-    recipients.remove(ctx.getAccountId());
-    ChangeData cd = changeDataFactory.create(ctx.getDb(), ctx.getControl());
-    MailRecipients oldRecipients = getRecipientsFromReviewers(cd.reviewers());
-    Iterable<PatchSetApproval> newApprovals =
-        approvalsUtil.addApprovalsForNewPatchSet(
-            ctx.getDb(),
-            update,
-            projectControl.getLabelTypes(),
-            newPatchSet,
-            ctx.getControl(),
-            approvals);
-    approvalCopier.copy(ctx.getDb(), ctx.getControl(), newPatchSet, newApprovals);
-    approvalsUtil.addReviewers(
-        ctx.getDb(),
-        update,
-        projectControl.getLabelTypes(),
-        change,
-        newPatchSet,
-        info,
-        recipients.getReviewers(),
-        oldRecipients.getAll());
-
-    // Check if approvals are changing in with this update. If so, add current user to reviewers.
-    // Note that this is done separately as addReviewers is filtering out the change owner as
-    // reviewer which is needed in several other code paths.
-    if (magicBranch != null && !magicBranch.labels.isEmpty()) {
-      update.putReviewer(ctx.getAccountId(), REVIEWER);
-    }
-
-    recipients.add(oldRecipients);
-
-    String approvalMessage =
-        ApprovalsUtil.renderMessageWithApprovals(
-            patchSetId.get(), approvals, scanLabels(ctx, approvals));
-    String kindMessage = changeKindMessage(changeKind);
-    StringBuilder message = new StringBuilder(approvalMessage);
-    if (!Strings.isNullOrEmpty(kindMessage)) {
-      message.append(kindMessage);
-    } else {
-      message.append('.');
-    }
-    if (!Strings.isNullOrEmpty(reviewMessage)) {
-      message.append("\n").append(reviewMessage);
-    }
-    msg =
-        ChangeMessagesUtil.newMessage(
-            patchSetId,
-            ctx.getUser(),
-            ctx.getWhen(),
-            message.toString(),
-            ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET);
-    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
-
-    if (mergedByPushOp == null) {
-      resetChange(ctx);
-    } else {
-      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet)).updateChange(ctx);
-    }
-
-    return true;
-  }
-
-  private String changeKindMessage(ChangeKind changeKind) {
-    switch (changeKind) {
-      case MERGE_FIRST_PARENT_UPDATE:
-      case TRIVIAL_REBASE:
-      case NO_CHANGE:
-        return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
-      case NO_CODE_CHANGE:
-        return ": Commit message was updated.";
-      case REWORK:
-      default:
-        return null;
-    }
-  }
-
-  private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, Map<String, Short> approvals)
-      throws OrmException {
-    Map<String, PatchSetApproval> current = new HashMap<>();
-    // We optimize here and only retrieve current when approvals provided
-    if (!approvals.isEmpty()) {
-      for (PatchSetApproval a :
-          approvalsUtil.byPatchSetUser(
-              ctx.getDb(), ctx.getControl(), priorPatchSetId, ctx.getAccountId())) {
-        if (a.isLegacySubmit()) {
-          continue;
-        }
-
-        LabelType lt = projectControl.getLabelTypes().byLabel(a.getLabelId());
-        if (lt != null) {
-          current.put(lt.getName(), a);
-        }
-      }
-    }
-    return current;
-  }
-
-  private void resetChange(ChangeContext ctx) {
-    Change change = ctx.getChange();
-    if (!change.currentPatchSetId().equals(priorPatchSetId)) {
-      return;
-    }
-
-    if (magicBranch != null && magicBranch.topic != null) {
-      change.setTopic(magicBranch.topic);
-    }
-    if (change.getStatus() == Change.Status.DRAFT && newPatchSet.isDraft()) {
-      // Leave in draft status.
-    } else {
-      change.setStatus(Change.Status.NEW);
-    }
-    change.setCurrentPatchSet(info);
-
-    List<String> idList = commit.getFooterLines(CHANGE_ID);
-    if (idList.isEmpty()) {
-      change.setKey(new Change.Key("I" + commit.name()));
-    } else {
-      change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
-    }
-  }
-
-  @Override
-  public void postUpdate(final Context ctx) throws Exception {
-    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
-      Runnable sender =
-          new Runnable() {
-            @Override
-            public void run() {
-              try {
-                ReplacePatchSetSender cm =
-                    replacePatchSetFactory.create(
-                        projectControl.getProject().getNameKey(), change.getId());
-                cm.setFrom(ctx.getAccount().getId());
-                cm.setPatchSet(newPatchSet, info);
-                cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
-                if (magicBranch != null) {
-                  cm.setNotify(magicBranch.notify);
-                  cm.setAccountsToNotify(magicBranch.getAccountsToNotify());
-                }
-                cm.addReviewers(recipients.getReviewers());
-                cm.addExtraCC(recipients.getCcOnly());
-                cm.send();
-              } catch (Exception e) {
-                log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
-              }
-            }
-
-            @Override
-            public String toString() {
-              return "send-email newpatchset";
-            }
-          };
-
-      if (requestScopePropagator != null) {
-        @SuppressWarnings("unused")
-        Future<?> possiblyIgnoredError =
-            sendEmailExecutor.submit(requestScopePropagator.wrap(sender));
-      } else {
-        sender.run();
-      }
-    }
-
-    NotifyHandling notify =
-        magicBranch != null && magicBranch.notify != null ? magicBranch.notify : NotifyHandling.ALL;
-    revisionCreated.fire(change, newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
-    try {
-      fireCommentAddedEvent(ctx);
-    } catch (Exception e) {
-      log.warn("comment-added event invocation failed", e);
-    }
-    if (mergedByPushOp != null) {
-      mergedByPushOp.postUpdate(ctx);
-    }
-  }
-
-  private void fireCommentAddedEvent(Context ctx) throws OrmException {
-    if (approvals.isEmpty()) {
-      return;
-    }
-
-    /* For labels that are not set in this operation, show the "current" value
-     * of 0, and no oldValue as the value was not modified by this operation.
-     * For labels that are set in this operation, the value was modified, so
-     * show a transition from an oldValue of 0 to the new value.
-     */
-    ChangeControl changeControl =
-        changeControlFactory.controlFor(ctx.getDb(), change, ctx.getUser());
-    List<LabelType> labels = changeControl.getLabelTypes().getLabelTypes();
-    Map<String, Short> allApprovals = new HashMap<>();
-    Map<String, Short> oldApprovals = new HashMap<>();
-    for (LabelType lt : labels) {
-      allApprovals.put(lt.getName(), (short) 0);
-      oldApprovals.put(lt.getName(), null);
-    }
-    for (Map.Entry<String, Short> entry : approvals.entrySet()) {
-      if (entry.getValue() != 0) {
-        allApprovals.put(entry.getKey(), entry.getValue());
-        oldApprovals.put(entry.getKey(), (short) 0);
-      }
-    }
-
-    commentAdded.fire(
-        change, newPatchSet, ctx.getAccount(), null, allApprovals, oldApprovals, ctx.getWhen());
-  }
-
-  public PatchSet getPatchSet() {
-    return newPatchSet;
-  }
-
-  public Change getChange() {
-    return change;
-  }
-
-  public String getRejectMessage() {
-    return rejectMessage;
-  }
-
-  public ReplaceOp setUpdateRef(boolean updateRef) {
-    this.updateRef = updateRef;
-    return this;
-  }
-
-  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
-    this.requestScopePropagator = requestScopePropagator;
-    return this;
-  }
-
-  private Ref findMergedInto(Context ctx, String first, RevCommit commit) {
-    try {
-      RefDatabase refDatabase = ctx.getRepository().getRefDatabase();
-
-      Ref firstRef = refDatabase.exactRef(first);
-      if (firstRef != null && isMergedInto(ctx.getRevWalk(), commit, firstRef)) {
-        return firstRef;
-      }
-
-      for (Ref ref : refDatabase.getRefs(Constants.R_HEADS).values()) {
-        if (isMergedInto(ctx.getRevWalk(), commit, ref)) {
-          return ref;
-        }
-      }
-      return null;
-    } catch (IOException e) {
-      log.warn("Can't check for already submitted change", e);
-      return null;
-    }
-  }
-
-  private static boolean isMergedInto(RevWalk rw, RevCommit commit, Ref ref) throws IOException {
-    return rw.isMergedInto(commit, rw.parseCommit(ref.getObjectId()));
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
index e7a86f1..6b2493a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepoRefCache.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.git;
 
 import java.io.IOException;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Optional;
@@ -44,4 +45,9 @@
     ids.put(refName, id);
     return id;
   }
+
+  /** @return an unmodifiable view of the refs that have been cached by this instance. */
+  public Map<String, Optional<ObjectId>> getCachedRefs() {
+    return Collections.unmodifiableMap(ids);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
index 45ec769..3a7a125 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/RepositoryCaseMismatchException.java
@@ -28,7 +28,7 @@
   private static final long serialVersionUID = 1L;
 
   /** @param projectName name of the project that cannot be created */
-  public RepositoryCaseMismatchException(final Project.NameKey projectName) {
+  public RepositoryCaseMismatchException(Project.NameKey projectName) {
     super("Name occupied in other case. Project " + projectName.get() + " cannot be created.");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SendEmailExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SendEmailExecutor.java
index feb32fa..7d37e5a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SendEmailExecutor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SendEmailExecutor.java
@@ -18,8 +18,9 @@
 
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 
-/** Marker on the global {@link WorkQueue.Executor} used to send email. */
+/** Marker on the global {@link ScheduledThreadPoolExecutor} used to send email. */
 @Retention(RUNTIME)
 @BindingAnnotation
 public @interface SendEmailExecutor {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java
index 5a3b4ff..a0422be 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleException.java
@@ -14,15 +14,19 @@
 
 package com.google.gerrit.server.git;
 
-/** Indicates the gitlink's update cannot be processed at this time. */
+/**
+ * Indicates the gitlink's update cannot be processed at this time.
+ *
+ * <p>Message should be considered user-visible.
+ */
 public class SubmoduleException extends Exception {
   private static final long serialVersionUID = 1L;
 
-  SubmoduleException(final String msg) {
+  SubmoduleException(String msg) {
     super(msg, null);
   }
 
-  SubmoduleException(final String msg, final Throwable why) {
+  SubmoduleException(String msg, Throwable why) {
     super(msg, why);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
index acd0e42..d497ee2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/SubmoduleOp.java
@@ -35,8 +35,9 @@
 import com.google.gerrit.server.update.RepoContext;
 import com.google.gerrit.server.update.RepoOnlyOp;
 import com.google.gerrit.server.update.UpdateException;
-import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
@@ -45,10 +46,12 @@
 import java.util.Deque;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import org.apache.commons.lang.StringUtils;
 import org.eclipse.jgit.dircache.DirCache;
 import org.eclipse.jgit.dircache.DirCacheBuilder;
 import org.eclipse.jgit.dircache.DirCacheEditor;
@@ -63,7 +66,6 @@
 import org.eclipse.jgit.lib.Ref;
 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.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -82,14 +84,49 @@
     public void updateRepo(RepoContext ctx) throws Exception {
       CodeReviewCommit c = composeGitlinksCommit(branch);
       if (c != null) {
-        ctx.addRefUpdate(new ReceiveCommand(c.getParent(0), c, branch.get()));
+        ctx.addRefUpdate(c.getParent(0), c, branch.get());
         addBranchTip(branch, c);
       }
     }
   }
 
-  public interface Factory {
-    SubmoduleOp create(Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm);
+  @Singleton
+  public static class Factory {
+    private final GitModules.Factory gitmodulesFactory;
+    private final Provider<PersonIdent> serverIdent;
+    private final Config cfg;
+    private final ProjectCache projectCache;
+    private final ProjectState.Factory projectStateFactory;
+    private final BatchUpdate.Factory batchUpdateFactory;
+
+    @Inject
+    Factory(
+        GitModules.Factory gitmodulesFactory,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        @GerritServerConfig Config cfg,
+        ProjectCache projectCache,
+        ProjectState.Factory projectStateFactory,
+        BatchUpdate.Factory batchUpdateFactory) {
+      this.gitmodulesFactory = gitmodulesFactory;
+      this.serverIdent = serverIdent;
+      this.cfg = cfg;
+      this.projectCache = projectCache;
+      this.projectStateFactory = projectStateFactory;
+      this.batchUpdateFactory = batchUpdateFactory;
+    }
+
+    public SubmoduleOp create(Set<Branch.NameKey> updatedBranches, MergeOpRepoManager orm)
+        throws SubmoduleException {
+      return new SubmoduleOp(
+          gitmodulesFactory,
+          serverIdent.get(),
+          cfg,
+          projectCache,
+          projectStateFactory,
+          batchUpdateFactory,
+          updatedBranches,
+          orm);
+    }
   }
 
   private static final Logger log = LoggerFactory.getLogger(SubmoduleOp.class);
@@ -101,6 +138,8 @@
   private final BatchUpdate.Factory batchUpdateFactory;
   private final VerboseSuperprojectUpdate verboseSuperProject;
   private final boolean enableSuperProjectSubscriptions;
+  private final long maxCombinedCommitMessageSize;
+  private final long maxCommitMessages;
   private final MergeOpRepoManager orm;
   private final Map<Branch.NameKey, GitModules> branchGitModules;
 
@@ -117,16 +156,15 @@
   // map of superproject and its branches which has submodule subscriptions
   private final SetMultimap<Project.NameKey, Branch.NameKey> branchesByProject;
 
-  @AssistedInject
-  public SubmoduleOp(
+  private SubmoduleOp(
       GitModules.Factory gitmodulesFactory,
-      @GerritPersonIdent PersonIdent myIdent,
-      @GerritServerConfig Config cfg,
+      PersonIdent myIdent,
+      Config cfg,
       ProjectCache projectCache,
       ProjectState.Factory projectStateFactory,
       BatchUpdate.Factory batchUpdateFactory,
-      @Assisted Set<Branch.NameKey> updatedBranches,
-      @Assisted MergeOpRepoManager orm)
+      Set<Branch.NameKey> updatedBranches,
+      MergeOpRepoManager orm)
       throws SubmoduleException {
     this.gitmodulesFactory = gitmodulesFactory;
     this.myIdent = myIdent;
@@ -137,6 +175,9 @@
         cfg.getEnum("submodule", null, "verboseSuperprojectUpdate", VerboseSuperprojectUpdate.TRUE);
     this.enableSuperProjectSubscriptions =
         cfg.getBoolean("submodule", "enableSuperProjectSubscriptions", true);
+    this.maxCombinedCommitMessageSize =
+        cfg.getLong("submodule", "maxCombinedCommitMessageSize", 256 << 10);
+    this.maxCommitMessages = cfg.getLong("submodule", "maxCommitMessages", 1000);
     this.orm = orm;
     this.updatedBranches = updatedBranches;
     this.targets = MultimapBuilder.hashKeys().hashSetValues().build();
@@ -349,7 +390,7 @@
   }
 
   /** Create a separate gitlink commit */
-  public CodeReviewCommit composeGitlinksCommit(final Branch.NameKey subscriber)
+  public CodeReviewCommit composeGitlinksCommit(Branch.NameKey subscriber)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
@@ -413,7 +454,7 @@
 
   /** Amend an existing commit with gitlink updates */
   public CodeReviewCommit composeGitlinksCommit(
-      final Branch.NameKey subscriber, CodeReviewCommit currentCommit)
+      Branch.NameKey subscriber, CodeReviewCommit currentCommit)
       throws IOException, SubmoduleException {
     OpenRepo or;
     try {
@@ -440,7 +481,7 @@
     commit.setTreeId(newTreeId);
     commit.setParentIds(currentCommit.getParents());
     if (verboseSuperProject != VerboseSuperprojectUpdate.FALSE) {
-      // TODO:czhen handle cherrypick footer
+      // TODO(czhen): handle cherrypick footer
       commit.setMessage(currentCommit.getFullMessage() + "\n\n* submodules:\n" + msgbuf.toString());
     } else {
       commit.setMessage(currentCommit.getFullMessage());
@@ -454,7 +495,7 @@
   }
 
   private RevCommit updateSubmodule(
-      DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, final SubmoduleSubscription s)
+      DirCache dc, DirCacheEditor ed, StringBuilder msgbuf, SubmoduleSubscription s)
       throws SubmoduleException, IOException {
     OpenRepo subOr;
     try {
@@ -519,8 +560,11 @@
       RevCommit newCommit,
       RevCommit oldCommit)
       throws SubmoduleException {
-    msgbuf.append("* Update " + s.getPath());
-    msgbuf.append(" from branch '" + s.getSubmodule().getShortName() + "'");
+    msgbuf.append("* Update ");
+    msgbuf.append(s.getPath());
+    msgbuf.append(" from branch '");
+    msgbuf.append(s.getSubmodule().getShortName());
+    msgbuf.append("'");
     msgbuf.append("\n  to " + newCommit.getName());
 
     // newly created submodule gitlink, do not append whole history
@@ -532,13 +576,27 @@
       subOr.rw.resetRetain(subOr.canMergeFlag);
       subOr.rw.markStart(newCommit);
       subOr.rw.markUninteresting(oldCommit);
-      for (RevCommit c : subOr.rw) {
+      int numMessages = 0;
+      for (Iterator<RevCommit> iter = subOr.rw.iterator(); iter.hasNext(); ) {
+        RevCommit c = iter.next();
         subOr.rw.parseBody(c);
-        if (verboseSuperProject == VerboseSuperprojectUpdate.SUBJECT_ONLY) {
-          msgbuf.append("\n  - " + c.getShortMessage());
-        } else if (verboseSuperProject == VerboseSuperprojectUpdate.TRUE) {
-          msgbuf.append("\n  - " + c.getFullMessage().replace("\n", "\n    "));
+
+        String message =
+            verboseSuperProject == VerboseSuperprojectUpdate.SUBJECT_ONLY
+                ? c.getShortMessage()
+                : StringUtils.replace(c.getFullMessage(), "\n", "\n    ");
+
+        String bullet = "\n  - ";
+        String ellipsis = "\n\n[...]";
+        int newSize = msgbuf.length() + bullet.length() + message.length();
+        if (++numMessages > maxCommitMessages
+            || newSize > maxCombinedCommitMessageSize
+            || iter.hasNext() && (newSize + ellipsis.length()) > maxCombinedCommitMessageSize) {
+          msgbuf.append(ellipsis);
+          break;
         }
+        msgbuf.append(bullet);
+        msgbuf.append(message);
       }
     } catch (IOException e) {
       throw new SubmoduleException(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java
index 773f612..8c93833 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/TransferConfig.java
@@ -31,7 +31,7 @@
   private final boolean inheritProjectMaxObjectSizeLimit;
 
   @Inject
-  TransferConfig(@GerritServerConfig final Config cfg) {
+  TransferConfig(@GerritServerConfig Config cfg) {
     timeout =
         (int)
             ConfigUtil.getTimeUnit(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackInitializer.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackInitializer.java
new file mode 100644
index 0000000..b63c5b3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/UploadPackInitializer.java
@@ -0,0 +1,35 @@
+// 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.git;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Project;
+import org.eclipse.jgit.transport.UploadPack;
+
+@ExtensionPoint
+public interface UploadPackInitializer {
+
+  /**
+   * UploadPack initialization.
+   *
+   * <p>Invoked by Gerrit when a new UploadPack instance is created and just before it is used.
+   * Implementors will usually call setXXX methods on the uploadPack parameter in order to set
+   * additional properties on it.
+   *
+   * @param project project for which the UploadPack is created
+   * @param uploadPack the UploadPack instance which is being initialized
+   */
+  void init(Project.NameKey project, UploadPack uploadPack);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ValidationError.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ValidationError.java
index 28d5171..ad84046 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ValidationError.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ValidationError.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.git;
 
-import java.util.Objects;
-
 /** Indicates a problem with Git based data. */
 public class ValidationError {
   private final String message;
@@ -44,21 +42,4 @@
   public interface Sink {
     void error(ValidationError error);
   }
-
-  @Override
-  public boolean equals(Object o) {
-    if (o == this) {
-      return true;
-    }
-    if (o instanceof ValidationError) {
-      ValidationError that = (ValidationError) o;
-      return Objects.equals(this.message, that.message);
-    }
-    return false;
-  }
-
-  @Override
-  public int hashCode() {
-    return Objects.hashCode(message);
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
index 2b9151b..d1e3381 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VersionedMetaData.java
@@ -75,6 +75,7 @@
   }
 
   protected RevCommit revision;
+  protected RevWalk rw;
   protected ObjectReader reader;
   protected ObjectInserter inserter;
   protected DirCache newTree;
@@ -153,12 +154,14 @@
    * @throws ConfigInvalidException
    */
   public void load(RevWalk walk, ObjectId id) throws IOException, ConfigInvalidException {
+    this.rw = walk;
     this.reader = walk.getObjectReader();
     try {
       revision = id != null ? walk.parseCommit(id) : null;
       onLoad();
     } finally {
-      reader = null;
+      this.rw = null;
+      this.reader = null;
     }
   }
 
@@ -214,8 +217,6 @@
 
     RevCommit createRef(String refName) throws IOException;
 
-    void removeRef(String refName) throws IOException;
-
     RevCommit commit() throws IOException;
 
     RevCommit commitAt(ObjectId revision) throws IOException;
@@ -238,7 +239,7 @@
    * @param update helper info about the update.
    * @throws IOException if the update failed.
    */
-  public BatchMetaDataUpdate openUpdate(final MetaDataUpdate update) throws IOException {
+  public BatchMetaDataUpdate openUpdate(MetaDataUpdate update) throws IOException {
     final Repository db = update.getRepository();
 
     reader = db.newObjectReader();
@@ -329,45 +330,6 @@
       }
 
       @Override
-      public void removeRef(String refName) throws IOException {
-        RefUpdate ru = db.updateRef(refName);
-        ru.setForceUpdate(true);
-        if (revision != null) {
-          ru.setExpectedOldObjectId(revision);
-        }
-        RefUpdate.Result result = ru.delete();
-        switch (result) {
-          case FORCED:
-            update.fireGitRefUpdatedEvent(ru);
-            return;
-          case LOCK_FAILURE:
-            throw new LockFailureException(
-                "Cannot delete "
-                    + ru.getName()
-                    + " in "
-                    + db.getDirectory()
-                    + ": "
-                    + ru.getResult());
-          case FAST_FORWARD:
-          case IO_FAILURE:
-          case NEW:
-          case NOT_ATTEMPTED:
-          case NO_CHANGE:
-          case REJECTED:
-          case REJECTED_CURRENT_BRANCH:
-          case RENAMED:
-          default:
-            throw new IOException(
-                "Cannot delete "
-                    + ru.getName()
-                    + " in "
-                    + db.getDirectory()
-                    + ": "
-                    + ru.getResult());
-        }
-      }
-
-      @Override
       public RevCommit commit() throws IOException {
         return commitAt(revision);
       }
@@ -408,7 +370,7 @@
 
         RefUpdate ru = db.updateRef(refName);
         ru.setExpectedOldObjectId(oldId);
-        ru.setNewObjectId(src);
+        ru.setNewObjectId(newId);
         ru.setRefLogIdent(update.getCommitBuilder().getAuthor());
         String message = update.getCommitBuilder().getMessage();
         if (message == null) {
@@ -433,7 +395,8 @@
                     + " in "
                     + db.getDirectory()
                     + ": "
-                    + ru.getResult());
+                    + ru.getResult(),
+                ru);
           case FORCED:
           case IO_FAILURE:
           case NOT_ATTEMPTED:
@@ -441,6 +404,8 @@
           case REJECTED:
           case REJECTED_CURRENT_BRANCH:
           case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
           default:
             throw new IOException(
                 "Cannot update "
@@ -496,10 +461,11 @@
       return new byte[] {};
     }
 
-    TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree());
-    if (tw != null) {
-      ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
-      return obj.getCachedBytes(Integer.MAX_VALUE);
+    try (TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
+      if (tw != null) {
+        ObjectLoader obj = reader.open(tw.getObjectId(0), Constants.OBJ_BLOB);
+        return obj.getCachedBytes(Integer.MAX_VALUE);
+      }
     }
     return new byte[] {};
   }
@@ -509,9 +475,10 @@
       return null;
     }
 
-    TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree());
-    if (tw != null) {
-      return tw.getObjectId(0);
+    try (TreeWalk tw = TreeWalk.forPath(reader, fileName, revision.getTree())) {
+      if (tw != null) {
+        return tw.getObjectId(0);
+      }
     }
 
     return null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
index 47d416c..c16c195 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/VisibleRefFilter.java
@@ -18,27 +18,41 @@
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_USERS_SELF;
+import static java.util.stream.Collectors.toMap;
 
-import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
+import com.google.gerrit.server.permissions.ChangePermission;
+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.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
+import java.util.Objects;
+import java.util.stream.Stream;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefDatabase;
@@ -53,50 +67,73 @@
 public class VisibleRefFilter extends AbstractAdvertiseRefsHook {
   private static final Logger log = LoggerFactory.getLogger(VisibleRefFilter.class);
 
+  public interface Factory {
+    VisibleRefFilter create(ProjectState projectState, Repository git);
+  }
+
   private final TagCache tagCache;
   private final ChangeNotes.Factory changeNotesFactory;
   @Nullable private final SearchingChangeCacheImpl changeCache;
-  private final Repository db;
-  private final Project.NameKey projectName;
-  private final ProjectControl projectCtl;
-  private final ReviewDb reviewDb;
-  private final boolean showMetadata;
+  private final Provider<ReviewDb> db;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
+  private final PermissionBackend.ForProject perm;
+  private final ProjectState projectState;
+  private final Repository git;
+  private ProjectControl projectCtl;
+  private boolean showMetadata = true;
   private String userEditPrefix;
-  private Set<Change.Id> visibleChanges;
+  private Map<Change.Id, Branch.NameKey> visibleChanges;
 
-  public VisibleRefFilter(
+  @Inject
+  VisibleRefFilter(
       TagCache tagCache,
       ChangeNotes.Factory changeNotesFactory,
       @Nullable SearchingChangeCacheImpl changeCache,
-      Repository db,
-      ProjectControl projectControl,
-      ReviewDb reviewDb,
-      boolean showMetadata) {
+      Provider<ReviewDb> db,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend,
+      @Assisted ProjectState projectState,
+      @Assisted Repository git) {
     this.tagCache = tagCache;
     this.changeNotesFactory = changeNotesFactory;
     this.changeCache = changeCache;
     this.db = db;
-    this.projectName = projectControl.getProject().getNameKey();
-    this.projectCtl = projectControl;
-    this.reviewDb = reviewDb;
-    this.showMetadata = showMetadata;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
+    this.perm =
+        permissionBackend.user(user).database(db).project(projectState.getProject().getNameKey());
+    this.projectState = projectState;
+    this.git = git;
+  }
+
+  /** Show change references. Default is {@code true}. */
+  public VisibleRefFilter setShowMetadata(boolean show) {
+    showMetadata = show;
+    return this;
   }
 
   public Map<String, Ref> filter(Map<String, Ref> refs, boolean filterTagsSeparately) {
-    if (projectCtl.getProjectState().isAllUsers()) {
+    if (projectState.isAllUsers()) {
       refs = addUsersSelfSymref(refs);
     }
 
-    if (projectCtl.allRefsAreVisible(ImmutableSet.of(REFS_CONFIG))) {
-      return fastHideRefsMetaConfig(refs);
+    PermissionBackend.WithUser withUser = permissionBackend.user(user);
+    PermissionBackend.ForProject forProject = withUser.project(projectState.getNameKey());
+    if (!projectState.isAllUsers()) {
+      if (checkProjectPermission(forProject, ProjectPermission.READ)) {
+        return refs;
+      } else if (checkProjectPermission(forProject, ProjectPermission.READ_NO_CONFIG)) {
+        return fastHideRefsMetaConfig(refs);
+      }
     }
 
     Account.Id userId;
     boolean viewMetadata;
-    if (projectCtl.getUser().isIdentifiedUser()) {
-      IdentifiedUser user = projectCtl.getUser().asIdentifiedUser();
-      userId = user.getAccountId();
-      viewMetadata = user.getCapabilities().canAccessDatabase();
+    if (user.get().isIdentifiedUser()) {
+      viewMetadata = withUser.testOrFalse(GlobalPermission.ACCESS_DATABASE);
+      IdentifiedUser u = user.get().asIdentifiedUser();
+      userId = u.getAccountId();
       userEditPrefix = RefNames.refsEditPrefix(userId);
     } else {
       userId = null;
@@ -106,12 +143,12 @@
     Map<String, Ref> result = new HashMap<>();
     List<Ref> deferredTags = new ArrayList<>();
 
+    projectCtl = projectState.controlFor(user.get());
     for (Ref ref : refs.values()) {
       String name = ref.getName();
       Change.Id changeId;
       Account.Id accountId;
-      if (name.startsWith(REFS_CACHE_AUTOMERGE)
-          || (!showMetadata && isMetadata(projectCtl, name))) {
+      if (name.startsWith(REFS_CACHE_AUTOMERGE) || (!showMetadata && isMetadata(name))) {
         continue;
       } else if (RefNames.isRefsEdit(name)) {
         // Edits are visible only to the owning user, if change is visible.
@@ -125,8 +162,7 @@
         }
       } else if ((accountId = Account.Id.fromRef(name)) != null) {
         // Account ref is visible only to corresponding account.
-        if (viewMetadata
-            || (accountId.equals(userId) && projectCtl.controlForRef(name).isVisible())) {
+        if (viewMetadata || (accountId.equals(userId) && canReadRef(name))) {
           result.put(name, ref);
         }
       } else if (isTag(ref)) {
@@ -139,17 +175,22 @@
         if (viewMetadata) {
           result.put(name, ref);
         }
-      } else if (projectCtl.getProjectState().isAllUsers()
-          && name.equals(RefNames.REFS_EXTERNAL_IDS)) {
+      } else if (projectState.isAllUsers() && name.equals(RefNames.REFS_EXTERNAL_IDS)) {
         // The notes branch with the external IDs of all users must not be exposed to normal users.
         if (viewMetadata) {
           result.put(name, ref);
         }
-      } else if (projectCtl.controlForRef(ref.getLeaf().getName()).isVisible()) {
+      } else if (canReadRef(ref.getLeaf().getName())) {
         // Use the leaf to lookup the control data. If the reference is
         // symbolic we want the control around the final target. If its
         // not symbolic then getLeaf() is a no-op returning ref itself.
         result.put(name, ref);
+      } else if (isRefsUsersSelf(ref)) {
+        // viewMetadata allows to see all account refs, hence refs/users/self should be included as
+        // well
+        if (viewMetadata) {
+          result.put(name, ref);
+        }
       }
     }
 
@@ -159,11 +200,11 @@
     if (!deferredTags.isEmpty() && (!result.isEmpty() || filterTagsSeparately)) {
       TagMatcher tags =
           tagCache
-              .get(projectName)
+              .get(projectState.getNameKey())
               .matcher(
                   tagCache,
-                  db,
-                  filterTagsSeparately ? filter(db.getAllRefs()).values() : result.values());
+                  git,
+                  filterTagsSeparately ? filter(git.getAllRefs()).values() : result.values());
       for (Ref tag : deferredTags) {
         if (tags.isReachable(tag)) {
           result.put(tag.getName(), tag);
@@ -175,7 +216,7 @@
   }
 
   private Map<String, Ref> fastHideRefsMetaConfig(Map<String, Ref> refs) {
-    if (refs.containsKey(REFS_CONFIG) && !projectCtl.controlForRef(REFS_CONFIG).isVisible()) {
+    if (refs.containsKey(REFS_CONFIG) && !canReadRef(REFS_CONFIG)) {
       Map<String, Ref> r = new HashMap<>(refs);
       r.remove(REFS_CONFIG);
       return r;
@@ -184,8 +225,8 @@
   }
 
   private Map<String, Ref> addUsersSelfSymref(Map<String, Ref> refs) {
-    if (projectCtl.getUser().isIdentifiedUser()) {
-      Ref r = refs.get(RefNames.refsUsers(projectCtl.getUser().getAccountId()));
+    if (user.get().isIdentifiedUser()) {
+      Ref r = refs.get(RefNames.refsUsers(user.get().getAccountId()));
       if (r != null) {
         SymbolicRef s = new SymbolicRef(REFS_USERS_SELF, r);
         refs = new HashMap<>(refs);
@@ -221,63 +262,109 @@
         visibleChanges = visibleChangesBySearch();
       }
     }
-    return visibleChanges.contains(changeId);
+    return visibleChanges.containsKey(changeId);
   }
 
   private boolean visibleEdit(String name) {
-    if (userEditPrefix != null && name.startsWith(userEditPrefix)) {
-      Change.Id id = Change.Id.fromEditRefPart(name);
-      if (id != null) {
-        return visible(id);
-      }
+    Change.Id id = Change.Id.fromEditRefPart(name);
+    // Initialize if it wasn't yet
+    if (visibleChanges == null) {
+      visible(id);
+    }
+    if (id != null) {
+      return (userEditPrefix != null && name.startsWith(userEditPrefix) && visible(id))
+          || (visibleChanges.containsKey(id)
+              && projectCtl.controlForRef(visibleChanges.get(id)).isEditVisible());
     }
     return false;
   }
 
-  private Set<Change.Id> visibleChangesBySearch() {
-    Project project = projectCtl.getProject();
+  private Map<Change.Id, Branch.NameKey> visibleChangesBySearch() {
+    Project.NameKey project = projectState.getNameKey();
     try {
-      Set<Change.Id> visibleChanges = new HashSet<>();
-      for (ChangeData cd : changeCache.getChangeData(reviewDb, project.getNameKey())) {
-        if (projectCtl.controlForIndexedChange(cd.change()).isVisible(reviewDb, cd)) {
-          visibleChanges.add(cd.getId());
+      Map<Change.Id, Branch.NameKey> visibleChanges = new HashMap<>();
+      for (ChangeData cd : changeCache.getChangeData(db.get(), project)) {
+        ChangeNotes notes = changeNotesFactory.createFromIndexedChange(cd.change());
+        if (perm.indexedChange(cd, notes).test(ChangePermission.READ)) {
+          visibleChanges.put(cd.getId(), cd.change().getDest());
         }
       }
       return visibleChanges;
-    } catch (OrmException e) {
-      log.error(
-          "Cannot load changes for project "
-              + project.getName()
-              + ", assuming no changes are visible",
-          e);
-      return Collections.emptySet();
-    }
-  }
-
-  private Set<Change.Id> visibleChangesByScan() {
-    Project.NameKey project = projectCtl.getProject().getNameKey();
-    try {
-      Set<Change.Id> visibleChanges = new HashSet<>();
-      for (ChangeNotes cn : changeNotesFactory.scan(db, reviewDb, project)) {
-        if (projectCtl.controlFor(cn).isVisible(reviewDb)) {
-          visibleChanges.add(cn.getChangeId());
-        }
-      }
-      return visibleChanges;
-    } catch (IOException | OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error(
           "Cannot load changes for project " + project + ", assuming no changes are visible", e);
-      return Collections.emptySet();
+      return Collections.emptyMap();
     }
   }
 
-  private static boolean isMetadata(ProjectControl projectCtl, String name) {
-    return name.startsWith(REFS_CHANGES)
-        || RefNames.isRefsEdit(name)
-        || (projectCtl.getProjectState().isAllUsers() && name.equals(RefNames.REFS_EXTERNAL_IDS));
+  private Map<Change.Id, Branch.NameKey> visibleChangesByScan() {
+    Project.NameKey p = projectState.getNameKey();
+    Stream<ChangeNotesResult> s;
+    try {
+      s = changeNotesFactory.scan(git, db.get(), p);
+    } catch (IOException e) {
+      log.error("Cannot load changes for project " + p + ", assuming no changes are visible", e);
+      return Collections.emptyMap();
+    }
+    return s.map(r -> toNotes(p, r))
+        .filter(Objects::nonNull)
+        .collect(toMap(n -> n.getChangeId(), n -> n.getChange().getDest()));
+  }
+
+  @Nullable
+  private ChangeNotes toNotes(Project.NameKey p, ChangeNotesResult r) {
+    if (r.error().isPresent()) {
+      log.warn("Failed to load change " + r.id() + " in " + p, r.error().get());
+      return null;
+    }
+    try {
+      if (perm.change(r.notes()).test(ChangePermission.READ)) {
+        return r.notes();
+      }
+    } catch (PermissionBackendException e) {
+      log.warn("Failed to check permission for " + r.id() + " in " + p, e);
+    }
+    return null;
+  }
+
+  private boolean isMetadata(String name) {
+    return name.startsWith(REFS_CHANGES) || RefNames.isRefsEdit(name);
   }
 
   private static boolean isTag(Ref ref) {
     return ref.getLeaf().getName().startsWith(Constants.R_TAGS);
   }
+
+  private static boolean isRefsUsersSelf(Ref ref) {
+    return ref.getName().startsWith(REFS_USERS_SELF);
+  }
+
+  private boolean canReadRef(String ref) {
+    try {
+      perm.ref(ref).check(RefPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    } catch (PermissionBackendException e) {
+      log.error("unable to check permissions", e);
+      return false;
+    }
+  }
+
+  private boolean checkProjectPermission(
+      PermissionBackend.ForProject forProject, ProjectPermission perm) {
+    try {
+      forProject.check(perm);
+    } catch (AuthException e) {
+      return false;
+    } catch (PermissionBackendException e) {
+      log.error(
+          "Can't check permission for user {} on project {}",
+          user.get(),
+          projectState.getName(),
+          e);
+      return false;
+    }
+    return true;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
index 4e9d937..72bc805 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/WorkQueue.java
@@ -39,6 +39,7 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import java.util.concurrent.RunnableScheduledFuture;
+import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
@@ -56,7 +57,7 @@
     private final WorkQueue workQueue;
 
     @Inject
-    Lifecycle(final WorkQueue workQeueue) {
+    Lifecycle(WorkQueue workQeueue) {
       this.workQueue = workQeueue;
     }
 
@@ -86,7 +87,7 @@
         }
       };
 
-  private final Executor defaultQueue;
+  private final ScheduledExecutorService defaultQueue;
   private final IdGenerator idGenerator;
   private final MetricMaker metrics;
   private final CopyOnWriteArrayList<Executor> queues;
@@ -105,7 +106,7 @@
   }
 
   /** Get the default work queue, for miscellaneous tasks. */
-  public Executor getDefaultQueue() {
+  public ScheduledExecutorService getDefaultQueue() {
     return defaultQueue;
   }
 
@@ -115,13 +116,28 @@
    * <p>Creates a new executor queue without associated metrics. This method is suitable for use by
    * plugins.
    *
-   * <p>If metrics are needed, use {@link #createQueue(int, String, boolean)} instead.
+   * <p>If metrics are needed, use {@link #createQueue(int, String, int, boolean)} instead.
    *
    * @param poolsize the size of the pool.
    * @param queueName the name of the queue.
    */
-  public Executor createQueue(int poolsize, String queueName) {
-    return createQueue(poolsize, queueName, false);
+  public ScheduledExecutorService createQueue(int poolsize, String queueName) {
+    return createQueue(poolsize, queueName, Thread.NORM_PRIORITY, false);
+  }
+
+  /**
+   * Create a new executor queue, with default priority, optionally with metrics.
+   *
+   * <p>Creates a new executor queue, optionally with associated metrics. Metrics should not be
+   * requested for queues created by plugins.
+   *
+   * @param poolsize the size of the pool.
+   * @param queueName the name of the queue.
+   * @param withMetrics whether to create metrics.
+   */
+  public ScheduledThreadPoolExecutor createQueue(
+      int poolsize, String queueName, boolean withMetrics) {
+    return createQueue(poolsize, queueName, Thread.NORM_PRIORITY, withMetrics);
   }
 
   /**
@@ -132,24 +148,36 @@
    *
    * @param poolsize the size of the pool.
    * @param queueName the name of the queue.
+   * @param threadPriority thread priority.
    * @param withMetrics whether to create metrics.
    */
-  public Executor createQueue(int poolsize, String queueName, boolean withMetrics) {
-    final Executor r = new Executor(poolsize, queueName);
+  public ScheduledThreadPoolExecutor createQueue(
+      int poolsize, String queueName, int threadPriority, boolean withMetrics) {
+    Executor executor = new Executor(poolsize, queueName);
     if (withMetrics) {
       log.info("Adding metrics for '{}' queue", queueName);
-      r.buildMetrics(queueName);
+      executor.buildMetrics(queueName);
     }
-    r.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
-    r.setExecuteExistingDelayedTasksAfterShutdownPolicy(true);
-    queues.add(r);
-    return r;
+    executor.setContinueExistingPeriodicTasksAfterShutdownPolicy(false);
+    executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(true);
+    queues.add(executor);
+    if (threadPriority != Thread.NORM_PRIORITY) {
+      ThreadFactory parent = executor.getThreadFactory();
+      executor.setThreadFactory(
+          task -> {
+            Thread t = parent.newThread(task);
+            t.setPriority(threadPriority);
+            return t;
+          });
+    }
+
+    return executor;
   }
 
   /** Get all of the tasks currently scheduled in any work queue. */
   public List<Task<?>> getTasks() {
     final List<Task<?>> r = new ArrayList<>();
-    for (final Executor e : queues) {
+    for (Executor e : queues) {
       e.addAllTo(r);
     }
     return r;
@@ -166,9 +194,9 @@
   }
 
   /** Locate a task by its unique id, null if no task matches. */
-  public Task<?> getTask(final int id) {
+  public Task<?> getTask(int id) {
     Task<?> result = null;
-    for (final Executor e : queues) {
+    for (Executor e : queues) {
       final Task<?> t = e.getTask(id);
       if (t != null) {
         if (result != null) {
@@ -181,7 +209,7 @@
     return result;
   }
 
-  public Executor getExecutor(String queueName) {
+  public ScheduledThreadPoolExecutor getExecutor(String queueName) {
     for (Executor e : queues) {
       if (e.queueName.equals(queueName)) {
         return e;
@@ -191,7 +219,7 @@
   }
 
   private void stop() {
-    for (final Executor p : queues) {
+    for (Executor p : queues) {
       p.shutdown();
       boolean isTerminated;
       do {
@@ -206,7 +234,7 @@
   }
 
   /** An isolated queue. */
-  public class Executor extends ScheduledThreadPoolExecutor {
+  private class Executor extends ScheduledThreadPoolExecutor {
     private final ConcurrentHashMap<Integer, Task<?>> all;
     private final String queueName;
 
@@ -218,7 +246,7 @@
             private final AtomicInteger tid = new AtomicInteger(1);
 
             @Override
-            public Thread newThread(final Runnable task) {
+            public Thread newThread(Runnable task) {
               final Thread t = parent.newThread(task);
               t.setName(queueName + "-" + tid.getAndIncrement());
               t.setUncaughtExceptionHandler(LOG_UNCAUGHT_EXCEPTION);
@@ -235,6 +263,12 @@
       this.queueName = queueName;
     }
 
+    @Override
+    protected void terminated() {
+      super.terminated();
+      queues.remove(this);
+    }
+
     private void buildMetrics(String queueName) {
       metrics.newCallbackMetric(
           getMetricName(queueName, "max_pool_size"),
@@ -314,13 +348,9 @@
       return metrics.sanitizeMetricName(String.format("queue/%s/%s", name, metricName));
     }
 
-    public void unregisterWorkQueue() {
-      queues.remove(this);
-    }
-
     @Override
     protected <V> RunnableScheduledFuture<V> decorateTask(
-        final Runnable runnable, RunnableScheduledFuture<V> r) {
+        Runnable runnable, RunnableScheduledFuture<V> r) {
       r = super.decorateTask(runnable, r);
       for (; ; ) {
         final int id = idGenerator.next();
@@ -341,19 +371,19 @@
 
     @Override
     protected <V> RunnableScheduledFuture<V> decorateTask(
-        final Callable<V> callable, final RunnableScheduledFuture<V> task) {
+        Callable<V> callable, RunnableScheduledFuture<V> task) {
       throw new UnsupportedOperationException("Callable not implemented");
     }
 
-    void remove(final Task<?> task) {
+    void remove(Task<?> task) {
       all.remove(task.getTaskId(), task);
     }
 
-    Task<?> getTask(final int id) {
+    Task<?> getTask(int id) {
       return all.get(id);
     }
 
-    void addAllTo(final List<Task<?>> list) {
+    void addAllTo(List<Task<?>> list) {
       list.addAll(all.values()); // iterator is thread safe
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java
new file mode 100644
index 0000000..4afaacd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/AllRefsWatcher.java
@@ -0,0 +1,49 @@
+// 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.git.receive;
+
+import static com.google.common.base.Preconditions.checkState;
+
+import java.util.Map;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.BaseReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.UploadPack;
+
+/**
+ * Hook that scans all refs and holds onto the results reference.
+ *
+ * <p>This allows a caller who has an {@code AllRefsWatcher} instance to get the full map of refs in
+ * the repo, even if refs are filtered by a later hook or filter.
+ */
+class AllRefsWatcher implements AdvertiseRefsHook {
+  private Map<String, Ref> allRefs;
+
+  @Override
+  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
+    allRefs = HookUtil.ensureAllRefsAdvertised(rp);
+  }
+
+  @Override
+  public void advertiseRefs(UploadPack uploadPack) {
+    throw new UnsupportedOperationException();
+  }
+
+  Map<String, Ref> getAllRefs() {
+    checkState(allRefs != null, "getAllRefs() only valid after refs were advertised");
+    return allRefs;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
new file mode 100644
index 0000000..bed9bd4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/AsyncReceiveCommits.java
@@ -0,0 +1,289 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.common.collect.SetMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.Capable;
+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.server.IdentifiedUser;
+import com.google.gerrit.server.config.ConfigUtil;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.HackPushNegotiateHook;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.ProjectRunnable;
+import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+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.ContributorAgreementsChecker;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.inject.Inject;
+import com.google.inject.PrivateModule;
+import com.google.inject.Provider;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+import com.google.inject.name.Named;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.AdvertiseRefsHookChain;
+import org.eclipse.jgit.transport.PreReceiveHook;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Hook that delegates to {@link ReceiveCommits} in a worker thread. */
+public class AsyncReceiveCommits implements PreReceiveHook {
+  private static final Logger log = LoggerFactory.getLogger(AsyncReceiveCommits.class);
+
+  private static final String TIMEOUT_NAME = "ReceiveCommitsOverallTimeout";
+
+  public interface Factory {
+    AsyncReceiveCommits create(
+        ProjectControl projectControl,
+        Repository repository,
+        @Nullable MessageSender messageSender,
+        SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
+  }
+
+  public static class Module extends PrivateModule {
+    @Override
+    public void configure() {
+      install(new FactoryModuleBuilder().build(AsyncReceiveCommits.Factory.class));
+      expose(AsyncReceiveCommits.Factory.class);
+      // Don't expose the binding for ReceiveCommits.Factory. All callers should
+      // be using AsyncReceiveCommits.Factory instead.
+      install(new FactoryModuleBuilder().build(ReceiveCommits.Factory.class));
+    }
+
+    @Provides
+    @Singleton
+    @Named(TIMEOUT_NAME)
+    long getTimeoutMillis(@GerritServerConfig Config cfg) {
+      return ConfigUtil.getTimeUnit(
+          cfg, "receive", null, "timeout", TimeUnit.MINUTES.toMillis(4), TimeUnit.MILLISECONDS);
+    }
+  }
+
+  private class Worker implements ProjectRunnable {
+    final MultiProgressMonitor progress;
+
+    private final Collection<ReceiveCommand> commands;
+
+    private Worker(Collection<ReceiveCommand> commands) {
+      this.commands = commands;
+      progress = new MultiProgressMonitor(new MessageSenderOutputStream(), "Processing changes");
+    }
+
+    @Override
+    public void run() {
+      rc.processCommands(commands, progress);
+    }
+
+    @Override
+    public Project.NameKey getProjectNameKey() {
+      return rc.getProject().getNameKey();
+    }
+
+    @Override
+    public String getRemoteName() {
+      return null;
+    }
+
+    @Override
+    public boolean hasCustomizedPrint() {
+      return true;
+    }
+
+    @Override
+    public String toString() {
+      return "receive-commits";
+    }
+
+    void sendMessages() {
+      rc.sendMessages();
+    }
+
+    private class MessageSenderOutputStream extends OutputStream {
+      @Override
+      public void write(int b) {
+        rc.getMessageSender().sendBytes(new byte[] {(byte) b});
+      }
+
+      @Override
+      public void write(byte[] what, int off, int len) {
+        rc.getMessageSender().sendBytes(what, off, len);
+      }
+
+      @Override
+      public void write(byte[] what) {
+        rc.getMessageSender().sendBytes(what);
+      }
+
+      @Override
+      public void flush() {
+        rc.getMessageSender().flush();
+      }
+    }
+  }
+
+  private final ReceiveCommits rc;
+  private final ReceivePack rp;
+  private final ExecutorService executor;
+  private final RequestScopePropagator scopePropagator;
+  private final ReceiveConfig receiveConfig;
+  private final ContributorAgreementsChecker contributorAgreements;
+  private final long timeoutMillis;
+  private final ProjectControl projectControl;
+  private final Repository repo;
+  private final AllRefsWatcher allRefsWatcher;
+
+  @Inject
+  AsyncReceiveCommits(
+      ReceiveCommits.Factory factory,
+      PermissionBackend permissionBackend,
+      VisibleRefFilter.Factory refFilterFactory,
+      Provider<InternalChangeQuery> queryProvider,
+      @ReceiveCommitsExecutor ExecutorService executor,
+      RequestScopePropagator scopePropagator,
+      ReceiveConfig receiveConfig,
+      TransferConfig transferConfig,
+      Provider<LazyPostReceiveHookChain> lazyPostReceive,
+      ContributorAgreementsChecker contributorAgreements,
+      @Named(TIMEOUT_NAME) long timeoutMillis,
+      @Assisted ProjectControl projectControl,
+      @Assisted Repository repo,
+      @Assisted @Nullable MessageSender messageSender,
+      @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
+      throws PermissionBackendException {
+    this.executor = executor;
+    this.scopePropagator = scopePropagator;
+    this.receiveConfig = receiveConfig;
+    this.contributorAgreements = contributorAgreements;
+    this.timeoutMillis = timeoutMillis;
+    this.projectControl = projectControl;
+    this.repo = repo;
+
+    IdentifiedUser user = projectControl.getUser().asIdentifiedUser();
+    ProjectState state = projectControl.getProjectState();
+    Project.NameKey projectName = projectControl.getProject().getNameKey();
+    rp = new ReceivePack(repo);
+    rp.setAllowCreates(true);
+    rp.setAllowDeletes(true);
+    rp.setAllowNonFastForwards(true);
+    rp.setRefLogIdent(user.newRefLogIdent());
+    rp.setTimeout(transferConfig.getTimeout());
+    rp.setMaxObjectSizeLimit(state.getEffectiveMaxObjectSizeLimit().value);
+    rp.setCheckReceivedObjects(state.getConfig().getCheckReceivedObjects());
+    rp.setRefFilter(new ReceiveRefFilter());
+    rp.setAllowPushOptions(true);
+    rp.setPreReceiveHook(this);
+    rp.setPostReceiveHook(lazyPostReceive.get());
+
+    // If the user lacks READ permission, some references may be filtered and hidden from view.
+    // Check objects mentioned inside the incoming pack file are reachable from visible refs.
+    try {
+      permissionBackend.user(user).project(projectName).check(ProjectPermission.READ);
+    } catch (AuthException e) {
+      rp.setCheckReferencedObjectsAreReachable(receiveConfig.checkReferencedObjectsAreReachable);
+    }
+
+    List<AdvertiseRefsHook> advHooks = new ArrayList<>(4);
+    allRefsWatcher = new AllRefsWatcher();
+    advHooks.add(allRefsWatcher);
+    advHooks.add(refFilterFactory.create(state, repo).setShowMetadata(false));
+    advHooks.add(new ReceiveCommitsAdvertiseRefsHook(queryProvider, projectName));
+    advHooks.add(new HackPushNegotiateHook());
+    rp.setAdvertiseRefsHook(AdvertiseRefsHookChain.newChain(advHooks));
+
+    rc = factory.create(projectControl, rp, allRefsWatcher, extraReviewers);
+    rc.init();
+    rc.setMessageSender(messageSender);
+  }
+
+  /** Determine if the user can upload commits. */
+  public Capable canUpload() throws IOException {
+    Capable result = projectControl.canPushToAtLeastOneRef();
+    if (result != Capable.OK) {
+      return result;
+    }
+
+    try {
+      contributorAgreements.check(
+          projectControl.getProject().getNameKey(), projectControl.getUser());
+    } catch (AuthException e) {
+      return new Capable(e.getMessage());
+    }
+
+    if (receiveConfig.checkMagicRefs) {
+      return MagicBranch.checkMagicBranchRefs(repo, projectControl.getProject());
+    }
+    return Capable.OK;
+  }
+
+  @Override
+  public void onPreReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
+    if (commands.stream().anyMatch(c -> c.getResult() != Result.NOT_ATTEMPTED)) {
+      // Stop processing when command was already processed by previously invoked
+      // pre-receive hooks
+      return;
+    }
+    Worker w = new Worker(commands);
+    try {
+      w.progress.waitFor(
+          executor.submit(scopePropagator.wrap(w)), timeoutMillis, TimeUnit.MILLISECONDS);
+    } catch (ExecutionException e) {
+      log.warn(
+          "Error in ReceiveCommits while processing changes for project {}",
+          projectControl.getProject().getName(),
+          e);
+      rp.sendError("internal error while processing changes");
+      // ReceiveCommits has tried its best to catch errors, so anything at this
+      // point is very bad.
+      for (ReceiveCommand c : commands) {
+        if (c.getResult() == Result.NOT_ATTEMPTED) {
+          c.setResult(Result.REJECTED_OTHER_REASON, "internal error");
+        }
+      }
+    } finally {
+      w.sendMessages();
+    }
+  }
+
+  public ReceivePack getReceivePack() {
+    return rp;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ChangeProgressOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ChangeProgressOp.java
new file mode 100644
index 0000000..6774465
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ChangeProgressOp.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import org.eclipse.jgit.lib.ProgressMonitor;
+
+/** Trivial op to update a counter during {@code updateChange} */
+class ChangeProgressOp implements BatchUpdateOp {
+  private final ProgressMonitor progress;
+
+  ChangeProgressOp(ProgressMonitor progress) {
+    this.progress = progress;
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx) {
+    synchronized (progress) {
+      progress.update(1);
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/HookUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/HookUtil.java
new file mode 100644
index 0000000..90b220a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/HookUtil.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import java.io.IOException;
+import java.util.Map;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
+import org.eclipse.jgit.transport.BaseReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+
+/** Static utilities for writing {@link ReceiveCommits}-related hooks. */
+class HookUtil {
+  /**
+   * Scan and advertise all refs in the repo if refs have not already been advertised; otherwise,
+   * just return the advertised map.
+   *
+   * @param rp receive-pack handler.
+   * @return map of refs that were advertised.
+   * @throws ServiceMayNotContinueException if a problem occurred.
+   */
+  static Map<String, Ref> ensureAllRefsAdvertised(BaseReceivePack rp)
+      throws ServiceMayNotContinueException {
+    Map<String, Ref> refs = rp.getAdvertisedRefs();
+    if (refs != null) {
+      return refs;
+    }
+    try {
+      refs = rp.getRepository().getRefDatabase().getRefs(RefDatabase.ALL);
+    } catch (ServiceMayNotContinueException e) {
+      throw e;
+    } catch (IOException e) {
+      ServiceMayNotContinueException ex = new ServiceMayNotContinueException();
+      ex.initCause(e);
+      throw ex;
+    }
+    rp.setAdvertisedRefs(refs, rp.getAdvertisedObjects());
+    return refs;
+  }
+
+  private HookUtil() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
new file mode 100644
index 0000000..7adb21b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/LazyPostReceiveHookChain.java
@@ -0,0 +1,38 @@
+// Copyright (C) 2015 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.inject.Inject;
+import java.util.Collection;
+import org.eclipse.jgit.transport.PostReceiveHook;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceivePack;
+
+class LazyPostReceiveHookChain implements PostReceiveHook {
+  private final DynamicSet<PostReceiveHook> hooks;
+
+  @Inject
+  LazyPostReceiveHookChain(DynamicSet<PostReceiveHook> hooks) {
+    this.hooks = hooks;
+  }
+
+  @Override
+  public void onPostReceive(ReceivePack rp, Collection<ReceiveCommand> commands) {
+    for (PostReceiveHook h : hooks) {
+      h.onPostReceive(rp, commands);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/MessageSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/MessageSender.java
new file mode 100644
index 0000000..a338021
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/MessageSender.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.server.git.receive;
+
+/**
+ * Interface used by {@link ReceiveCommits} for send messages over the wire during {@code
+ * receive-pack}.
+ */
+public interface MessageSender {
+  void sendMessage(String what);
+
+  void sendError(String what);
+
+  void sendBytes(byte[] what);
+
+  void sendBytes(byte[] what, int off, int len);
+
+  void flush();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommits.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
new file mode 100644
index 0000000..35259bb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommits.java
@@ -0,0 +1,3015 @@
+// 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.server.git.receive;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+import static com.google.gerrit.server.change.HashtagsUtil.cleanupHashtag;
+import static com.google.gerrit.server.git.MultiProgressMonitor.UNKNOWN;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.COMMAND_REJECTION_MESSAGE_FOOTER;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.PUSH_OPTION_SKIP_VALIDATION;
+import static com.google.gerrit.server.git.receive.ReceiveConstants.SAME_CHANGE_ID_IN_MULTIPLE_CHANGES;
+import static com.google.gerrit.server.git.validators.CommitValidators.NEW_PATCHSET_PATTERN;
+import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparingInt;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.NOT_ATTEMPTED;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.OK;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_MISSING_OBJECT;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_NONFASTFORWARD;
+import static org.eclipse.jgit.transport.ReceiveCommand.Result.REJECTED_OTHER_REASON;
+
+import com.google.common.base.Function;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.BiMap;
+import com.google.common.collect.HashBiMap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.LinkedListMultimap;
+import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Sets;
+import com.google.common.collect.SortedSetMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.extensions.api.changes.HashtagsInput;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.registration.DynamicMap.Entry;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.SetHashtagsOp;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.edit.ChangeEdit;
+import com.google.gerrit.server.edit.ChangeEditUtil;
+import com.google.gerrit.server.events.CommitReceivedEvent;
+import com.google.gerrit.server.git.BanCommit;
+import com.google.gerrit.server.git.ChangeReportFormatter;
+import com.google.gerrit.server.git.GroupCollector;
+import com.google.gerrit.server.git.MergeOp;
+import com.google.gerrit.server.git.MergeOpRepoManager;
+import com.google.gerrit.server.git.MergedByPushOp;
+import com.google.gerrit.server.git.MultiProgressMonitor;
+import com.google.gerrit.server.git.MultiProgressMonitor.Task;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.git.ReceivePackInitializer;
+import com.google.gerrit.server.git.SubmoduleException;
+import com.google.gerrit.server.git.SubmoduleOp;
+import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.git.ValidationError;
+import com.google.gerrit.server.git.validators.CommitValidationException;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.git.validators.RefOperationValidationException;
+import com.google.gerrit.server.git.validators.RefOperationValidators;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.mail.MailUtil.MailRecipients;
+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.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.ChangePermission;
+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.RefPermission;
+import com.google.gerrit.server.project.CreateRefControl;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.ssh.SshInfo;
+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.Context;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.update.RepoOnlyOp;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gerrit.server.util.LabelVote;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gerrit.util.cli.CmdLineParser;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.io.StringWriter;
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.regex.Matcher;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.IncorrectObjectTypeException;
+import org.eclipse.jgit.errors.MissingObjectException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.FooterLine;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.revwalk.filter.RevFilter;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.transport.ReceiveCommand.Result;
+import org.eclipse.jgit.transport.ReceivePack;
+import org.kohsuke.args4j.CmdLineException;
+import org.kohsuke.args4j.Option;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Receives change upload using the Git receive-pack protocol. */
+class ReceiveCommits {
+  private static final Logger log = LoggerFactory.getLogger(ReceiveCommits.class);
+
+  private enum ReceiveError {
+    CONFIG_UPDATE(
+        "You are not allowed to perform this operation.\n"
+            + "Configuration changes can only be pushed by project owners\n"
+            + "who also have 'Push' rights on "
+            + RefNames.REFS_CONFIG),
+    UPDATE(
+        "You are not allowed to perform this operation.\n"
+            + "To push into this reference you need 'Push' rights."),
+    DELETE(
+        "You need 'Delete Reference' rights or 'Push' rights with the \n"
+            + "'Force Push' flag set to delete references."),
+    DELETE_CHANGES("Cannot delete from '" + REFS_CHANGES + "'"),
+    CODE_REVIEW(
+        "You need 'Push' rights to upload code review requests.\n"
+            + "Verify that you are pushing to the right branch.");
+
+    private final String value;
+
+    ReceiveError(String value) {
+      this.value = value;
+    }
+
+    String get() {
+      return value;
+    }
+  }
+
+  interface Factory {
+    ReceiveCommits create(
+        ProjectControl projectControl,
+        ReceivePack receivePack,
+        AllRefsWatcher allRefsWatcher,
+        SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers);
+  }
+
+  private class ReceivePackMessageSender implements MessageSender {
+    @Override
+    public void sendMessage(String what) {
+      rp.sendMessage(what);
+    }
+
+    @Override
+    public void sendError(String what) {
+      rp.sendError(what);
+    }
+
+    @Override
+    public void sendBytes(byte[] what) {
+      sendBytes(what, 0, what.length);
+    }
+
+    @Override
+    public void sendBytes(byte[] what, int off, int len) {
+      try {
+        rp.getMessageOutputStream().write(what, off, len);
+      } catch (IOException e) {
+        // Ignore write failures (matching JGit behavior).
+      }
+    }
+
+    @Override
+    public void flush() {
+      try {
+        rp.getMessageOutputStream().flush();
+      } catch (IOException e) {
+        // Ignore write failures (matching JGit behavior).
+      }
+    }
+  }
+
+  private static final Function<Exception, RestApiException> INSERT_EXCEPTION =
+      new Function<Exception, RestApiException>() {
+        @Override
+        public RestApiException apply(Exception input) {
+          if (input instanceof RestApiException) {
+            return (RestApiException) input;
+          } else if ((input instanceof ExecutionException)
+              && (input.getCause() instanceof RestApiException)) {
+            return (RestApiException) input.getCause();
+          }
+          return new RestApiException("Error inserting change/patchset", input);
+        }
+      };
+
+  // ReceiveCommits has a lot of fields, sorry. Here and in the constructor they are split up
+  // somewhat, and kept sorted lexicographically within sections, except where later assignments
+  // depend on previous ones.
+
+  // Injected fields.
+  private final AccountResolver accountResolver;
+  private final AccountsUpdate.Server accountsUpdate;
+  private final AllProjectsName allProjectsName;
+  private final BatchUpdate.Factory batchUpdateFactory;
+  private final ChangeEditUtil editUtil;
+  private final ChangeIndexer indexer;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final ChangeNotes.Factory notesFactory;
+  private final CmdLineParser.Factory optionParserFactory;
+  private final CommitValidators.Factory commitValidatorsFactory;
+  private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
+  private final DynamicSet<ReceivePackInitializer> initializers;
+  private final IdentifiedUser user;
+  private final MergedByPushOp.Factory mergedByPushOpFactory;
+  private final NotesMigration notesMigration;
+  private final PatchSetInfoFactory patchSetInfoFactory;
+  private final PatchSetUtil psUtil;
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Provider<MergeOp> mergeOpProvider;
+  private final Provider<MergeOpRepoManager> ormProvider;
+  private final ReceiveConfig receiveConfig;
+  private final RefOperationValidators.Factory refValidatorsFactory;
+  private final ReplaceOp.Factory replaceOpFactory;
+  private final RequestScopePropagator requestScopePropagator;
+  private final ReviewDb db;
+  private final Sequences seq;
+  private final SetHashtagsOp.Factory hashtagsFactory;
+  private final SshInfo sshInfo;
+  private final SubmoduleOp.Factory subOpFactory;
+  private final TagCache tagCache;
+  private final CreateRefControl createRefControl;
+
+  // Assisted injected fields.
+  private final AllRefsWatcher allRefsWatcher;
+  private final ImmutableSetMultimap<ReviewerStateInternal, Account.Id> extraReviewers;
+  private final ProjectControl projectControl;
+  private final ReceivePack rp;
+
+  // Immutable fields derived from constructor arguments.
+  private final LabelTypes labelTypes;
+  private final NoteMap rejectCommits;
+  private final PermissionBackend.ForProject permissions;
+  private final Project project;
+  private final Repository repo;
+  private final RequestId receiveId;
+
+  // Collections populated during processing.
+  private final List<UpdateGroupsRequest> updateGroups;
+  private final List<ValidationMessage> messages;
+  private final ListMultimap<ReceiveError, String> errors;
+  private final ListMultimap<String, String> pushOptions;
+  private final Map<Change.Id, ReplaceRequest> replaceByChange;
+  private final Set<ObjectId> validCommits;
+
+  /**
+   * Actual commands to be executed, as opposed to the mix of actual and magic commands that were
+   * provided over the wire.
+   *
+   * <p>Excludes commands executed implicitly as part of other {@link BatchUpdateOp}s, such as
+   * creating patch set refs.
+   */
+  private final List<ReceiveCommand> actualCommands;
+
+  // Collections lazily populated during processing.
+  private List<CreateRequest> newChanges;
+  private ListMultimap<Change.Id, Ref> refsByChange;
+  private ListMultimap<ObjectId, Ref> refsById;
+
+  // Other settings populated during processing.
+  private MagicBranchInput magicBranch;
+  private boolean newChangeForAllNotInTarget;
+  private String setFullNameTo;
+  private boolean setChangeAsPrivate;
+
+  // Handles for outputting back over the wire to the end user.
+  private Task newProgress;
+  private Task replaceProgress;
+  private Task closeProgress;
+  private Task commandProgress;
+  private MessageSender messageSender;
+  private final ChangeReportFormatter changeFormatter;
+
+  @Inject
+  ReceiveCommits(
+      AccountResolver accountResolver,
+      AccountsUpdate.Server accountsUpdate,
+      AllProjectsName allProjectsName,
+      BatchUpdate.Factory batchUpdateFactory,
+      ChangeEditUtil editUtil,
+      ChangeIndexer indexer,
+      ChangeInserter.Factory changeInserterFactory,
+      ChangeNotes.Factory notesFactory,
+      CmdLineParser.Factory optionParserFactory,
+      CommitValidators.Factory commitValidatorsFactory,
+      DynamicMap<ProjectConfigEntry> pluginConfigEntries,
+      DynamicSet<ReceivePackInitializer> initializers,
+      MergedByPushOp.Factory mergedByPushOpFactory,
+      NotesMigration notesMigration,
+      PatchSetInfoFactory patchSetInfoFactory,
+      PatchSetUtil psUtil,
+      PermissionBackend permissionBackend,
+      ProjectCache projectCache,
+      Provider<InternalChangeQuery> queryProvider,
+      Provider<MergeOp> mergeOpProvider,
+      Provider<MergeOpRepoManager> ormProvider,
+      ReceiveConfig receiveConfig,
+      RefOperationValidators.Factory refValidatorsFactory,
+      ReplaceOp.Factory replaceOpFactory,
+      RequestScopePropagator requestScopePropagator,
+      ReviewDb db,
+      Sequences seq,
+      SetHashtagsOp.Factory hashtagsFactory,
+      SshInfo sshInfo,
+      SubmoduleOp.Factory subOpFactory,
+      TagCache tagCache,
+      CreateRefControl createRefControl,
+      DynamicItem<ChangeReportFormatter> changeFormatterProvider,
+      @Assisted ProjectControl projectControl,
+      @Assisted ReceivePack rp,
+      @Assisted AllRefsWatcher allRefsWatcher,
+      @Assisted SetMultimap<ReviewerStateInternal, Account.Id> extraReviewers)
+      throws IOException {
+    // Injected fields.
+    this.accountResolver = accountResolver;
+    this.accountsUpdate = accountsUpdate;
+    this.allProjectsName = allProjectsName;
+    this.batchUpdateFactory = batchUpdateFactory;
+    this.changeInserterFactory = changeInserterFactory;
+    this.commitValidatorsFactory = commitValidatorsFactory;
+    this.changeFormatter = changeFormatterProvider.get();
+    this.user = projectControl.getUser().asIdentifiedUser();
+    this.db = db;
+    this.editUtil = editUtil;
+    this.hashtagsFactory = hashtagsFactory;
+    this.indexer = indexer;
+    this.initializers = initializers;
+    this.mergeOpProvider = mergeOpProvider;
+    this.mergedByPushOpFactory = mergedByPushOpFactory;
+    this.notesFactory = notesFactory;
+    this.notesMigration = notesMigration;
+    this.optionParserFactory = optionParserFactory;
+    this.ormProvider = ormProvider;
+    this.patchSetInfoFactory = patchSetInfoFactory;
+    this.permissionBackend = permissionBackend;
+    this.pluginConfigEntries = pluginConfigEntries;
+    this.projectCache = projectCache;
+    this.psUtil = psUtil;
+    this.queryProvider = queryProvider;
+    this.receiveConfig = receiveConfig;
+    this.refValidatorsFactory = refValidatorsFactory;
+    this.replaceOpFactory = replaceOpFactory;
+    this.requestScopePropagator = requestScopePropagator;
+    this.seq = seq;
+    this.sshInfo = sshInfo;
+    this.subOpFactory = subOpFactory;
+    this.tagCache = tagCache;
+    this.createRefControl = createRefControl;
+
+    // Assisted injected fields.
+    this.allRefsWatcher = allRefsWatcher;
+    this.extraReviewers = ImmutableSetMultimap.copyOf(extraReviewers);
+    this.projectControl = projectControl;
+    this.rp = rp;
+
+    // Immutable fields derived from constructor arguments.
+    repo = rp.getRepository();
+    project = projectControl.getProject();
+    labelTypes = projectControl.getProjectState().getLabelTypes();
+    permissions = permissionBackend.user(user).project(project.getNameKey());
+    receiveId = RequestId.forProject(project.getNameKey());
+    rejectCommits = BanCommit.loadRejectCommitsMap(rp.getRepository(), rp.getRevWalk());
+
+    // Collections populated during processing.
+    actualCommands = new ArrayList<>();
+    errors = LinkedListMultimap.create();
+    messages = new ArrayList<>();
+    pushOptions = LinkedListMultimap.create();
+    replaceByChange = new LinkedHashMap<>();
+    updateGroups = new ArrayList<>();
+    validCommits = new HashSet<>();
+
+    // Collections lazily populated during processing.
+    newChanges = Collections.emptyList();
+
+    // Other settings populated during processing.
+    newChangeForAllNotInTarget =
+        projectControl.getProjectState().isCreateNewChangeForAllNotInTarget();
+
+    // Handles for outputting back over the wire to the end user.
+    messageSender = new ReceivePackMessageSender();
+  }
+
+  void init() {
+    for (ReceivePackInitializer i : initializers) {
+      i.init(projectControl.getProject().getNameKey(), rp);
+    }
+  }
+
+  /** Set a message sender for this operation. */
+  void setMessageSender(MessageSender ms) {
+    messageSender = ms != null ? ms : new ReceivePackMessageSender();
+  }
+
+  MessageSender getMessageSender() {
+    if (messageSender == null) {
+      setMessageSender(null);
+    }
+    return messageSender;
+  }
+
+  Project getProject() {
+    return project;
+  }
+
+  private void addMessage(String message) {
+    messages.add(new CommitValidationMessage(message, false));
+  }
+
+  void addError(String error) {
+    messages.add(new CommitValidationMessage(error, true));
+  }
+
+  void sendMessages() {
+    for (ValidationMessage m : messages) {
+      if (m.isError()) {
+        messageSender.sendError(m.getMessage());
+      } else {
+        messageSender.sendMessage(m.getMessage());
+      }
+    }
+  }
+
+  void processCommands(Collection<ReceiveCommand> commands, MultiProgressMonitor progress) {
+    newProgress = progress.beginSubTask("new", UNKNOWN);
+    replaceProgress = progress.beginSubTask("updated", UNKNOWN);
+    closeProgress = progress.beginSubTask("closed", UNKNOWN);
+    commandProgress = progress.beginSubTask("refs", UNKNOWN);
+
+    try {
+      parseCommands(commands);
+    } catch (PermissionBackendException | NoSuchProjectException | IOException err) {
+      for (ReceiveCommand cmd : actualCommands) {
+        if (cmd.getResult() == NOT_ATTEMPTED) {
+          cmd.setResult(REJECTED_OTHER_REASON, "internal server error");
+        }
+      }
+      logError(String.format("Failed to process refs in %s", project.getName()), err);
+    }
+    if (magicBranch != null && magicBranch.cmd.getResult() == NOT_ATTEMPTED) {
+      selectNewAndReplacedChangesFromMagicBranch();
+    }
+    preparePatchSetsForReplace();
+    insertChangesAndPatchSets();
+    newProgress.end();
+    replaceProgress.end();
+
+    if (!errors.isEmpty()) {
+      logDebug("Handling error conditions: {}", errors.keySet());
+      for (ReceiveError error : errors.keySet()) {
+        rp.sendMessage(buildError(error, errors.get(error)));
+      }
+      rp.sendMessage(String.format("User: %s", displayName(user)));
+      rp.sendMessage(COMMAND_REJECTION_MESSAGE_FOOTER);
+    }
+
+    Set<Branch.NameKey> branches = new HashSet<>();
+    for (ReceiveCommand c : actualCommands) {
+      // Most post-update steps should happen in UpdateOneRefOp#postUpdate. The only steps that
+      // should happen in this loop are things that can't happen within one BatchUpdate because they
+      // involve kicking off an additional BatchUpdate.
+      if (c.getResult() != OK) {
+        continue;
+      }
+      if (isHead(c) || isConfig(c)) {
+        switch (c.getType()) {
+          case CREATE:
+          case UPDATE:
+          case UPDATE_NONFASTFORWARD:
+            autoCloseChanges(c);
+            branches.add(new Branch.NameKey(project.getNameKey(), c.getRefName()));
+            break;
+
+          case DELETE:
+            break;
+        }
+      }
+    }
+
+    // Update superproject gitlinks if required.
+    if (!branches.isEmpty()) {
+      try (MergeOpRepoManager orm = ormProvider.get()) {
+        orm.setContext(db, TimeUtil.nowTs(), user, receiveId);
+        SubmoduleOp op = subOpFactory.create(branches, orm);
+        op.updateSuperProjects();
+      } catch (SubmoduleException e) {
+        logError("Can't update the superprojects", e);
+      }
+    }
+
+    // Update account info with details discovered during commit walking.
+    updateAccountInfo();
+
+    closeProgress.end();
+    commandProgress.end();
+    progress.end();
+    reportMessages();
+  }
+
+  private void reportMessages() {
+    List<CreateRequest> created =
+        newChanges.stream().filter(r -> r.change != null).collect(toList());
+    if (!created.isEmpty()) {
+      addMessage("");
+      addMessage("New Changes:");
+      for (CreateRequest c : created) {
+        addMessage(
+            changeFormatter.newChange(
+                ChangeReportFormatter.Input.builder().setChange(c.change).build()));
+      }
+      addMessage("");
+    }
+
+    List<ReplaceRequest> updated =
+        replaceByChange
+            .values()
+            .stream()
+            .filter(r -> !r.skip && r.inputCommand.getResult() == OK)
+            .sorted(comparingInt(r -> r.notes.getChangeId().get()))
+            .collect(toList());
+    if (!updated.isEmpty()) {
+      addMessage("");
+      addMessage("Updated Changes:");
+      boolean edit = magicBranch != null && (magicBranch.edit || magicBranch.draft);
+      for (ReplaceRequest u : updated) {
+        String subject;
+        Change change = u.notes.getChange();
+        if (edit) {
+          try {
+            subject = rp.getRevWalk().parseCommit(u.newCommitId).getShortMessage();
+          } catch (IOException e) {
+            // Log and fall back to original change subject
+            logWarn("failed to get subject for edit patch set", e);
+            subject = change.getSubject();
+          }
+        } else {
+          subject = u.info.getSubject();
+        }
+
+        boolean isPrivate = change.isPrivate();
+        boolean wip = change.isWorkInProgress();
+        if (magicBranch != null) {
+          if (magicBranch.isPrivate) {
+            isPrivate = true;
+          } else if (magicBranch.removePrivate) {
+            isPrivate = false;
+          }
+          if (magicBranch.workInProgress) {
+            wip = true;
+          } else if (magicBranch.ready) {
+            wip = false;
+          }
+        }
+
+        ChangeReportFormatter.Input input =
+            ChangeReportFormatter.Input.builder()
+                .setChange(change)
+                .setSubject(subject)
+                .setIsEdit(edit)
+                .setIsPrivate(isPrivate)
+                .setIsWorkInProgress(wip)
+                .build();
+        addMessage(changeFormatter.changeUpdated(input));
+      }
+      addMessage("");
+    }
+
+    // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
+    if (magicBranch != null && magicBranch.publish) {
+      addMessage("Pushing to refs/publish/* is deprecated, use refs/for/* instead.");
+    }
+  }
+
+  private void insertChangesAndPatchSets() {
+    ReceiveCommand magicBranchCmd = magicBranch != null ? magicBranch.cmd : null;
+    if (magicBranchCmd != null && magicBranchCmd.getResult() != NOT_ATTEMPTED) {
+      logWarn(
+          String.format(
+              "Skipping change updates on %s because ref update failed: %s %s",
+              project.getName(),
+              magicBranchCmd.getResult(),
+              Strings.nullToEmpty(magicBranchCmd.getMessage())));
+      return;
+    }
+
+    try (BatchUpdate bu =
+            batchUpdateFactory.create(
+                db, project.getNameKey(), user.materializedCopy(), TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins).updateChangesInParallel();
+      bu.setRequestId(receiveId);
+      bu.setRefLogMessage("push");
+
+      logDebug("Adding {} replace requests", newChanges.size());
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        replace.addOps(bu, replaceProgress);
+      }
+
+      logDebug("Adding {} create requests", newChanges.size());
+      for (CreateRequest create : newChanges) {
+        create.addOps(bu);
+      }
+
+      logDebug("Adding {} group update requests", newChanges.size());
+      updateGroups.forEach(r -> r.addOps(bu));
+
+      logDebug("Adding {} additional ref updates", actualCommands.size());
+      actualCommands.forEach(c -> bu.addRepoOnlyOp(new UpdateOneRefOp(c)));
+
+      logDebug("Executing batch");
+      try {
+        bu.execute();
+      } catch (UpdateException e) {
+        throw INSERT_EXCEPTION.apply(e);
+      }
+      if (magicBranchCmd != null) {
+        magicBranchCmd.setResult(OK);
+      }
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        String rejectMessage = replace.getRejectMessage();
+        if (rejectMessage == null) {
+          if (replace.inputCommand.getResult() == NOT_ATTEMPTED) {
+            // Not necessarily the magic branch, so need to set OK on the original value.
+            replace.inputCommand.setResult(OK);
+          }
+        } else {
+          logDebug("Rejecting due to message from ReplaceOp");
+          reject(replace.inputCommand, rejectMessage);
+        }
+      }
+
+    } catch (ResourceConflictException e) {
+      addMessage(e.getMessage());
+      reject(magicBranchCmd, "conflict");
+    } catch (RestApiException | IOException err) {
+      logError("Can't insert change/patch set for " + project.getName(), err);
+      reject(magicBranchCmd, "internal server error: " + err.getMessage());
+    }
+
+    if (magicBranch != null && magicBranch.submit) {
+      try {
+        submit(newChanges, replaceByChange.values());
+      } catch (ResourceConflictException e) {
+        addMessage(e.getMessage());
+        reject(magicBranchCmd, "conflict");
+      } catch (RestApiException
+          | OrmException
+          | UpdateException
+          | IOException
+          | ConfigInvalidException
+          | PermissionBackendException e) {
+        logError("Error submitting changes to " + project.getName(), e);
+        reject(magicBranchCmd, "error during submit");
+      }
+    }
+  }
+
+  private String buildError(ReceiveError error, List<String> branches) {
+    StringBuilder sb = new StringBuilder();
+    if (branches.size() == 1) {
+      sb.append("Branch ").append(branches.get(0)).append(":\n");
+      sb.append(error.get());
+      return sb.toString();
+    }
+    sb.append("Branches");
+    String delim = " ";
+    for (String branch : branches) {
+      sb.append(delim).append(branch);
+      delim = ", ";
+    }
+    return sb.append(":\n").append(error.get()).toString();
+  }
+
+  private static String displayName(IdentifiedUser user) {
+    String displayName = user.getUserName();
+    if (displayName == null) {
+      displayName = user.getAccount().getPreferredEmail();
+    }
+    return displayName;
+  }
+
+  private void parseCommands(Collection<ReceiveCommand> commands)
+      throws PermissionBackendException, NoSuchProjectException, IOException {
+    List<String> optionList = rp.getPushOptions();
+    if (optionList != null) {
+      for (String option : optionList) {
+        int e = option.indexOf('=');
+        if (e > 0) {
+          pushOptions.put(option.substring(0, e), option.substring(e + 1));
+        } else {
+          pushOptions.put(option, "");
+        }
+      }
+    }
+
+    logDebug("Parsing {} commands", commands.size());
+    for (ReceiveCommand cmd : commands) {
+      if (cmd.getResult() != NOT_ATTEMPTED) {
+        // Already rejected by the core receive process.
+        logDebug("Already processed by core: {} {}", cmd.getResult(), cmd);
+        continue;
+      }
+
+      if (!Repository.isValidRefName(cmd.getRefName()) || cmd.getRefName().contains("//")) {
+        reject(cmd, "not valid ref");
+        continue;
+      }
+
+      if (MagicBranch.isMagicBranch(cmd.getRefName())) {
+        parseMagicBranch(cmd);
+        continue;
+      }
+
+      if (projectControl.getProjectState().isAllUsers()
+          && RefNames.REFS_USERS_SELF.equals(cmd.getRefName())) {
+        String newName = RefNames.refsUsers(user.getAccountId());
+        logDebug("Swapping out command for {} to {}", RefNames.REFS_USERS_SELF, newName);
+        final ReceiveCommand orgCmd = cmd;
+        cmd =
+            new ReceiveCommand(cmd.getOldId(), cmd.getNewId(), newName, cmd.getType()) {
+              @Override
+              public void setResult(Result s, String m) {
+                super.setResult(s, m);
+                orgCmd.setResult(s, m);
+              }
+            };
+      }
+
+      Matcher m = NEW_PATCHSET_PATTERN.matcher(cmd.getRefName());
+      if (m.matches()) {
+        // The referenced change must exist and must still be open.
+        //
+        Change.Id changeId = Change.Id.parse(m.group(1));
+        parseReplaceCommand(cmd, changeId);
+        continue;
+      }
+
+      switch (cmd.getType()) {
+        case CREATE:
+          parseCreate(cmd);
+          break;
+
+        case UPDATE:
+          parseUpdate(cmd);
+          break;
+
+        case DELETE:
+          parseDelete(cmd);
+          break;
+
+        case UPDATE_NONFASTFORWARD:
+          parseRewind(cmd);
+          break;
+
+        default:
+          reject(cmd, "prohibited by Gerrit: unknown command type " + cmd.getType());
+          continue;
+      }
+
+      if (cmd.getResult() != NOT_ATTEMPTED) {
+        continue;
+      }
+
+      if (isConfig(cmd)) {
+        logDebug("Processing {} command", cmd.getRefName());
+        if (!projectControl.isOwner()) {
+          reject(cmd, "not project owner");
+          continue;
+        }
+
+        switch (cmd.getType()) {
+          case CREATE:
+          case UPDATE:
+          case UPDATE_NONFASTFORWARD:
+            try {
+              ProjectConfig cfg = new ProjectConfig(project.getNameKey());
+              cfg.load(rp.getRevWalk(), cmd.getNewId());
+              if (!cfg.getValidationErrors().isEmpty()) {
+                addError("Invalid project configuration:");
+                for (ValidationError err : cfg.getValidationErrors()) {
+                  addError("  " + err.getMessage());
+                }
+                reject(cmd, "invalid project configuration");
+                logError(
+                    "User "
+                        + user.getUserName()
+                        + " tried to push invalid project configuration "
+                        + cmd.getNewId().name()
+                        + " for "
+                        + project.getName());
+                continue;
+              }
+              Project.NameKey newParent = cfg.getProject().getParent(allProjectsName);
+              Project.NameKey oldParent = project.getParent(allProjectsName);
+              if (oldParent == null) {
+                // update of the 'All-Projects' project
+                if (newParent != null) {
+                  reject(cmd, "invalid project configuration: root project cannot have parent");
+                  continue;
+                }
+              } else {
+                if (!oldParent.equals(newParent)) {
+                  try {
+                    permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+                  } catch (AuthException e) {
+                    reject(cmd, "invalid project configuration: only Gerrit admin can set parent");
+                    continue;
+                  }
+                }
+
+                if (projectCache.get(newParent) == null) {
+                  reject(cmd, "invalid project configuration: parent does not exist");
+                  continue;
+                }
+              }
+
+              for (Entry<ProjectConfigEntry> e : pluginConfigEntries) {
+                PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
+                ProjectConfigEntry configEntry = e.getProvider().get();
+                String value = pluginCfg.getString(e.getExportName());
+                String oldValue =
+                    projectControl
+                        .getProjectState()
+                        .getConfig()
+                        .getPluginConfig(e.getPluginName())
+                        .getString(e.getExportName());
+                if (configEntry.getType() == ProjectConfigEntryType.ARRAY) {
+                  oldValue =
+                      Arrays.stream(
+                              projectControl
+                                  .getProjectState()
+                                  .getConfig()
+                                  .getPluginConfig(e.getPluginName())
+                                  .getStringList(e.getExportName()))
+                          .collect(joining("\n"));
+                }
+
+                if ((value == null ? oldValue != null : !value.equals(oldValue))
+                    && !configEntry.isEditable(projectControl.getProjectState())) {
+                  reject(
+                      cmd,
+                      String.format(
+                          "invalid project configuration: Not allowed to set parameter"
+                              + " '%s' of plugin '%s' on project '%s'.",
+                          e.getExportName(), e.getPluginName(), project.getName()));
+                  continue;
+                }
+
+                if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
+                    && value != null
+                    && !configEntry.getPermittedValues().contains(value)) {
+                  reject(
+                      cmd,
+                      String.format(
+                          "invalid project configuration: The value '%s' is "
+                              + "not permitted for parameter '%s' of plugin '%s'.",
+                          value, e.getExportName(), e.getPluginName()));
+                }
+              }
+            } catch (Exception e) {
+              reject(cmd, "invalid project configuration");
+              logError(
+                  "User "
+                      + user.getUserName()
+                      + " tried to push invalid project configuration "
+                      + cmd.getNewId().name()
+                      + " for "
+                      + project.getName(),
+                  e);
+              continue;
+            }
+            break;
+
+          case DELETE:
+            break;
+
+          default:
+            reject(
+                cmd,
+                "prohibited by Gerrit: don't know how to handle config update of type "
+                    + cmd.getType());
+            continue;
+        }
+      }
+    }
+  }
+
+  private void parseCreate(ReceiveCommand cmd)
+      throws PermissionBackendException, NoSuchProjectException, IOException {
+    RevObject obj;
+    try {
+      obj = rp.getRevWalk().parseAny(cmd.getNewId());
+    } catch (IOException err) {
+      logError(
+          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " creation",
+          err);
+      reject(cmd, "invalid object");
+      return;
+    }
+    logDebug("Creating {}", cmd);
+
+    if (isHead(cmd) && !isCommit(cmd)) {
+      return;
+    }
+
+    Branch.NameKey branch = new Branch.NameKey(project.getName(), cmd.getRefName());
+    try {
+      // Must pass explicit user instead of injecting a provider into CreateRefControl, since
+      // Provider<CurrentUser> within ReceiveCommits will always return anonymous.
+      createRefControl.checkCreateRef(Providers.of(user), rp.getRepository(), branch, obj);
+    } catch (AuthException denied) {
+      reject(cmd, "prohibited by Gerrit: " + denied.getMessage());
+      return;
+    }
+
+    if (!validRefOperation(cmd)) {
+      // validRefOperation sets messages, so no need to provide more feedback.
+      return;
+    }
+
+    validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+    actualCommands.add(cmd);
+  }
+
+  private void parseUpdate(ReceiveCommand cmd) throws PermissionBackendException {
+    logDebug("Updating {}", cmd);
+    boolean ok;
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.UPDATE);
+      ok = true;
+    } catch (AuthException err) {
+      ok = false;
+    }
+    if (ok) {
+      if (isHead(cmd) && !isCommit(cmd)) {
+        return;
+      }
+      if (!validRefOperation(cmd)) {
+        return;
+      }
+      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+      actualCommands.add(cmd);
+    } else {
+      if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
+        errors.put(ReceiveError.CONFIG_UPDATE, RefNames.REFS_CONFIG);
+      } else {
+        errors.put(ReceiveError.UPDATE, cmd.getRefName());
+      }
+      reject(cmd, "prohibited by Gerrit: ref update access denied");
+    }
+  }
+
+  private boolean isCommit(ReceiveCommand cmd) {
+    RevObject obj;
+    try {
+      obj = rp.getRevWalk().parseAny(cmd.getNewId());
+    } catch (IOException err) {
+      logError("Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName(), err);
+      reject(cmd, "invalid object");
+      return false;
+    }
+
+    if (obj instanceof RevCommit) {
+      return true;
+    }
+    reject(cmd, "not a commit");
+    return false;
+  }
+
+  private void parseDelete(ReceiveCommand cmd) throws PermissionBackendException {
+    logDebug("Deleting {}", cmd);
+    if (cmd.getRefName().startsWith(REFS_CHANGES)) {
+      errors.put(ReceiveError.DELETE_CHANGES, cmd.getRefName());
+      reject(cmd, "cannot delete changes");
+    } else if (canDelete(cmd)) {
+      if (!validRefOperation(cmd)) {
+        return;
+      }
+      actualCommands.add(cmd);
+    } else if (RefNames.REFS_CONFIG.equals(cmd.getRefName())) {
+      reject(cmd, "cannot delete project configuration");
+    } else {
+      errors.put(ReceiveError.DELETE, cmd.getRefName());
+      reject(cmd, "cannot delete references");
+    }
+  }
+
+  private boolean canDelete(ReceiveCommand cmd) throws PermissionBackendException {
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.DELETE);
+      return true;
+    } catch (AuthException e) {
+      return false;
+    }
+  }
+
+  private void parseRewind(ReceiveCommand cmd) throws PermissionBackendException {
+    RevCommit newObject;
+    try {
+      newObject = rp.getRevWalk().parseCommit(cmd.getNewId());
+    } catch (IncorrectObjectTypeException notCommit) {
+      newObject = null;
+    } catch (IOException err) {
+      logError(
+          "Invalid object " + cmd.getNewId().name() + " for " + cmd.getRefName() + " forced update",
+          err);
+      reject(cmd, "invalid object");
+      return;
+    }
+    logDebug("Rewinding {}", cmd);
+
+    if (newObject != null) {
+      validateNewCommits(new Branch.NameKey(project.getNameKey(), cmd.getRefName()), cmd);
+      if (cmd.getResult() != NOT_ATTEMPTED) {
+        return;
+      }
+    }
+
+    boolean ok;
+    try {
+      permissions.ref(cmd.getRefName()).check(RefPermission.FORCE_UPDATE);
+      ok = true;
+    } catch (AuthException err) {
+      ok = false;
+    }
+    if (ok) {
+      if (!validRefOperation(cmd)) {
+        return;
+      }
+      actualCommands.add(cmd);
+    } else {
+      cmd.setResult(
+          REJECTED_NONFASTFORWARD, " need '" + PermissionRule.FORCE_PUSH + "' privilege.");
+    }
+  }
+
+  static class MagicBranchInput {
+    private static final Splitter COMMAS = Splitter.on(',').omitEmptyStrings();
+
+    final ReceiveCommand cmd;
+    final LabelTypes labelTypes;
+    final NotesMigration notesMigration;
+    private final boolean defaultPublishComments;
+    Branch.NameKey dest;
+    PermissionBackend.ForRef perm;
+    Set<Account.Id> reviewer = Sets.newLinkedHashSet();
+    Set<Account.Id> cc = Sets.newLinkedHashSet();
+    Map<String, Short> labels = new HashMap<>();
+    String message;
+    List<RevCommit> baseCommit;
+    CmdLineParser clp;
+    Set<String> hashtags = new HashSet<>();
+
+    @Option(name = "--base", metaVar = "BASE", usage = "merge base of changes")
+    List<ObjectId> base;
+
+    @Option(name = "--topic", metaVar = "NAME", usage = "attach topic to changes")
+    String topic;
+
+    @Option(
+        name = "--draft",
+        usage =
+            "Will be removed. Before that, this option will be mapped to '--private'"
+                + "for new changes and '--edit' for existing changes")
+    boolean draft;
+
+    boolean publish;
+
+    @Option(name = "--private", usage = "mark new/updated change as private")
+    boolean isPrivate;
+
+    @Option(name = "--remove-private", usage = "remove privacy flag from updated change")
+    boolean removePrivate;
+
+    @Option(
+        name = "--wip",
+        aliases = {"-work-in-progress"},
+        usage = "mark change as work in progress")
+    boolean workInProgress;
+
+    @Option(name = "--ready", usage = "mark change as ready")
+    boolean ready;
+
+    @Option(
+        name = "--edit",
+        aliases = {"-e"},
+        usage = "upload as change edit")
+    boolean edit;
+
+    @Option(name = "--submit", usage = "immediately submit the change")
+    boolean submit;
+
+    @Option(name = "--merged", usage = "create single change for a merged commit")
+    boolean merged;
+
+    @Option(name = "--publish-comments", usage = "publish all draft comments on updated changes")
+    private boolean publishComments;
+
+    @Option(
+        name = "--no-publish-comments",
+        aliases = {"--np"},
+        usage = "do not publish draft comments")
+    private boolean noPublishComments;
+
+    @Option(
+        name = "--notify",
+        usage =
+            "Notify handling that defines to whom email notifications "
+                + "should be sent. Allowed values are NONE, OWNER, "
+                + "OWNER_REVIEWERS, ALL. If not set, the default is ALL.")
+    private NotifyHandling notify;
+
+    @Option(name = "--notify-to", metaVar = "USER", usage = "user that should be notified")
+    List<Account.Id> tos = new ArrayList<>();
+
+    @Option(name = "--notify-cc", metaVar = "USER", usage = "user that should be CC'd")
+    List<Account.Id> ccs = new ArrayList<>();
+
+    @Option(name = "--notify-bcc", metaVar = "USER", usage = "user that should be BCC'd")
+    List<Account.Id> bccs = new ArrayList<>();
+
+    @Option(
+        name = "--reviewer",
+        aliases = {"-r"},
+        metaVar = "EMAIL",
+        usage = "add reviewer to changes")
+    void reviewer(Account.Id id) {
+      reviewer.add(id);
+    }
+
+    @Option(name = "--cc", metaVar = "EMAIL", usage = "notify user by CC")
+    void cc(Account.Id id) {
+      cc.add(id);
+    }
+
+    @Option(
+        name = "--label",
+        aliases = {"-l"},
+        metaVar = "LABEL+VALUE",
+        usage = "label(s) to assign (defaults to +1 if no value provided")
+    void addLabel(String token) throws CmdLineException {
+      LabelVote v = LabelVote.parse(token);
+      try {
+        LabelType.checkName(v.label());
+        ApprovalsUtil.checkLabel(labelTypes, v.label(), v.value());
+      } catch (BadRequestException e) {
+        throw clp.reject(e.getMessage());
+      }
+      labels.put(v.label(), v.value());
+    }
+
+    @Option(
+        name = "--message",
+        aliases = {"-m"},
+        metaVar = "MESSAGE",
+        usage = "Comment message to apply to the review")
+    void addMessage(String token) {
+      // Many characters have special meaning in the context of a git ref.
+      //
+      // Clients can use underscores to represent spaces.
+      message = token.replace("_", " ");
+      try {
+        // Other characters can be represented using percent-encoding.
+        message = URLDecoder.decode(message, UTF_8.name());
+      } catch (IllegalArgumentException e) {
+        // Ignore decoding errors; leave message as percent-encoded.
+      } catch (UnsupportedEncodingException e) {
+        // This shouldn't happen; surely URLDecoder recognizes UTF-8.
+        throw new IllegalStateException(e);
+      }
+    }
+
+    @Option(
+        name = "--hashtag",
+        aliases = {"-t"},
+        metaVar = "HASHTAG",
+        usage = "add hashtag to changes")
+    void addHashtag(String token) throws CmdLineException {
+      if (!notesMigration.readChanges()) {
+        throw clp.reject("cannot add hashtags; noteDb is disabled");
+      }
+      String hashtag = cleanupHashtag(token);
+      if (!hashtag.isEmpty()) {
+        hashtags.add(hashtag);
+      }
+      // TODO(dpursehouse): validate hashtags
+    }
+
+    MagicBranchInput(
+        IdentifiedUser user,
+        ReceiveCommand cmd,
+        LabelTypes labelTypes,
+        NotesMigration notesMigration) {
+      this.cmd = cmd;
+      this.draft = cmd.getRefName().startsWith(MagicBranch.NEW_DRAFT_CHANGE);
+      this.publish = cmd.getRefName().startsWith(MagicBranch.NEW_PUBLISH_CHANGE);
+      this.labelTypes = labelTypes;
+      this.notesMigration = notesMigration;
+      GeneralPreferencesInfo prefs = user.getAccount().getGeneralPreferencesInfo();
+      this.defaultPublishComments =
+          prefs != null
+              ? firstNonNull(
+                  user.getAccount().getGeneralPreferencesInfo().publishCommentsOnPush, false)
+              : false;
+    }
+
+    MailRecipients getMailRecipients() {
+      return new MailRecipients(reviewer, cc);
+    }
+
+    ListMultimap<RecipientType, Account.Id> getAccountsToNotify() {
+      ListMultimap<RecipientType, Account.Id> accountsToNotify =
+          MultimapBuilder.hashKeys().arrayListValues().build();
+      accountsToNotify.putAll(RecipientType.TO, tos);
+      accountsToNotify.putAll(RecipientType.CC, ccs);
+      accountsToNotify.putAll(RecipientType.BCC, bccs);
+      return accountsToNotify;
+    }
+
+    boolean shouldPublishComments() {
+      if (publishComments) {
+        return true;
+      } else if (noPublishComments) {
+        return false;
+      }
+      return defaultPublishComments;
+    }
+
+    String parse(
+        CmdLineParser clp,
+        Repository repo,
+        Set<String> refs,
+        ListMultimap<String, String> pushOptions)
+        throws CmdLineException {
+      String ref = RefNames.fullName(MagicBranch.getDestBranchName(cmd.getRefName()));
+
+      ListMultimap<String, String> options = LinkedListMultimap.create(pushOptions);
+      int optionStart = ref.indexOf('%');
+      if (0 < optionStart) {
+        for (String s : COMMAS.split(ref.substring(optionStart + 1))) {
+          int e = s.indexOf('=');
+          if (0 < e) {
+            options.put(s.substring(0, e), s.substring(e + 1));
+          } else {
+            options.put(s, "");
+          }
+        }
+        ref = ref.substring(0, optionStart);
+      }
+
+      if (!options.isEmpty()) {
+        clp.parseOptionMap(options);
+      }
+
+      // Split the destination branch by branch and topic. The topic
+      // suffix is entirely optional, so it might not even exist.
+      String head = readHEAD(repo);
+      int split = ref.length();
+      for (; ; ) {
+        String name = ref.substring(0, split);
+        if (refs.contains(name) || name.equals(head)) {
+          break;
+        }
+
+        split = name.lastIndexOf('/', split - 1);
+        if (split <= Constants.R_REFS.length()) {
+          return ref;
+        }
+      }
+      if (split < ref.length()) {
+        topic = Strings.emptyToNull(ref.substring(split + 1));
+      }
+      return ref.substring(0, split);
+    }
+
+    NotifyHandling getNotify() {
+      if (notify != null) {
+        return notify;
+      }
+      if (workInProgress) {
+        return NotifyHandling.OWNER;
+      }
+      return NotifyHandling.ALL;
+    }
+
+    NotifyHandling getNotify(ChangeNotes notes) {
+      if (notify != null) {
+        return notify;
+      }
+      if (workInProgress || (!ready && notes.getChange().isWorkInProgress())) {
+        return NotifyHandling.OWNER;
+      }
+      return NotifyHandling.ALL;
+    }
+  }
+
+  /**
+   * Gets an unmodifiable view of the pushOptions.
+   *
+   * <p>The collection is empty if the client does not support push options, or if the client did
+   * not send any options.
+   *
+   * @return an unmodifiable view of pushOptions.
+   */
+  @Nullable
+  ListMultimap<String, String> getPushOptions() {
+    return ImmutableListMultimap.copyOf(pushOptions);
+  }
+
+  private void parseMagicBranch(ReceiveCommand cmd) throws PermissionBackendException {
+    // Permit exactly one new change request per push.
+    if (magicBranch != null) {
+      reject(cmd, "duplicate request");
+      return;
+    }
+
+    logDebug("Found magic branch {}", cmd.getRefName());
+    magicBranch = new MagicBranchInput(user, cmd, labelTypes, notesMigration);
+    magicBranch.reviewer.addAll(extraReviewers.get(ReviewerStateInternal.REVIEWER));
+    magicBranch.cc.addAll(extraReviewers.get(ReviewerStateInternal.CC));
+
+    String ref;
+    CmdLineParser clp = optionParserFactory.create(magicBranch);
+    magicBranch.clp = clp;
+
+    try {
+      ref = magicBranch.parse(clp, repo, rp.getAdvertisedRefs().keySet(), pushOptions);
+    } catch (CmdLineException e) {
+      if (!clp.wasHelpRequestedByOption()) {
+        logDebug("Invalid branch syntax");
+        reject(cmd, e.getMessage());
+        return;
+      }
+      ref = null; // never happen
+    }
+
+    if (magicBranch.topic != null && magicBranch.topic.length() > ChangeUtil.TOPIC_MAX_LENGTH) {
+      reject(
+          cmd, String.format("topic length exceeds the limit (%s)", ChangeUtil.TOPIC_MAX_LENGTH));
+    }
+
+    if (clp.wasHelpRequestedByOption()) {
+      StringWriter w = new StringWriter();
+      w.write("\nHelp for refs/for/branch:\n\n");
+      clp.printUsage(w, null);
+      addMessage(w.toString());
+      reject(cmd, "see help");
+      return;
+    }
+    if (projectControl.getProjectState().isAllUsers() && RefNames.REFS_USERS_SELF.equals(ref)) {
+      logDebug("Handling {}", RefNames.REFS_USERS_SELF);
+      ref = RefNames.refsUsers(user.getAccountId());
+    }
+    if (!rp.getAdvertisedRefs().containsKey(ref)
+        && !ref.equals(readHEAD(repo))
+        && !ref.equals(RefNames.REFS_CONFIG)) {
+      logDebug("Ref {} not found", ref);
+      if (ref.startsWith(Constants.R_HEADS)) {
+        String n = ref.substring(Constants.R_HEADS.length());
+        reject(cmd, "branch " + n + " not found");
+      } else {
+        reject(cmd, ref + " not found");
+      }
+      return;
+    }
+
+    magicBranch.dest = new Branch.NameKey(project.getNameKey(), ref);
+    magicBranch.perm = permissions.ref(ref);
+    if (!projectControl.getProject().getState().permitsWrite()) {
+      reject(cmd, "project state does not permit write");
+      return;
+    }
+
+    try {
+      magicBranch.perm.check(RefPermission.CREATE_CHANGE);
+    } catch (AuthException denied) {
+      errors.put(ReceiveError.CODE_REVIEW, ref);
+      reject(cmd, denied.getMessage());
+      return;
+    }
+
+    // TODO(davido): Remove legacy support for drafts magic branch option
+    // after repo-tool supports private and work-in-progress changes.
+    if (magicBranch.draft && !receiveConfig.allowDrafts) {
+      errors.put(ReceiveError.CODE_REVIEW, ref);
+      reject(cmd, "draft workflow is disabled");
+      return;
+    }
+
+    if (magicBranch.isPrivate && magicBranch.removePrivate) {
+      reject(cmd, "the options 'private' and 'remove-private' are mutually exclusive");
+      return;
+    }
+
+    boolean privateByDefault = projectCache.get(project.getNameKey()).isPrivateByDefault();
+    setChangeAsPrivate =
+        magicBranch.draft
+            || magicBranch.isPrivate
+            || (privateByDefault && !magicBranch.removePrivate);
+
+    if (receiveConfig.disablePrivateChanges && setChangeAsPrivate) {
+      reject(cmd, "private changes are disabled");
+      return;
+    }
+
+    if (magicBranch.workInProgress && magicBranch.ready) {
+      reject(cmd, "the options 'wip' and 'ready' are mutually exclusive");
+      return;
+    }
+    if (magicBranch.publishComments && magicBranch.noPublishComments) {
+      reject(
+          cmd, "the options 'publish-comments' and 'no-publish-comments' are mutually exclusive");
+      return;
+    }
+
+    if (magicBranch.submit) {
+      try {
+        permissions.ref(ref).check(RefPermission.UPDATE_BY_SUBMIT);
+      } catch (AuthException e) {
+        reject(cmd, e.getMessage());
+        return;
+      }
+    }
+
+    RevWalk walk = rp.getRevWalk();
+    RevCommit tip;
+    try {
+      tip = walk.parseCommit(magicBranch.cmd.getNewId());
+      logDebug("Tip of push: {}", tip.name());
+    } catch (IOException ex) {
+      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
+      logError("Invalid pack upload; one or more objects weren't sent", ex);
+      return;
+    }
+
+    String destBranch = magicBranch.dest.get();
+    try {
+      if (magicBranch.merged) {
+        if (magicBranch.base != null) {
+          reject(cmd, "cannot use merged with base");
+          return;
+        }
+        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
+        if (branchTip == null) {
+          return; // readBranchTip already rejected cmd.
+        }
+        if (!walk.isMergedInto(tip, branchTip)) {
+          reject(cmd, "not merged into branch");
+          return;
+        }
+      }
+
+      // If tip is a merge commit, or the root commit or
+      // if %base or %merged was specified, ignore newChangeForAllNotInTarget.
+      if (tip.getParentCount() > 1
+          || magicBranch.base != null
+          || magicBranch.merged
+          || tip.getParentCount() == 0) {
+        logDebug("Forcing newChangeForAllNotInTarget = false");
+        newChangeForAllNotInTarget = false;
+      }
+
+      if (magicBranch.base != null) {
+        logDebug("Handling %base: {}", magicBranch.base);
+        magicBranch.baseCommit = Lists.newArrayListWithCapacity(magicBranch.base.size());
+        for (ObjectId id : magicBranch.base) {
+          try {
+            magicBranch.baseCommit.add(walk.parseCommit(id));
+          } catch (IncorrectObjectTypeException notCommit) {
+            reject(cmd, "base must be a commit");
+            return;
+          } catch (MissingObjectException e) {
+            reject(cmd, "base not found");
+            return;
+          } catch (IOException e) {
+            logWarn(String.format("Project %s cannot read %s", project.getName(), id.name()), e);
+            reject(cmd, "internal server error");
+            return;
+          }
+        }
+      } else if (newChangeForAllNotInTarget) {
+        RevCommit branchTip = readBranchTip(cmd, magicBranch.dest);
+        if (branchTip == null) {
+          return; // readBranchTip already rejected cmd.
+        }
+        magicBranch.baseCommit = Collections.singletonList(branchTip);
+        logDebug("Set baseCommit = {}", magicBranch.baseCommit.get(0).name());
+      }
+    } catch (IOException ex) {
+      logWarn(
+          String.format("Error walking to %s in project %s", destBranch, project.getName()), ex);
+      reject(cmd, "internal server error");
+      return;
+    }
+
+    // Validate that the new commits are connected with the target
+    // branch.  If they aren't, we want to abort. We do this check by
+    // looking to see if we can compute a merge base between the new
+    // commits and the target branch head.
+    //
+    try {
+      Ref targetRef = rp.getAdvertisedRefs().get(magicBranch.dest.get());
+      if (targetRef == null || targetRef.getObjectId() == null) {
+        // The destination branch does not yet exist. Assume the
+        // history being sent for review will start it and thus
+        // is "connected" to the branch.
+        logDebug("Branch is unborn");
+        return;
+      }
+      RevCommit h = walk.parseCommit(targetRef.getObjectId());
+      logDebug("Current branch tip: {}", h.name());
+      RevFilter oldRevFilter = walk.getRevFilter();
+      try {
+        walk.reset();
+        walk.setRevFilter(RevFilter.MERGE_BASE);
+        walk.markStart(tip);
+        walk.markStart(h);
+        if (walk.next() == null) {
+          reject(magicBranch.cmd, "no common ancestry");
+        }
+      } finally {
+        walk.reset();
+        walk.setRevFilter(oldRevFilter);
+      }
+    } catch (IOException e) {
+      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
+      logError("Invalid pack upload; one or more objects weren't sent", e);
+    }
+  }
+
+  private static String readHEAD(Repository repo) {
+    try {
+      return repo.getFullBranch();
+    } catch (IOException e) {
+      log.error("Cannot read HEAD symref", e);
+      return null;
+    }
+  }
+
+  private RevCommit readBranchTip(ReceiveCommand cmd, Branch.NameKey branch) throws IOException {
+    Ref r = allRefs().get(branch.get());
+    if (r == null) {
+      reject(cmd, branch.get() + " not found");
+      return null;
+    }
+    return rp.getRevWalk().parseCommit(r.getObjectId());
+  }
+
+  private void parseReplaceCommand(ReceiveCommand cmd, Change.Id changeId) {
+    logDebug("Parsing replace command");
+    if (cmd.getType() != ReceiveCommand.Type.CREATE) {
+      reject(cmd, "invalid usage");
+      return;
+    }
+
+    RevCommit newCommit;
+    try {
+      newCommit = rp.getRevWalk().parseCommit(cmd.getNewId());
+      logDebug("Replacing with {}", newCommit);
+    } catch (IOException e) {
+      logError("Cannot parse " + cmd.getNewId().name() + " as commit", e);
+      reject(cmd, "invalid commit");
+      return;
+    }
+
+    Change changeEnt;
+    try {
+      changeEnt = notesFactory.createChecked(db, project.getNameKey(), changeId).getChange();
+    } catch (NoSuchChangeException e) {
+      logError("Change not found " + changeId, e);
+      reject(cmd, "change " + changeId + " not found");
+      return;
+    } catch (OrmException e) {
+      logError("Cannot lookup existing change " + changeId, e);
+      reject(cmd, "database error");
+      return;
+    }
+    if (!project.getNameKey().equals(changeEnt.getProject())) {
+      reject(cmd, "change " + changeId + " does not belong to project " + project.getName());
+      return;
+    }
+
+    logDebug("Replacing change {}", changeEnt.getId());
+    requestReplace(cmd, true, changeEnt, newCommit);
+  }
+
+  private boolean requestReplace(
+      ReceiveCommand cmd, boolean checkMergedInto, Change change, RevCommit newCommit) {
+    if (change.getStatus().isClosed()) {
+      reject(
+          cmd,
+          changeFormatter.changeClosed(
+              ChangeReportFormatter.Input.builder().setChange(change).build()));
+      return false;
+    }
+
+    ReplaceRequest req = new ReplaceRequest(change.getId(), newCommit, cmd, checkMergedInto);
+    if (replaceByChange.containsKey(req.ontoChange)) {
+      reject(cmd, "duplicate request");
+      return false;
+    }
+    replaceByChange.put(req.ontoChange, req);
+    return true;
+  }
+
+  private void selectNewAndReplacedChangesFromMagicBranch() {
+    logDebug("Finding new and replaced changes");
+    newChanges = new ArrayList<>();
+
+    ListMultimap<ObjectId, Ref> existing = changeRefsById();
+    GroupCollector groupCollector =
+        GroupCollector.create(changeRefsById(), db, psUtil, notesFactory, project.getNameKey());
+
+    try {
+      RevCommit start = setUpWalkForSelectingChanges();
+      if (start == null) {
+        return;
+      }
+
+      LinkedHashMap<RevCommit, ChangeLookup> pending = new LinkedHashMap<>();
+      Set<Change.Key> newChangeIds = new HashSet<>();
+      int maxBatchChanges = receiveConfig.getEffectiveMaxBatchChangesLimit(user);
+      int total = 0;
+      int alreadyTracked = 0;
+      boolean rejectImplicitMerges =
+          start.getParentCount() == 1
+              && projectCache.get(project.getNameKey()).isRejectImplicitMerges()
+              // Don't worry about implicit merges when creating changes for
+              // already-merged commits; they're already in history, so it's too
+              // late.
+              && !magicBranch.merged;
+      Set<RevCommit> mergedParents;
+      if (rejectImplicitMerges) {
+        mergedParents = new HashSet<>();
+      } else {
+        mergedParents = null;
+      }
+
+      for (; ; ) {
+        RevCommit c = rp.getRevWalk().next();
+        if (c == null) {
+          break;
+        }
+        total++;
+        rp.getRevWalk().parseBody(c);
+        String name = c.name();
+        groupCollector.visit(c);
+        Collection<Ref> existingRefs = existing.get(c);
+
+        if (rejectImplicitMerges) {
+          Collections.addAll(mergedParents, c.getParents());
+          mergedParents.remove(c);
+        }
+
+        boolean commitAlreadyTracked = !existingRefs.isEmpty();
+        if (commitAlreadyTracked) {
+          alreadyTracked++;
+          // Corner cases where an existing commit might need a new group:
+          // A) Existing commit has a null group; wasn't assigned during schema
+          //    upgrade, or schema upgrade is performed on a running server.
+          // B) Let A<-B<-C, then:
+          //      1. Push A to refs/heads/master
+          //      2. Push B to refs/for/master
+          //      3. Force push A~ to refs/heads/master
+          //      4. Push C to refs/for/master.
+          //      B will be in existing so we aren't replacing the patch set. It
+          //      used to have its own group, but now needs to to be changed to
+          //      A's group.
+          // C) Commit is a PatchSet of a pre-existing change uploaded with a
+          //    different target branch.
+          for (Ref ref : existingRefs) {
+            updateGroups.add(new UpdateGroupsRequest(ref, c));
+          }
+          if (!(newChangeForAllNotInTarget || magicBranch.base != null)) {
+            continue;
+          }
+        }
+
+        List<String> idList = c.getFooterLines(CHANGE_ID);
+
+        String idStr = !idList.isEmpty() ? idList.get(idList.size() - 1).trim() : null;
+
+        if (idStr != null) {
+          pending.put(c, new ChangeLookup(c, new Change.Key(idStr)));
+        } else {
+          pending.put(c, new ChangeLookup(c));
+        }
+        int n = pending.size() + newChanges.size();
+        if (maxBatchChanges != 0 && n > maxBatchChanges) {
+          logDebug("{} changes exceeds limit of {}", n, maxBatchChanges);
+          reject(
+              magicBranch.cmd,
+              "the number of pushed changes in a batch exceeds the max limit " + maxBatchChanges);
+          newChanges = Collections.emptyList();
+          return;
+        }
+
+        if (commitAlreadyTracked) {
+          boolean changeExistsOnDestBranch = false;
+          for (ChangeData cd : pending.get(c).destChanges) {
+            if (cd.change().getDest().equals(magicBranch.dest)) {
+              changeExistsOnDestBranch = true;
+              break;
+            }
+          }
+          if (changeExistsOnDestBranch) {
+            continue;
+          }
+
+          logDebug("Creating new change for {} even though it is already tracked", name);
+        }
+
+        if (!validCommit(rp.getRevWalk(), magicBranch.perm, magicBranch.dest, magicBranch.cmd, c)) {
+          // Not a change the user can propose? Abort as early as possible.
+          newChanges = Collections.emptyList();
+          logDebug("Aborting early due to invalid commit");
+          return;
+        }
+
+        // Don't allow merges to be uploaded in commit chain via all-not-in-target
+        if (newChangeForAllNotInTarget && c.getParentCount() > 1) {
+          reject(
+              magicBranch.cmd,
+              "Pushing merges in commit chains with 'all not in target' is not allowed,\n"
+                  + "to override please set the base manually");
+          logDebug("Rejecting merge commit {} with newChangeForAllNotInTarget", name);
+          // TODO(dborowitz): Should we early return here?
+        }
+
+        if (idList.isEmpty()) {
+          newChanges.add(new CreateRequest(c, magicBranch.dest.get()));
+          continue;
+        }
+      }
+      logDebug(
+          "Finished initial RevWalk with {} commits total: {} already"
+              + " tracked, {} new changes with no Change-Id, and {} deferred"
+              + " lookups",
+          total,
+          alreadyTracked,
+          newChanges.size(),
+          pending.size());
+
+      if (rejectImplicitMerges) {
+        rejectImplicitMerges(mergedParents);
+      }
+
+      for (Iterator<ChangeLookup> itr = pending.values().iterator(); itr.hasNext(); ) {
+        ChangeLookup p = itr.next();
+        if (p.changeKey == null) {
+          continue;
+        }
+
+        if (newChangeIds.contains(p.changeKey)) {
+          logDebug("Multiple commits with Change-Id {}", p.changeKey);
+          reject(magicBranch.cmd, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
+          newChanges = Collections.emptyList();
+          return;
+        }
+
+        List<ChangeData> changes = p.destChanges;
+        if (changes.size() > 1) {
+          logDebug(
+              "Multiple changes in branch {} with Change-Id {}: {}",
+              magicBranch.dest,
+              p.changeKey,
+              changes.stream().map(cd -> cd.getId().toString()).collect(joining()));
+          // WTF, multiple changes in this branch have the same key?
+          // Since the commit is new, the user should recreate it with
+          // a different Change-Id. In practice, we should never see
+          // this error message as Change-Id should be unique per branch.
+          //
+          reject(magicBranch.cmd, p.changeKey.get() + " has duplicates");
+          newChanges = Collections.emptyList();
+          return;
+        }
+
+        if (changes.size() == 1) {
+          // Schedule as a replacement to this one matching change.
+          //
+
+          RevId currentPs = changes.get(0).currentPatchSet().getRevision();
+          // If Commit is already current PatchSet of target Change.
+          if (p.commit.name().equals(currentPs.get())) {
+            if (pending.size() == 1) {
+              // There are no commits left to check, all commits in pending were already
+              // current PatchSet of the corresponding target changes.
+              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+            } else {
+              // Commit is already current PatchSet.
+              // Remove from pending and try next commit.
+              itr.remove();
+              continue;
+            }
+          }
+          if (requestReplace(magicBranch.cmd, false, changes.get(0).change(), p.commit)) {
+            continue;
+          }
+          newChanges = Collections.emptyList();
+          return;
+        }
+
+        if (changes.size() == 0) {
+          if (!isValidChangeId(p.changeKey.get())) {
+            reject(magicBranch.cmd, "invalid Change-Id");
+            newChanges = Collections.emptyList();
+            return;
+          }
+
+          // In case the change look up from the index failed,
+          // double check against the existing refs
+          if (foundInExistingRef(existing.get(p.commit))) {
+            if (pending.size() == 1) {
+              reject(magicBranch.cmd, "commit(s) already exists (as current patchset)");
+              newChanges = Collections.emptyList();
+              return;
+            }
+            itr.remove();
+            continue;
+          }
+          newChangeIds.add(p.changeKey);
+        }
+        newChanges.add(new CreateRequest(p.commit, magicBranch.dest.get()));
+      }
+      logDebug(
+          "Finished deferred lookups with {} updates and {} new changes",
+          replaceByChange.size(),
+          newChanges.size());
+    } catch (IOException e) {
+      // Should never happen, the core receive process would have
+      // identified the missing object earlier before we got control.
+      //
+      magicBranch.cmd.setResult(REJECTED_MISSING_OBJECT);
+      logError("Invalid pack upload; one or more objects weren't sent", e);
+      newChanges = Collections.emptyList();
+      return;
+    } catch (OrmException e) {
+      logError("Cannot query database to locate prior changes", e);
+      reject(magicBranch.cmd, "database error");
+      newChanges = Collections.emptyList();
+      return;
+    }
+
+    if (newChanges.isEmpty() && replaceByChange.isEmpty()) {
+      reject(magicBranch.cmd, "no new changes");
+      return;
+    }
+    if (!newChanges.isEmpty() && magicBranch.edit) {
+      reject(magicBranch.cmd, "edit is not supported for new changes");
+      return;
+    }
+
+    try {
+      SortedSetMultimap<ObjectId, String> groups = groupCollector.getGroups();
+      List<Integer> newIds = seq.nextChangeIds(newChanges.size());
+      for (int i = 0; i < newChanges.size(); i++) {
+        CreateRequest create = newChanges.get(i);
+        create.setChangeId(newIds.get(i));
+        create.groups = ImmutableList.copyOf(groups.get(create.commit));
+      }
+      for (ReplaceRequest replace : replaceByChange.values()) {
+        replace.groups = ImmutableList.copyOf(groups.get(replace.newCommitId));
+      }
+      for (UpdateGroupsRequest update : updateGroups) {
+        update.groups = ImmutableList.copyOf((groups.get(update.commit)));
+      }
+      logDebug("Finished updating groups from GroupCollector");
+    } catch (OrmException e) {
+      logError("Error collecting groups for changes", e);
+      reject(magicBranch.cmd, "internal server error");
+      return;
+    }
+  }
+
+  private boolean foundInExistingRef(Collection<Ref> existingRefs) throws OrmException {
+    for (Ref ref : existingRefs) {
+      ChangeNotes notes =
+          notesFactory.create(db, project.getNameKey(), Change.Id.fromRef(ref.getName()));
+      Change change = notes.getChange();
+      if (change.getDest().equals(magicBranch.dest)) {
+        logDebug("Found change {} from existing refs.", change.getKey());
+        // Reindex the change asynchronously, ignoring errors.
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError = indexer.indexAsync(project.getNameKey(), change.getId());
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private RevCommit setUpWalkForSelectingChanges() throws IOException {
+    RevWalk rw = rp.getRevWalk();
+    RevCommit start = rw.parseCommit(magicBranch.cmd.getNewId());
+
+    rw.reset();
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE, true);
+    rp.getRevWalk().markStart(start);
+    if (magicBranch.baseCommit != null) {
+      markExplicitBasesUninteresting();
+    } else if (magicBranch.merged) {
+      logDebug("Marking parents of merged commit {} uninteresting", start.name());
+      for (RevCommit c : start.getParents()) {
+        rw.markUninteresting(c);
+      }
+    } else {
+      markHeadsAsUninteresting(rw, magicBranch.dest != null ? magicBranch.dest.get() : null);
+    }
+    return start;
+  }
+
+  private void markExplicitBasesUninteresting() throws IOException {
+    logDebug("Marking {} base commits uninteresting", magicBranch.baseCommit.size());
+    for (RevCommit c : magicBranch.baseCommit) {
+      rp.getRevWalk().markUninteresting(c);
+    }
+    Ref targetRef = allRefs().get(magicBranch.dest.get());
+    if (targetRef != null) {
+      logDebug(
+          "Marking target ref {} ({}) uninteresting",
+          magicBranch.dest.get(),
+          targetRef.getObjectId().name());
+      rp.getRevWalk().markUninteresting(rp.getRevWalk().parseCommit(targetRef.getObjectId()));
+    }
+  }
+
+  private void rejectImplicitMerges(Set<RevCommit> mergedParents) throws IOException {
+    if (!mergedParents.isEmpty()) {
+      Ref targetRef = allRefs().get(magicBranch.dest.get());
+      if (targetRef != null) {
+        RevWalk rw = rp.getRevWalk();
+        RevCommit tip = rw.parseCommit(targetRef.getObjectId());
+        boolean containsImplicitMerges = true;
+        for (RevCommit p : mergedParents) {
+          containsImplicitMerges &= !rw.isMergedInto(p, tip);
+        }
+
+        if (containsImplicitMerges) {
+          rw.reset();
+          for (RevCommit p : mergedParents) {
+            rw.markStart(p);
+          }
+          rw.markUninteresting(tip);
+          RevCommit c;
+          while ((c = rw.next()) != null) {
+            rw.parseBody(c);
+            messages.add(
+                new CommitValidationMessage(
+                    "ERROR: Implicit Merge of "
+                        + c.abbreviate(7).name()
+                        + " "
+                        + c.getShortMessage(),
+                    false));
+          }
+          reject(magicBranch.cmd, "implicit merges detected");
+        }
+      }
+    }
+  }
+
+  private void markHeadsAsUninteresting(RevWalk rw, @Nullable String forRef) {
+    int i = 0;
+    for (Ref ref : allRefs().values()) {
+      if ((ref.getName().startsWith(R_HEADS) || ref.getName().equals(forRef))
+          && ref.getObjectId() != null) {
+        try {
+          rw.markUninteresting(rw.parseCommit(ref.getObjectId()));
+          i++;
+        } catch (IOException e) {
+          logWarn(String.format("Invalid ref %s in %s", ref.getName(), project.getName()), e);
+        }
+      }
+    }
+    logDebug("Marked {} heads as uninteresting", i);
+  }
+
+  private static boolean isValidChangeId(String idStr) {
+    return idStr.matches("^I[0-9a-fA-F]{40}$") && !idStr.matches("^I00*$");
+  }
+
+  private class ChangeLookup {
+    final RevCommit commit;
+    final Change.Key changeKey;
+    final List<ChangeData> destChanges;
+
+    ChangeLookup(RevCommit c, Change.Key key) throws OrmException {
+      commit = c;
+      changeKey = key;
+      destChanges = queryProvider.get().byBranchKey(magicBranch.dest, key);
+    }
+
+    ChangeLookup(RevCommit c) throws OrmException {
+      commit = c;
+      destChanges = queryProvider.get().byBranchCommit(magicBranch.dest, c.getName());
+      changeKey = null;
+    }
+  }
+
+  private class CreateRequest {
+    final RevCommit commit;
+    private final String refName;
+
+    Change.Id changeId;
+    ReceiveCommand cmd;
+    ChangeInserter ins;
+    List<String> groups = ImmutableList.of();
+
+    Change change;
+
+    CreateRequest(RevCommit commit, String refName) {
+      this.commit = commit;
+      this.refName = refName;
+    }
+
+    private void setChangeId(int id) {
+      possiblyOverrideWorkInProgress();
+
+      changeId = new Change.Id(id);
+      ins =
+          changeInserterFactory
+              .create(changeId, commit, refName)
+              .setTopic(magicBranch.topic)
+              .setPrivate(setChangeAsPrivate)
+              .setWorkInProgress(magicBranch.workInProgress)
+              // Changes already validated in validateNewCommits.
+              .setValidate(false);
+
+      if (magicBranch.merged) {
+        ins.setStatus(Change.Status.MERGED);
+      }
+      cmd = new ReceiveCommand(ObjectId.zeroId(), commit, ins.getPatchSetId().toRefName());
+      if (rp.getPushCertificate() != null) {
+        ins.setPushCertificate(rp.getPushCertificate().toTextWithSignature());
+      }
+    }
+
+    private void possiblyOverrideWorkInProgress() {
+      // When wip or ready explicitly provided, leave it as is.
+      if (magicBranch.workInProgress || magicBranch.ready) {
+        return;
+      }
+      magicBranch.workInProgress =
+          projectCache.get(project.getNameKey()).isWorkInProgressByDefault()
+              || firstNonNull(
+                  user.getAccount().getGeneralPreferencesInfo().workInProgressByDefault, false);
+    }
+
+    private void addOps(BatchUpdate bu) throws RestApiException {
+      checkState(changeId != null, "must call setChangeId before addOps");
+      try {
+        RevWalk rw = rp.getRevWalk();
+        rw.parseBody(commit);
+        final PatchSet.Id psId = ins.setGroups(groups).getPatchSetId();
+        Account.Id me = user.getAccountId();
+        List<FooterLine> footerLines = commit.getFooterLines();
+        MailRecipients recipients = new MailRecipients();
+        Map<String, Short> approvals = new HashMap<>();
+        checkNotNull(magicBranch);
+        recipients.add(magicBranch.getMailRecipients());
+        approvals = magicBranch.labels;
+        recipients.add(getRecipientsFromFooters(accountResolver, footerLines));
+        recipients.remove(me);
+        StringBuilder msg =
+            new StringBuilder(
+                ApprovalsUtil.renderMessageWithApprovals(
+                    psId.get(), approvals, Collections.<String, PatchSetApproval>emptyMap()));
+        msg.append('.');
+        if (!Strings.isNullOrEmpty(magicBranch.message)) {
+          msg.append("\n").append(magicBranch.message);
+        }
+
+        bu.insertChange(
+            ins.setReviewers(recipients.getReviewers())
+                .setExtraCC(recipients.getCcOnly())
+                .setApprovals(approvals)
+                .setMessage(msg.toString())
+                .setNotify(magicBranch.getNotify())
+                .setAccountsToNotify(magicBranch.getAccountsToNotify())
+                .setRequestScopePropagator(requestScopePropagator)
+                .setSendMail(true)
+                .setPatchSetDescription(magicBranch.message));
+        if (!magicBranch.hashtags.isEmpty()) {
+          // Any change owner is allowed to add hashtags when creating a change.
+          bu.addOp(
+              changeId,
+              hashtagsFactory.create(new HashtagsInput(magicBranch.hashtags)).setFireEvent(false));
+        }
+        if (!Strings.isNullOrEmpty(magicBranch.topic)) {
+          bu.addOp(
+              changeId,
+              new BatchUpdateOp() {
+                @Override
+                public boolean updateChange(ChangeContext ctx) {
+                  ctx.getUpdate(psId).setTopic(magicBranch.topic);
+                  return true;
+                }
+              });
+        }
+        bu.addOp(
+            changeId,
+            new BatchUpdateOp() {
+              @Override
+              public boolean updateChange(ChangeContext ctx) {
+                change = ctx.getChange();
+                return false;
+              }
+            });
+        bu.addOp(changeId, new ChangeProgressOp(newProgress));
+      } catch (Exception e) {
+        throw INSERT_EXCEPTION.apply(e);
+      }
+    }
+  }
+
+  private void submit(Collection<CreateRequest> create, Collection<ReplaceRequest> replace)
+      throws OrmException, RestApiException, UpdateException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    Map<ObjectId, Change> bySha = Maps.newHashMapWithExpectedSize(create.size() + replace.size());
+    for (CreateRequest r : create) {
+      checkNotNull(r.change, "cannot submit new change %s; op may not have run", r.changeId);
+      bySha.put(r.commit, r.change);
+    }
+    for (ReplaceRequest r : replace) {
+      bySha.put(r.newCommitId, r.notes.getChange());
+    }
+    Change tipChange = bySha.get(magicBranch.cmd.getNewId());
+    checkNotNull(
+        tipChange, "tip of push does not correspond to a change; found these changes: %s", bySha);
+    logDebug(
+        "Processing submit with tip change {} ({})", tipChange.getId(), magicBranch.cmd.getNewId());
+    try (MergeOp op = mergeOpProvider.get()) {
+      op.merge(db, tipChange, user, false, new SubmitInput(), false);
+    }
+  }
+
+  private void preparePatchSetsForReplace() {
+    try {
+      readChangesForReplace();
+      for (Iterator<ReplaceRequest> itr = replaceByChange.values().iterator(); itr.hasNext(); ) {
+        ReplaceRequest req = itr.next();
+        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
+          req.validate(false);
+          if (req.skip && req.cmd == null) {
+            itr.remove();
+          }
+        }
+      }
+    } catch (OrmException err) {
+      logError(
+          String.format(
+              "Cannot read database before replacement for project %s", project.getName()),
+          err);
+      for (ReplaceRequest req : replaceByChange.values()) {
+        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
+          req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
+        }
+      }
+    } catch (IOException | PermissionBackendException err) {
+      logError(
+          String.format(
+              "Cannot read repository before replacement for project %s", project.getName()),
+          err);
+      for (ReplaceRequest req : replaceByChange.values()) {
+        if (req.inputCommand.getResult() == NOT_ATTEMPTED) {
+          req.inputCommand.setResult(REJECTED_OTHER_REASON, "internal server error");
+        }
+      }
+    }
+    logDebug("Read {} changes to replace", replaceByChange.size());
+
+    if (magicBranch != null && magicBranch.cmd.getResult() != NOT_ATTEMPTED) {
+      // Cancel creations tied to refs/for/ or refs/drafts/ command.
+      for (ReplaceRequest req : replaceByChange.values()) {
+        if (req.inputCommand == magicBranch.cmd && req.cmd != null) {
+          req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
+        }
+      }
+      for (CreateRequest req : newChanges) {
+        req.cmd.setResult(Result.REJECTED_OTHER_REASON, "aborted");
+      }
+    }
+  }
+
+  private void readChangesForReplace() throws OrmException {
+    Collection<ChangeNotes> allNotes =
+        notesFactory.create(
+            db, replaceByChange.values().stream().map(r -> r.ontoChange).collect(toList()));
+    for (ChangeNotes notes : allNotes) {
+      replaceByChange.get(notes.getChangeId()).notes = notes;
+    }
+  }
+
+  private class ReplaceRequest {
+    final Change.Id ontoChange;
+    final ObjectId newCommitId;
+    final ReceiveCommand inputCommand;
+    final boolean checkMergedInto;
+    ChangeNotes notes;
+    BiMap<RevCommit, PatchSet.Id> revisions;
+    PatchSet.Id psId;
+    ReceiveCommand prev;
+    ReceiveCommand cmd;
+    PatchSetInfo info;
+    boolean skip;
+    private PatchSet.Id priorPatchSet;
+    List<String> groups = ImmutableList.of();
+    private ReplaceOp replaceOp;
+
+    ReplaceRequest(
+        Change.Id toChange, RevCommit newCommit, ReceiveCommand cmd, boolean checkMergedInto) {
+      this.ontoChange = toChange;
+      this.newCommitId = newCommit.copy();
+      this.inputCommand = checkNotNull(cmd);
+      this.checkMergedInto = checkMergedInto;
+
+      revisions = HashBiMap.create();
+      for (Ref ref : refs(toChange)) {
+        try {
+          revisions.forcePut(
+              rp.getRevWalk().parseCommit(ref.getObjectId()), PatchSet.Id.fromRef(ref.getName()));
+        } catch (IOException err) {
+          logWarn(
+              String.format(
+                  "Project %s contains invalid change ref %s", project.getName(), ref.getName()),
+              err);
+        }
+      }
+    }
+
+    /**
+     * Validate the new patch set commit for this change.
+     *
+     * <p><strong>Side effects:</strong>
+     *
+     * <ul>
+     *   <li>May add error or warning messages to the progress monitor
+     *   <li>Will reject {@code cmd} prior to returning false
+     *   <li>May reset {@code rp.getRevWalk()}; do not call in the middle of a walk.
+     * </ul>
+     *
+     * @param autoClose whether the caller intends to auto-close the change after adding a new patch
+     *     set.
+     * @return whether the new commit is valid
+     * @throws IOException
+     * @throws OrmException
+     * @throws PermissionBackendException
+     */
+    boolean validate(boolean autoClose)
+        throws IOException, OrmException, PermissionBackendException {
+      if (!autoClose && inputCommand.getResult() != NOT_ATTEMPTED) {
+        return false;
+      } else if (notes == null) {
+        reject(inputCommand, "change " + ontoChange + " not found");
+        return false;
+      }
+
+      Change change = notes.getChange();
+      priorPatchSet = change.currentPatchSetId();
+      if (!revisions.containsValue(priorPatchSet)) {
+        reject(inputCommand, "change " + ontoChange + " missing revisions");
+        return false;
+      }
+
+      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
+      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+      try {
+        permissions.change(notes).database(db).check(ChangePermission.ADD_PATCH_SET);
+      } catch (AuthException no) {
+        reject(inputCommand, "cannot add patch set to " + ontoChange + ".");
+        return false;
+      }
+
+      if (change.getStatus().isClosed()) {
+        reject(inputCommand, "change " + ontoChange + " closed");
+        return false;
+      } else if (revisions.containsKey(newCommit)) {
+        reject(inputCommand, "commit already exists (in the change)");
+        return false;
+      }
+
+      for (Ref r : rp.getRepository().getRefDatabase().getRefs("refs/changes").values()) {
+        if (r.getObjectId().equals(newCommit)) {
+          reject(inputCommand, "commit already exists (in the project)");
+          return false;
+        }
+      }
+
+      for (RevCommit prior : revisions.keySet()) {
+        // Don't allow a change to directly depend upon itself. This is a
+        // very common error due to users making a new commit rather than
+        // amending when trying to address review comments.
+        if (rp.getRevWalk().isMergedInto(prior, newCommit)) {
+          reject(inputCommand, SAME_CHANGE_ID_IN_MULTIPLE_CHANGES);
+          return false;
+        }
+      }
+
+      PermissionBackend.ForRef perm = permissions.ref(change.getDest().get());
+      if (!validCommit(rp.getRevWalk(), perm, change.getDest(), inputCommand, newCommit)) {
+        return false;
+      }
+      rp.getRevWalk().parseBody(priorCommit);
+
+      // Don't allow the same tree if the commit message is unmodified
+      // or no parents were updated (rebase), else warn that only part
+      // of the commit was modified.
+      if (newCommit.getTree().equals(priorCommit.getTree())) {
+        boolean messageEq = eq(newCommit.getFullMessage(), priorCommit.getFullMessage());
+        boolean parentsEq = parentsEqual(newCommit, priorCommit);
+        boolean authorEq = authorEqual(newCommit, priorCommit);
+        ObjectReader reader = rp.getRevWalk().getObjectReader();
+
+        if (messageEq && parentsEq && authorEq && !autoClose) {
+          addMessage(
+              String.format(
+                  "(W) No changes between prior commit %s and new commit %s",
+                  reader.abbreviate(priorCommit).name(), reader.abbreviate(newCommit).name()));
+        } else {
+          StringBuilder msg = new StringBuilder();
+          msg.append("(I) ");
+          msg.append(reader.abbreviate(newCommit).name());
+          msg.append(":");
+          msg.append(" no files changed");
+          if (!authorEq) {
+            msg.append(", author changed");
+          }
+          if (!messageEq) {
+            msg.append(", message updated");
+          }
+          if (!parentsEq) {
+            msg.append(", was rebased");
+          }
+          addMessage(msg.toString());
+        }
+      }
+
+      if (magicBranch != null
+          && (magicBranch.workInProgress || magicBranch.ready)
+          && magicBranch.workInProgress != change.isWorkInProgress()
+          && (!user.getAccountId().equals(change.getOwner())
+              && !permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)
+              && !projectControl.isOwner())) {
+        reject(inputCommand, ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP);
+        return false;
+      }
+
+      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
+        return newEdit();
+      }
+
+      newPatchSet();
+      return true;
+    }
+
+    private boolean newEdit() {
+      psId = notes.getChange().currentPatchSetId();
+      Optional<ChangeEdit> edit = null;
+
+      try {
+        edit = editUtil.byChange(notes, user);
+      } catch (AuthException | IOException e) {
+        logError("Cannot retrieve edit", e);
+        return false;
+      }
+
+      if (edit.isPresent()) {
+        if (edit.get().getBasePatchSet().getId().equals(psId)) {
+          // replace edit
+          cmd =
+              new ReceiveCommand(edit.get().getEditCommit(), newCommitId, edit.get().getRefName());
+        } else {
+          // delete old edit ref on rebase
+          prev =
+              new ReceiveCommand(
+                  edit.get().getEditCommit(), ObjectId.zeroId(), edit.get().getRefName());
+          createEditCommand();
+        }
+      } else {
+        createEditCommand();
+      }
+
+      return true;
+    }
+
+    private void createEditCommand() {
+      // create new edit
+      cmd =
+          new ReceiveCommand(
+              ObjectId.zeroId(),
+              newCommitId,
+              RefNames.refsEdit(user.getAccountId(), notes.getChangeId(), psId));
+    }
+
+    private void newPatchSet() throws IOException, OrmException {
+      RevCommit newCommit = rp.getRevWalk().parseCommit(newCommitId);
+      psId =
+          ChangeUtil.nextPatchSetIdFromAllRefsMap(allRefs(), notes.getChange().currentPatchSetId());
+      info = patchSetInfoFactory.get(rp.getRevWalk(), newCommit, psId);
+      cmd = new ReceiveCommand(ObjectId.zeroId(), newCommitId, psId.toRefName());
+    }
+
+    void addOps(BatchUpdate bu, @Nullable Task progress) throws IOException {
+      if (magicBranch != null && (magicBranch.edit || magicBranch.draft)) {
+        bu.addOp(notes.getChangeId(), new ReindexOnlyOp());
+        if (prev != null) {
+          bu.addRepoOnlyOp(new UpdateOneRefOp(prev));
+        }
+        bu.addRepoOnlyOp(new UpdateOneRefOp(cmd));
+        return;
+      }
+      RevWalk rw = rp.getRevWalk();
+      // TODO(dborowitz): Move to ReplaceOp#updateRepo.
+      RevCommit newCommit = rw.parseCommit(newCommitId);
+      rw.parseBody(newCommit);
+
+      RevCommit priorCommit = revisions.inverse().get(priorPatchSet);
+      replaceOp =
+          replaceOpFactory
+              .create(
+                  projectControl,
+                  notes.getChange().getDest(),
+                  checkMergedInto,
+                  priorPatchSet,
+                  priorCommit,
+                  psId,
+                  newCommit,
+                  info,
+                  groups,
+                  magicBranch,
+                  rp.getPushCertificate())
+              .setRequestScopePropagator(requestScopePropagator);
+      bu.addOp(notes.getChangeId(), replaceOp);
+      if (progress != null) {
+        bu.addOp(notes.getChangeId(), new ChangeProgressOp(progress));
+      }
+    }
+
+    String getRejectMessage() {
+      return replaceOp != null ? replaceOp.getRejectMessage() : null;
+    }
+  }
+
+  private class UpdateGroupsRequest {
+    private final PatchSet.Id psId;
+    private final RevCommit commit;
+    List<String> groups = ImmutableList.of();
+
+    UpdateGroupsRequest(Ref ref, RevCommit commit) {
+      this.psId = checkNotNull(PatchSet.Id.fromRef(ref.getName()));
+      this.commit = commit;
+    }
+
+    private void addOps(BatchUpdate bu) {
+      bu.addOp(
+          psId.getParentKey(),
+          new BatchUpdateOp() {
+            @Override
+            public boolean updateChange(ChangeContext ctx) throws OrmException {
+              PatchSet ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+              List<String> oldGroups = ps.getGroups();
+              if (oldGroups == null) {
+                if (groups == null) {
+                  return false;
+                }
+              } else if (sameGroups(oldGroups, groups)) {
+                return false;
+              }
+              psUtil.setGroups(ctx.getDb(), ctx.getUpdate(psId), ps, groups);
+              return true;
+            }
+          });
+    }
+
+    private boolean sameGroups(List<String> a, List<String> b) {
+      return Sets.newHashSet(a).equals(Sets.newHashSet(b));
+    }
+  }
+
+  private class UpdateOneRefOp implements RepoOnlyOp {
+    private final ReceiveCommand cmd;
+
+    private UpdateOneRefOp(ReceiveCommand cmd) {
+      this.cmd = checkNotNull(cmd);
+    }
+
+    @Override
+    public void updateRepo(RepoContext ctx) throws IOException {
+      ctx.addRefUpdate(cmd);
+    }
+
+    @Override
+    public void postUpdate(Context ctx) {
+      String refName = cmd.getRefName();
+      if (cmd.getType() == ReceiveCommand.Type.UPDATE) { // aka fast-forward
+        logDebug("Updating tag cache on fast-forward of {}", cmd.getRefName());
+        tagCache.updateFastForward(project.getNameKey(), refName, cmd.getOldId(), cmd.getNewId());
+      }
+      if (isConfig(cmd)) {
+        logDebug("Reloading project in cache");
+        projectCache.evict(project);
+        ProjectState ps = projectCache.get(project.getNameKey());
+        try {
+          logDebug("Updating project description");
+          repo.setGitwebDescription(ps.getProject().getDescription());
+        } catch (IOException e) {
+          log.warn("cannot update description of " + project.getName(), e);
+        }
+      }
+    }
+  }
+
+  private static class ReindexOnlyOp implements BatchUpdateOp {
+    @Override
+    public boolean updateChange(ChangeContext ctx) {
+      // Trigger reindexing even though change isn't actually updated.
+      return true;
+    }
+  }
+
+  private List<Ref> refs(Change.Id changeId) {
+    return refsByChange().get(changeId);
+  }
+
+  private void initChangeRefMaps() {
+    if (refsByChange == null) {
+      int estRefsPerChange = 4;
+      refsById = MultimapBuilder.hashKeys().arrayListValues().build();
+      refsByChange =
+          MultimapBuilder.hashKeys(allRefs().size() / estRefsPerChange)
+              .arrayListValues(estRefsPerChange)
+              .build();
+      for (Ref ref : allRefs().values()) {
+        ObjectId obj = ref.getObjectId();
+        if (obj != null) {
+          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+          if (psId != null) {
+            refsById.put(obj, ref);
+            refsByChange.put(psId.getParentKey(), ref);
+          }
+        }
+      }
+    }
+  }
+
+  private ListMultimap<Change.Id, Ref> refsByChange() {
+    initChangeRefMaps();
+    return refsByChange;
+  }
+
+  private ListMultimap<ObjectId, Ref> changeRefsById() {
+    initChangeRefMaps();
+    return refsById;
+  }
+
+  static boolean parentsEqual(RevCommit a, RevCommit b) {
+    if (a.getParentCount() != b.getParentCount()) {
+      return false;
+    }
+    for (int i = 0; i < a.getParentCount(); i++) {
+      if (!a.getParent(i).equals(b.getParent(i))) {
+        return false;
+      }
+    }
+    return true;
+  }
+
+  static boolean authorEqual(RevCommit a, RevCommit b) {
+    PersonIdent aAuthor = a.getAuthorIdent();
+    PersonIdent bAuthor = b.getAuthorIdent();
+
+    if (aAuthor == null && bAuthor == null) {
+      return true;
+    } else if (aAuthor == null || bAuthor == null) {
+      return false;
+    }
+
+    return eq(aAuthor.getName(), bAuthor.getName())
+        && eq(aAuthor.getEmailAddress(), bAuthor.getEmailAddress());
+  }
+
+  static boolean eq(String a, String b) {
+    if (a == null && b == null) {
+      return true;
+    } else if (a == null || b == null) {
+      return false;
+    } else {
+      return a.equals(b);
+    }
+  }
+
+  private boolean validRefOperation(ReceiveCommand cmd) {
+    RefOperationValidators refValidators = refValidatorsFactory.create(getProject(), user, cmd);
+
+    try {
+      messages.addAll(refValidators.validateForRefOperation());
+    } catch (RefOperationValidationException e) {
+      messages.addAll(Lists.newArrayList(e.getMessages()));
+      reject(cmd, e.getMessage());
+      return false;
+    }
+
+    return true;
+  }
+
+  private void validateNewCommits(Branch.NameKey branch, ReceiveCommand cmd)
+      throws PermissionBackendException {
+    PermissionBackend.ForRef perm = permissions.ref(branch.get());
+    if (!RefNames.REFS_CONFIG.equals(cmd.getRefName())
+        && !(MagicBranch.isMagicBranch(cmd.getRefName())
+            || NEW_PATCHSET_PATTERN.matcher(cmd.getRefName()).matches())
+        && pushOptions.containsKey(PUSH_OPTION_SKIP_VALIDATION)) {
+      try {
+        perm.check(RefPermission.SKIP_VALIDATION);
+        if (!Iterables.isEmpty(rejectCommits)) {
+          throw new AuthException("reject-commits prevents " + PUSH_OPTION_SKIP_VALIDATION);
+        }
+        logDebug("Short-circuiting new commit validation");
+      } catch (AuthException denied) {
+        reject(cmd, denied.getMessage());
+      }
+      return;
+    }
+
+    boolean missingFullName = Strings.isNullOrEmpty(user.getAccount().getFullName());
+    RevWalk walk = rp.getRevWalk();
+    walk.reset();
+    walk.sort(RevSort.NONE);
+    try {
+      RevObject parsedObject = walk.parseAny(cmd.getNewId());
+      if (!(parsedObject instanceof RevCommit)) {
+        return;
+      }
+      ListMultimap<ObjectId, Ref> existing = changeRefsById();
+      walk.markStart((RevCommit) parsedObject);
+      markHeadsAsUninteresting(walk, cmd.getRefName());
+      int limit = receiveConfig.maxBatchCommits;
+      int n = 0;
+      for (RevCommit c; (c = walk.next()) != null; ) {
+        if (++n > limit) {
+          logDebug("Number of new commits exceeds limit of {}", limit);
+          addMessage(
+              "Cannot push more than "
+                  + limit
+                  + " commits to "
+                  + branch.get()
+                  + " without "
+                  + PUSH_OPTION_SKIP_VALIDATION
+                  + " option");
+          reject(cmd, "too many commits");
+          return;
+        }
+        if (existing.keySet().contains(c)) {
+          continue;
+        } else if (!validCommit(walk, perm, branch, cmd, c)) {
+          break;
+        }
+
+        if (missingFullName && user.hasEmailAddress(c.getCommitterIdent().getEmailAddress())) {
+          logDebug("Will update full name of caller");
+          setFullNameTo = c.getCommitterIdent().getName();
+          missingFullName = false;
+        }
+      }
+      logDebug("Validated {} new commits", n);
+    } catch (IOException err) {
+      cmd.setResult(REJECTED_MISSING_OBJECT);
+      logError("Invalid pack upload; one or more objects weren't sent", err);
+    }
+  }
+
+  private boolean validCommit(
+      RevWalk rw,
+      PermissionBackend.ForRef perm,
+      Branch.NameKey branch,
+      ReceiveCommand cmd,
+      ObjectId id)
+      throws IOException {
+
+    if (validCommits.contains(id)) {
+      return true;
+    }
+
+    RevCommit c = rw.parseCommit(id);
+    rw.parseBody(c);
+
+    try (CommitReceivedEvent receiveEvent =
+        new CommitReceivedEvent(cmd, project, branch.get(), rw.getObjectReader(), c, user)) {
+      boolean isMerged =
+          magicBranch != null
+              && cmd.getRefName().equals(magicBranch.cmd.getRefName())
+              && magicBranch.merged;
+      CommitValidators validators =
+          isMerged
+              ? commitValidatorsFactory.forMergedCommits(perm, user.asIdentifiedUser())
+              : commitValidatorsFactory.forReceiveCommits(
+                  perm, branch, user.asIdentifiedUser(), sshInfo, repo, rw);
+      messages.addAll(validators.validate(receiveEvent));
+    } catch (CommitValidationException e) {
+      logDebug("Commit validation failed on {}", c.name());
+      messages.addAll(e.getMessages());
+      reject(cmd, e.getMessage());
+      return false;
+    }
+    validCommits.add(c.copy());
+    return true;
+  }
+
+  private void autoCloseChanges(ReceiveCommand cmd) {
+    logDebug("Starting auto-closing of changes");
+    String refName = cmd.getRefName();
+    checkState(
+        !MagicBranch.isMagicBranch(refName),
+        "shouldn't be auto-closing changes on magic branch %s",
+        refName);
+    // TODO(dborowitz): Combine this BatchUpdate with the main one in
+    // insertChangesAndPatchSets.
+    try (BatchUpdate bu =
+            batchUpdateFactory.create(
+                db, projectControl.getProject().getNameKey(), user, TimeUtil.nowTs());
+        ObjectInserter ins = repo.newObjectInserter();
+        ObjectReader reader = ins.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo, rw, ins).updateChangesInParallel();
+      bu.setRequestId(receiveId);
+      // TODO(dborowitz): Teach BatchUpdate to ignore missing changes.
+
+      RevCommit newTip = rw.parseCommit(cmd.getNewId());
+      Branch.NameKey branch = new Branch.NameKey(project.getNameKey(), refName);
+
+      rw.reset();
+      rw.markStart(newTip);
+      if (!ObjectId.zeroId().equals(cmd.getOldId())) {
+        rw.markUninteresting(rw.parseCommit(cmd.getOldId()));
+      }
+
+      ListMultimap<ObjectId, Ref> byCommit = changeRefsById();
+      Map<Change.Key, ChangeNotes> byKey = null;
+      List<ReplaceRequest> replaceAndClose = new ArrayList<>();
+
+      int existingPatchSets = 0;
+      int newPatchSets = 0;
+      COMMIT:
+      for (RevCommit c; (c = rw.next()) != null; ) {
+        rw.parseBody(c);
+
+        for (Ref ref : byCommit.get(c.copy())) {
+          PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+          Optional<ChangeData> cd = byLegacyId(psId.getParentKey());
+          if (cd.isPresent() && cd.get().change().getDest().equals(branch)) {
+            existingPatchSets++;
+            bu.addOp(
+                psId.getParentKey(),
+                mergedByPushOpFactory.create(requestScopePropagator, psId, refName));
+            continue COMMIT;
+          }
+        }
+
+        for (String changeId : c.getFooterLines(CHANGE_ID)) {
+          if (byKey == null) {
+            byKey = openChangesByKeyByBranch(branch);
+          }
+
+          ChangeNotes onto = byKey.get(new Change.Key(changeId.trim()));
+          if (onto != null) {
+            newPatchSets++;
+            // Hold onto this until we're done with the walk, as the call to
+            // req.validate below calls isMergedInto which resets the walk.
+            ReplaceRequest req = new ReplaceRequest(onto.getChangeId(), c, cmd, false);
+            req.notes = onto;
+            replaceAndClose.add(req);
+            continue COMMIT;
+          }
+        }
+      }
+
+      for (ReplaceRequest req : replaceAndClose) {
+        Change.Id id = req.notes.getChangeId();
+        if (!req.validate(true)) {
+          logDebug("Not closing {} because validation failed", id);
+          continue;
+        }
+        req.addOps(bu, null);
+        bu.addOp(
+            id,
+            mergedByPushOpFactory
+                .create(requestScopePropagator, req.psId, refName)
+                .setPatchSetProvider(
+                    new Provider<PatchSet>() {
+                      @Override
+                      public PatchSet get() {
+                        return req.replaceOp.getPatchSet();
+                      }
+                    }));
+        bu.addOp(id, new ChangeProgressOp(closeProgress));
+      }
+
+      logDebug(
+          "Auto-closing {} changes with existing patch sets and {} with new patch sets",
+          existingPatchSets,
+          newPatchSets);
+      bu.execute();
+    } catch (RestApiException e) {
+      logError("Can't insert patchset", e);
+    } catch (IOException | OrmException | UpdateException | PermissionBackendException e) {
+      logError("Can't scan for changes to close", e);
+    }
+  }
+
+  private void updateAccountInfo() {
+    if (setFullNameTo == null) {
+      return;
+    }
+    logDebug("Updating full name of caller");
+    try {
+      Account account =
+          accountsUpdate
+              .create()
+              .update(
+                  user.getAccountId(),
+                  a -> {
+                    if (Strings.isNullOrEmpty(a.getFullName())) {
+                      a.setFullName(setFullNameTo);
+                    }
+                  });
+      if (account != null) {
+        user.getAccount().setFullName(account.getFullName());
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      logWarn("Failed to update full name of caller", e);
+    }
+  }
+
+  private Map<Change.Key, ChangeNotes> openChangesByKeyByBranch(Branch.NameKey branch)
+      throws OrmException {
+    Map<Change.Key, ChangeNotes> r = new HashMap<>();
+    for (ChangeData cd : queryProvider.get().byBranchOpen(branch)) {
+      try {
+        r.put(cd.change().getKey(), cd.notes());
+      } catch (NoSuchChangeException e) {
+        // Ignore deleted change
+      }
+    }
+    return r;
+  }
+
+  private Optional<ChangeData> byLegacyId(Change.Id legacyId) throws OrmException {
+    List<ChangeData> res = queryProvider.get().byLegacyChangeId(legacyId);
+    if (res.isEmpty()) {
+      return Optional.empty();
+    }
+    return Optional.of(res.get(0));
+  }
+
+  private Map<String, Ref> allRefs() {
+    return allRefsWatcher.getAllRefs();
+  }
+
+  private void reject(@Nullable ReceiveCommand cmd, String why) {
+    if (cmd != null) {
+      cmd.setResult(REJECTED_OTHER_REASON, why);
+      commandProgress.update(1);
+    }
+  }
+
+  private static boolean isHead(ReceiveCommand cmd) {
+    return cmd.getRefName().startsWith(Constants.R_HEADS);
+  }
+
+  private static boolean isConfig(ReceiveCommand cmd) {
+    return cmd.getRefName().equals(RefNames.REFS_CONFIG);
+  }
+
+  private void logDebug(String msg, Object... args) {
+    if (log.isDebugEnabled()) {
+      log.debug(receiveId + msg, args);
+    }
+  }
+
+  private void logWarn(String msg, Throwable t) {
+    if (log.isWarnEnabled()) {
+      if (t != null) {
+        log.warn(receiveId + msg, t);
+      } else {
+        log.warn(receiveId + msg);
+      }
+    }
+  }
+
+  private void logWarn(String msg) {
+    logWarn(msg, null);
+  }
+
+  private void logError(String msg, Throwable t) {
+    if (log.isErrorEnabled()) {
+      if (t != null) {
+        log.error(receiveId + msg, t);
+      } else {
+        log.error(receiveId + msg);
+      }
+    }
+  }
+
+  private void logError(String msg) {
+    logError(msg, null);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
new file mode 100644
index 0000000..3645392
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsAdvertiseRefsHook.java
@@ -0,0 +1,136 @@
+// Copyright (C) 2010 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+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.index.change.ChangeField;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.util.MagicBranch;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Provider;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.AdvertiseRefsHook;
+import org.eclipse.jgit.transport.BaseReceivePack;
+import org.eclipse.jgit.transport.ServiceMayNotContinueException;
+import org.eclipse.jgit.transport.UploadPack;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Exposes only the non refs/changes/ reference names. */
+public class ReceiveCommitsAdvertiseRefsHook implements AdvertiseRefsHook {
+  private static final Logger log = LoggerFactory.getLogger(ReceiveCommitsAdvertiseRefsHook.class);
+
+  @VisibleForTesting
+  @AutoValue
+  public abstract static class Result {
+    public abstract Map<String, Ref> allRefs();
+
+    public abstract Set<ObjectId> additionalHaves();
+  }
+
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final Project.NameKey projectName;
+
+  public ReceiveCommitsAdvertiseRefsHook(
+      Provider<InternalChangeQuery> queryProvider, Project.NameKey projectName) {
+    this.queryProvider = queryProvider;
+    this.projectName = projectName;
+  }
+
+  @Override
+  public void advertiseRefs(UploadPack us) {
+    throw new UnsupportedOperationException(
+        "ReceiveCommitsAdvertiseRefsHook cannot be used for UploadPack");
+  }
+
+  @Override
+  public void advertiseRefs(BaseReceivePack rp) throws ServiceMayNotContinueException {
+    Result r = advertiseRefs(HookUtil.ensureAllRefsAdvertised(rp));
+    rp.setAdvertisedRefs(r.allRefs(), r.additionalHaves());
+  }
+
+  @VisibleForTesting
+  public Result advertiseRefs(Map<String, Ref> oldRefs) {
+    Map<String, Ref> r = Maps.newHashMapWithExpectedSize(oldRefs.size());
+    Set<ObjectId> allPatchSets = Sets.newHashSetWithExpectedSize(oldRefs.size());
+    for (Map.Entry<String, Ref> e : oldRefs.entrySet()) {
+      String name = e.getKey();
+      if (!skip(name)) {
+        r.put(name, e.getValue());
+      }
+      if (name.startsWith(RefNames.REFS_CHANGES)) {
+        allPatchSets.add(e.getValue().getObjectId());
+      }
+    }
+    return new AutoValue_ReceiveCommitsAdvertiseRefsHook_Result(
+        r, advertiseOpenChanges(allPatchSets));
+  }
+
+  private static final ImmutableSet<String> OPEN_CHANGES_FIELDS =
+      ImmutableSet.of(
+          // Required for ChangeIsVisibleToPrdicate.
+          ChangeField.CHANGE.getName(),
+          ChangeField.REVIEWER.getName(),
+          // Required during advertiseOpenChanges.
+          ChangeField.PATCH_SET.getName());
+
+  private Set<ObjectId> advertiseOpenChanges(Set<ObjectId> allPatchSets) {
+    // Advertise some recent open changes, in case a commit is based on one.
+    int limit = 32;
+    try {
+      Set<ObjectId> r = Sets.newHashSetWithExpectedSize(limit);
+      for (ChangeData cd :
+          queryProvider
+              .get()
+              .setRequestedFields(OPEN_CHANGES_FIELDS)
+              .enforceVisibility(true)
+              .setLimit(limit)
+              .byProjectOpen(projectName)) {
+        PatchSet ps = cd.currentPatchSet();
+        if (ps != null) {
+          ObjectId id = ObjectId.fromString(ps.getRevision().get());
+          // Ensure we actually observed a patch set ref pointing to this
+          // object, in case the database is out of sync with the repo and the
+          // object doesn't actually exist.
+          if (allPatchSets.contains(id)) {
+            r.add(id);
+          }
+        }
+      }
+      return r;
+    } catch (OrmException err) {
+      log.error("Cannot list open changes of " + projectName, err);
+      return Collections.emptySet();
+    }
+  }
+
+  private static boolean skip(String name) {
+    return name.startsWith(RefNames.REFS_CHANGES)
+        || name.startsWith(RefNames.REFS_CACHE_AUTOMERGE)
+        || MagicBranch.isMagicBranch(name);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java
new file mode 100644
index 0000000..ee83a2c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutor.java
@@ -0,0 +1,26 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+import java.util.concurrent.ExecutorService;
+
+/** Marker on the global {@link ExecutorService} used by {@link ReceiveCommits}. */
+@Retention(RUNTIME)
+@BindingAnnotation
+public @interface ReceiveCommitsExecutor {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutorModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutorModule.java
new file mode 100644
index 0000000..8a66e34
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsExecutorModule.java
@@ -0,0 +1,85 @@
+// Copyright (C) 2012 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.SendEmailExecutor;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.update.ChangeUpdateExecutor;
+import com.google.inject.AbstractModule;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Module providing the {@link ReceiveCommitsExecutor}.
+ *
+ * <p>Unlike {@link ReceiveCommitsModule}, this module is intended to be installed only in top-level
+ * injectors like in {@code Daemon}, not in the {@code sysInjector}.
+ */
+public class ReceiveCommitsExecutorModule extends AbstractModule {
+  @Override
+  protected void configure() {}
+
+  @Provides
+  @Singleton
+  @ReceiveCommitsExecutor
+  public ExecutorService createReceiveCommitsExecutor(
+      @GerritServerConfig Config config, WorkQueue queues) {
+    int poolSize =
+        config.getInt(
+            "receive", null, "threadPoolSize", Runtime.getRuntime().availableProcessors());
+    return queues.createQueue(poolSize, "ReceiveCommits", true);
+  }
+
+  @Provides
+  @Singleton
+  @SendEmailExecutor
+  public ExecutorService createSendEmailExecutor(
+      @GerritServerConfig Config config, WorkQueue queues) {
+    int poolSize = config.getInt("sendemail", null, "threadPoolSize", 1);
+    if (poolSize == 0) {
+      return MoreExecutors.newDirectExecutorService();
+    }
+    return queues.createQueue(poolSize, "SendEmail", true);
+  }
+
+  @Provides
+  @Singleton
+  @ChangeUpdateExecutor
+  public ListeningExecutorService createChangeUpdateExecutor(@GerritServerConfig Config config) {
+    int poolSize = config.getInt("receive", null, "changeUpdateThreads", 1);
+    if (poolSize <= 1) {
+      return MoreExecutors.newDirectExecutorService();
+    }
+    return MoreExecutors.listeningDecorator(
+        MoreExecutors.getExitingExecutorService(
+            new ThreadPoolExecutor(
+                1,
+                poolSize,
+                10,
+                TimeUnit.MINUTES,
+                new ArrayBlockingQueue<Runnable>(poolSize),
+                new ThreadFactoryBuilder().setNameFormat("ChangeUpdate-%d").setDaemon(true).build(),
+                new ThreadPoolExecutor.CallerRunsPolicy())));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsModule.java
new file mode 100644
index 0000000..a973460
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveCommitsModule.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.server.git.receive;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+
+public class ReceiveCommitsModule extends FactoryModule {
+  @Override
+  protected void configure() {
+    bind(ReceiveConfig.class);
+    factory(ReplaceOp.Factory.class);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
new file mode 100644
index 0000000..b1c9f13
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConfig.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import static com.google.gerrit.common.data.GlobalCapability.BATCH_CHANGES_LIMIT;
+
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+class ReceiveConfig {
+  final boolean checkMagicRefs;
+  final boolean checkReferencedObjectsAreReachable;
+  final boolean allowDrafts;
+  final int maxBatchCommits;
+  final boolean disablePrivateChanges;
+  private final int systemMaxBatchChanges;
+  private final AccountLimits.Factory limitsFactory;
+
+  @Inject
+  ReceiveConfig(@GerritServerConfig Config config, AccountLimits.Factory limitsFactory) {
+    checkMagicRefs = config.getBoolean("receive", null, "checkMagicRefs", true);
+    checkReferencedObjectsAreReachable =
+        config.getBoolean("receive", null, "checkReferencedObjectsAreReachable", true);
+    allowDrafts = config.getBoolean("change", null, "allowDrafts", false);
+    maxBatchCommits = config.getInt("receive", null, "maxBatchCommits", 10000);
+    systemMaxBatchChanges = config.getInt("receive", "maxBatchChanges", 0);
+    disablePrivateChanges = config.getBoolean("change", null, "disablePrivateChanges", false);
+    this.limitsFactory = limitsFactory;
+  }
+
+  public int getEffectiveMaxBatchChangesLimit(CurrentUser user) {
+    AccountLimits limits = limitsFactory.create(user);
+    if (limits.hasExplicitRange(BATCH_CHANGES_LIMIT)) {
+      return limits.getRange(BATCH_CHANGES_LIMIT).getMax();
+    }
+    return systemMaxBatchChanges;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConstants.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConstants.java
new file mode 100644
index 0000000..b71f01e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveConstants.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.server.git.receive;
+
+import com.google.common.annotations.VisibleForTesting;
+
+public final class ReceiveConstants {
+  public static final String PUSH_OPTION_SKIP_VALIDATION = "skip-validation";
+
+  @VisibleForTesting
+  public static final String ONLY_CHANGE_OWNER_OR_PROJECT_OWNER_CAN_MODIFY_WIP =
+      "only change owner or project owner can modify Work-in-Progress";
+
+  static final String COMMAND_REJECTION_MESSAGE_FOOTER =
+      "Please read the documentation and contact an administrator\n"
+          + "if you feel the configuration is incorrect";
+
+  static final String SAME_CHANGE_ID_IN_MULTIPLE_CHANGES =
+      "same Change-Id in multiple changes.\n"
+          + "Squash the commits with the same Change-Id or "
+          + "ensure Change-Ids are unique for each commit";
+
+  private ReceiveConstants() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java
new file mode 100644
index 0000000..16cba53
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReceiveRefFilter.java
@@ -0,0 +1,37 @@
+// 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.git.receive;
+
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CACHE_AUTOMERGE;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
+
+import com.google.common.collect.Maps;
+import java.util.Map;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.transport.RefFilter;
+
+class ReceiveRefFilter implements RefFilter {
+  @Override
+  public Map<String, Ref> filter(Map<String, Ref> refs) {
+    Map<String, Ref> filteredRefs = Maps.newHashMapWithExpectedSize(refs.size());
+    for (Map.Entry<String, Ref> e : refs.entrySet()) {
+      String name = e.getKey();
+      if (!name.startsWith(REFS_CHANGES) && !name.startsWith(REFS_CACHE_AUTOMERGE)) {
+        filteredRefs.put(name, e.getValue());
+      }
+    }
+    return filteredRefs;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReplaceOp.java
new file mode 100644
index 0000000..4455aed
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/receive/ReplaceOp.java
@@ -0,0 +1,604 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.git.receive;
+
+import static com.google.gerrit.common.FooterConstants.CHANGE_ID;
+import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromFooters;
+import static com.google.gerrit.server.mail.MailUtil.getRecipientsFromReviewers;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
+import com.google.gerrit.extensions.client.ChangeKind;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.server.ApprovalCopier;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountResolver;
+import com.google.gerrit.server.change.ChangeKindCache;
+import com.google.gerrit.server.change.EmailReviewComments;
+import com.google.gerrit.server.extensions.events.CommentAdded;
+import com.google.gerrit.server.extensions.events.RevisionCreated;
+import com.google.gerrit.server.git.MergedByPushOp;
+import com.google.gerrit.server.git.SendEmailExecutor;
+import com.google.gerrit.server.git.receive.ReceiveCommits.MagicBranchInput;
+import com.google.gerrit.server.mail.MailUtil.MailRecipients;
+import com.google.gerrit.server.mail.send.ReplacePatchSetSender;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.RequestScopePropagator;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.util.Providers;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushCertificate;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ReplaceOp implements BatchUpdateOp {
+  public interface Factory {
+    ReplaceOp create(
+        ProjectControl projectControl,
+        Branch.NameKey dest,
+        boolean checkMergedInto,
+        @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
+        @Assisted("priorCommitId") ObjectId priorCommit,
+        @Assisted("patchSetId") PatchSet.Id patchSetId,
+        @Assisted("commitId") ObjectId commitId,
+        PatchSetInfo info,
+        List<String> groups,
+        @Nullable MagicBranchInput magicBranch,
+        @Nullable PushCertificate pushCertificate);
+  }
+
+  private static final Logger log = LoggerFactory.getLogger(ReplaceOp.class);
+
+  private static final String CHANGE_IS_CLOSED = "change is closed";
+
+  private final AccountResolver accountResolver;
+  private final ApprovalCopier approvalCopier;
+  private final ApprovalsUtil approvalsUtil;
+  private final ChangeData.Factory changeDataFactory;
+  private final ChangeKindCache changeKindCache;
+  private final ChangeMessagesUtil cmUtil;
+  private final CommentsUtil commentsUtil;
+  private final EmailReviewComments.Factory emailCommentsFactory;
+  private final ExecutorService sendEmailExecutor;
+  private final RevisionCreated revisionCreated;
+  private final CommentAdded commentAdded;
+  private final MergedByPushOp.Factory mergedByPushOpFactory;
+  private final PatchSetUtil psUtil;
+  private final ReplacePatchSetSender.Factory replacePatchSetFactory;
+  private final ProjectCache projectCache;
+
+  private final ProjectControl projectControl;
+  private final Branch.NameKey dest;
+  private final boolean checkMergedInto;
+  private final PatchSet.Id priorPatchSetId;
+  private final ObjectId priorCommitId;
+  private final PatchSet.Id patchSetId;
+  private final ObjectId commitId;
+  private final PatchSetInfo info;
+  private final MagicBranchInput magicBranch;
+  private final PushCertificate pushCertificate;
+  private List<String> groups = ImmutableList.of();
+
+  private final Map<String, Short> approvals = new HashMap<>();
+  private final MailRecipients recipients = new MailRecipients();
+  private RevCommit commit;
+  private ReceiveCommand cmd;
+  private ChangeNotes notes;
+  private PatchSet newPatchSet;
+  private ChangeKind changeKind;
+  private ChangeMessage msg;
+  private List<Comment> comments = ImmutableList.of();
+  private String rejectMessage;
+  private MergedByPushOp mergedByPushOp;
+  private RequestScopePropagator requestScopePropagator;
+
+  @Inject
+  ReplaceOp(
+      AccountResolver accountResolver,
+      ApprovalCopier approvalCopier,
+      ApprovalsUtil approvalsUtil,
+      ChangeData.Factory changeDataFactory,
+      ChangeKindCache changeKindCache,
+      ChangeMessagesUtil cmUtil,
+      CommentsUtil commentsUtil,
+      EmailReviewComments.Factory emailCommentsFactory,
+      RevisionCreated revisionCreated,
+      CommentAdded commentAdded,
+      MergedByPushOp.Factory mergedByPushOpFactory,
+      PatchSetUtil psUtil,
+      ReplacePatchSetSender.Factory replacePatchSetFactory,
+      ProjectCache projectCache,
+      @SendEmailExecutor ExecutorService sendEmailExecutor,
+      @Assisted ProjectControl projectControl,
+      @Assisted Branch.NameKey dest,
+      @Assisted boolean checkMergedInto,
+      @Assisted("priorPatchSetId") PatchSet.Id priorPatchSetId,
+      @Assisted("priorCommitId") ObjectId priorCommitId,
+      @Assisted("patchSetId") PatchSet.Id patchSetId,
+      @Assisted("commitId") ObjectId commitId,
+      @Assisted PatchSetInfo info,
+      @Assisted List<String> groups,
+      @Assisted @Nullable MagicBranchInput magicBranch,
+      @Assisted @Nullable PushCertificate pushCertificate) {
+    this.accountResolver = accountResolver;
+    this.approvalCopier = approvalCopier;
+    this.approvalsUtil = approvalsUtil;
+    this.changeDataFactory = changeDataFactory;
+    this.changeKindCache = changeKindCache;
+    this.cmUtil = cmUtil;
+    this.commentsUtil = commentsUtil;
+    this.emailCommentsFactory = emailCommentsFactory;
+    this.revisionCreated = revisionCreated;
+    this.commentAdded = commentAdded;
+    this.mergedByPushOpFactory = mergedByPushOpFactory;
+    this.psUtil = psUtil;
+    this.replacePatchSetFactory = replacePatchSetFactory;
+    this.projectCache = projectCache;
+    this.sendEmailExecutor = sendEmailExecutor;
+
+    this.projectControl = projectControl;
+    this.dest = dest;
+    this.checkMergedInto = checkMergedInto;
+    this.priorPatchSetId = priorPatchSetId;
+    this.priorCommitId = priorCommitId.copy();
+    this.patchSetId = patchSetId;
+    this.commitId = commitId.copy();
+    this.info = info;
+    this.groups = groups;
+    this.magicBranch = magicBranch;
+    this.pushCertificate = pushCertificate;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws Exception {
+    commit = ctx.getRevWalk().parseCommit(commitId);
+    ctx.getRevWalk().parseBody(commit);
+    changeKind =
+        changeKindCache.getChangeKind(
+            projectControl.getProject().getNameKey(),
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig(),
+            priorCommitId,
+            commitId);
+
+    if (checkMergedInto) {
+      String mergedInto = findMergedInto(ctx, dest.get(), commit);
+      if (mergedInto != null) {
+        mergedByPushOp =
+            mergedByPushOpFactory.create(requestScopePropagator, patchSetId, mergedInto);
+      }
+    }
+
+    cmd = new ReceiveCommand(ObjectId.zeroId(), commitId, patchSetId.toRefName());
+    ctx.addRefUpdate(cmd);
+  }
+
+  @Override
+  public boolean updateChange(ChangeContext ctx)
+      throws RestApiException, OrmException, IOException, PermissionBackendException {
+    notes = ctx.getNotes();
+    Change change = notes.getChange();
+    if (change == null || change.getStatus().isClosed()) {
+      rejectMessage = CHANGE_IS_CLOSED;
+      return false;
+    }
+    if (groups.isEmpty()) {
+      PatchSet prevPs = psUtil.current(ctx.getDb(), notes);
+      groups = prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of();
+    }
+
+    ChangeUpdate update = ctx.getUpdate(patchSetId);
+    update.setSubjectForCommit("Create patch set " + patchSetId.get());
+
+    String reviewMessage = null;
+    String psDescription = null;
+    if (magicBranch != null) {
+      recipients.add(magicBranch.getMailRecipients());
+      reviewMessage = magicBranch.message;
+      psDescription = magicBranch.message;
+      approvals.putAll(magicBranch.labels);
+      Set<String> hashtags = magicBranch.hashtags;
+      if (hashtags != null && !hashtags.isEmpty()) {
+        hashtags.addAll(notes.getHashtags());
+        update.setHashtags(hashtags);
+      }
+      if (magicBranch.topic != null && !magicBranch.topic.equals(ctx.getChange().getTopic())) {
+        update.setTopic(magicBranch.topic);
+      }
+      if (magicBranch.removePrivate) {
+        change.setPrivate(false);
+        update.setPrivate(false);
+      } else if (magicBranch.isPrivate) {
+        change.setPrivate(true);
+        update.setPrivate(true);
+      }
+      if (magicBranch.ready) {
+        change.setWorkInProgress(false);
+        change.setReviewStarted(true);
+        update.setWorkInProgress(false);
+      } else if (magicBranch.workInProgress) {
+        change.setWorkInProgress(true);
+        update.setWorkInProgress(true);
+      }
+      if (shouldPublishComments()) {
+        boolean workInProgress = change.isWorkInProgress();
+        if (magicBranch != null && magicBranch.workInProgress) {
+          workInProgress = true;
+        }
+        comments = publishComments(ctx, workInProgress);
+      }
+    }
+
+    newPatchSet =
+        psUtil.insert(
+            ctx.getDb(),
+            ctx.getRevWalk(),
+            update,
+            patchSetId,
+            commitId,
+            groups,
+            pushCertificate != null ? pushCertificate.toTextWithSignature() : null,
+            psDescription);
+
+    update.setPsDescription(psDescription);
+    recipients.add(getRecipientsFromFooters(accountResolver, commit.getFooterLines()));
+    recipients.remove(ctx.getAccountId());
+    ChangeData cd = changeDataFactory.create(ctx.getDb(), ctx.getNotes());
+    MailRecipients oldRecipients = getRecipientsFromReviewers(cd.reviewers());
+    Iterable<PatchSetApproval> newApprovals =
+        approvalsUtil.addApprovalsForNewPatchSet(
+            ctx.getDb(),
+            update,
+            projectControl.getProjectState().getLabelTypes(),
+            newPatchSet,
+            ctx.getUser(),
+            approvals);
+    approvalCopier.copyInReviewDb(
+        ctx.getDb(),
+        ctx.getNotes(),
+        ctx.getUser(),
+        newPatchSet,
+        ctx.getRevWalk(),
+        ctx.getRepoView().getConfig(),
+        newApprovals);
+    approvalsUtil.addReviewers(
+        ctx.getDb(),
+        update,
+        projectControl.getProjectState().getLabelTypes(),
+        change,
+        newPatchSet,
+        info,
+        recipients.getReviewers(),
+        oldRecipients.getAll());
+
+    // Check if approvals are changing in with this update. If so, add current user to reviewers.
+    // Note that this is done separately as addReviewers is filtering out the change owner as
+    // reviewer which is needed in several other code paths.
+    if (magicBranch != null && !magicBranch.labels.isEmpty()) {
+      update.putReviewer(ctx.getAccountId(), REVIEWER);
+    }
+
+    recipients.add(oldRecipients);
+
+    msg = createChangeMessage(ctx, reviewMessage);
+    cmUtil.addChangeMessage(ctx.getDb(), update, msg);
+
+    if (mergedByPushOp == null) {
+      resetChange(ctx);
+    } else {
+      mergedByPushOp.setPatchSetProvider(Providers.of(newPatchSet)).updateChange(ctx);
+    }
+
+    return true;
+  }
+
+  private ChangeMessage createChangeMessage(ChangeContext ctx, String reviewMessage)
+      throws OrmException, IOException {
+    String approvalMessage =
+        ApprovalsUtil.renderMessageWithApprovals(
+            patchSetId.get(), approvals, scanLabels(ctx, approvals));
+    String kindMessage = changeKindMessage(changeKind);
+    StringBuilder message = new StringBuilder(approvalMessage);
+    if (!Strings.isNullOrEmpty(kindMessage)) {
+      message.append(kindMessage);
+    } else {
+      message.append('.');
+    }
+    if (comments.size() == 1) {
+      message.append("\n\n(1 comment)");
+    } else if (comments.size() > 1) {
+      message.append(String.format("\n\n(%d comments)", comments.size()));
+    }
+    if (!Strings.isNullOrEmpty(reviewMessage)) {
+      message.append("\n\n").append(reviewMessage);
+    }
+    boolean workInProgress = ctx.getChange().isWorkInProgress();
+    if (magicBranch != null && magicBranch.workInProgress) {
+      workInProgress = true;
+    }
+    return ChangeMessagesUtil.newMessage(
+        patchSetId,
+        ctx.getUser(),
+        ctx.getWhen(),
+        message.toString(),
+        ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
+  }
+
+  private String changeKindMessage(ChangeKind changeKind) {
+    switch (changeKind) {
+      case MERGE_FIRST_PARENT_UPDATE:
+      case TRIVIAL_REBASE:
+      case NO_CHANGE:
+        return ": Patch Set " + priorPatchSetId.get() + " was rebased.";
+      case NO_CODE_CHANGE:
+        return ": Commit message was updated.";
+      case REWORK:
+      default:
+        return null;
+    }
+  }
+
+  private Map<String, PatchSetApproval> scanLabels(ChangeContext ctx, Map<String, Short> approvals)
+      throws OrmException, IOException {
+    Map<String, PatchSetApproval> current = new HashMap<>();
+    // We optimize here and only retrieve current when approvals provided
+    if (!approvals.isEmpty()) {
+      for (PatchSetApproval a :
+          approvalsUtil.byPatchSetUser(
+              ctx.getDb(),
+              ctx.getNotes(),
+              ctx.getUser(),
+              priorPatchSetId,
+              ctx.getAccountId(),
+              ctx.getRevWalk(),
+              ctx.getRepoView().getConfig())) {
+        if (a.isLegacySubmit()) {
+          continue;
+        }
+
+        LabelType lt = projectControl.getProjectState().getLabelTypes().byLabel(a.getLabelId());
+        if (lt != null) {
+          current.put(lt.getName(), a);
+        }
+      }
+    }
+    return current;
+  }
+
+  private void resetChange(ChangeContext ctx) {
+    Change change = ctx.getChange();
+    if (!change.currentPatchSetId().equals(priorPatchSetId)) {
+      return;
+    }
+
+    if (magicBranch != null && magicBranch.topic != null) {
+      change.setTopic(magicBranch.topic);
+    }
+    change.setStatus(Change.Status.NEW);
+    change.setCurrentPatchSet(info);
+
+    List<String> idList = commit.getFooterLines(CHANGE_ID);
+    if (idList.isEmpty()) {
+      change.setKey(new Change.Key("I" + commitId.name()));
+    } else {
+      change.setKey(new Change.Key(idList.get(idList.size() - 1).trim()));
+    }
+  }
+
+  private List<Comment> publishComments(ChangeContext ctx, boolean workInProgress)
+      throws OrmException {
+    List<Comment> comments =
+        commentsUtil.draftByChangeAuthor(ctx.getDb(), ctx.getNotes(), ctx.getUser().getAccountId());
+    commentsUtil.publish(
+        ctx, patchSetId, comments, ChangeMessagesUtil.uploadedPatchSetTag(workInProgress));
+    return comments;
+  }
+
+  @Override
+  public void postUpdate(Context ctx) throws Exception {
+    if (changeKind != ChangeKind.TRIVIAL_REBASE) {
+      // TODO(dborowitz): Merge email templates so we only have to send one.
+      Runnable e = new ReplaceEmailTask(ctx);
+      if (requestScopePropagator != null) {
+        @SuppressWarnings("unused")
+        Future<?> possiblyIgnoredError = sendEmailExecutor.submit(requestScopePropagator.wrap(e));
+      } else {
+        e.run();
+      }
+    }
+
+    NotifyHandling notify = magicBranch != null ? magicBranch.getNotify(notes) : NotifyHandling.ALL;
+
+    if (shouldPublishComments()) {
+      emailCommentsFactory
+          .create(
+              notify,
+              magicBranch != null ? magicBranch.getAccountsToNotify() : ImmutableListMultimap.of(),
+              notes,
+              newPatchSet,
+              ctx.getUser().asIdentifiedUser(),
+              msg,
+              comments,
+              msg.getMessage(),
+              ImmutableList.of()) // TODO(dborowitz): Include labels.
+          .sendAsync();
+    }
+
+    revisionCreated.fire(notes.getChange(), newPatchSet, ctx.getAccount(), ctx.getWhen(), notify);
+    try {
+      fireCommentAddedEvent(ctx);
+    } catch (Exception e) {
+      log.warn("comment-added event invocation failed", e);
+    }
+    if (mergedByPushOp != null) {
+      mergedByPushOp.postUpdate(ctx);
+    }
+  }
+
+  private class ReplaceEmailTask implements Runnable {
+    private final Context ctx;
+
+    private ReplaceEmailTask(Context ctx) {
+      this.ctx = ctx;
+    }
+
+    @Override
+    public void run() {
+      try {
+        ReplacePatchSetSender cm =
+            replacePatchSetFactory.create(
+                projectControl.getProject().getNameKey(), notes.getChangeId());
+        cm.setFrom(ctx.getAccount().getId());
+        cm.setPatchSet(newPatchSet, info);
+        cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
+        if (magicBranch != null) {
+          cm.setNotify(magicBranch.getNotify(notes));
+          cm.setAccountsToNotify(magicBranch.getAccountsToNotify());
+        }
+        cm.addReviewers(recipients.getReviewers());
+        cm.addExtraCC(recipients.getCcOnly());
+        cm.send();
+      } catch (Exception e) {
+        log.error("Cannot send email for new patch set " + newPatchSet.getId(), e);
+      }
+    }
+
+    @Override
+    public String toString() {
+      return "send-email newpatchset";
+    }
+  }
+
+  private void fireCommentAddedEvent(Context ctx) throws IOException {
+    if (approvals.isEmpty()) {
+      return;
+    }
+
+    /* For labels that are not set in this operation, show the "current" value
+     * of 0, and no oldValue as the value was not modified by this operation.
+     * For labels that are set in this operation, the value was modified, so
+     * show a transition from an oldValue of 0 to the new value.
+     */
+    List<LabelType> labels =
+        projectCache
+            .checkedGet(ctx.getProject())
+            .getLabelTypes(notes, ctx.getUser())
+            .getLabelTypes();
+    Map<String, Short> allApprovals = new HashMap<>();
+    Map<String, Short> oldApprovals = new HashMap<>();
+    for (LabelType lt : labels) {
+      allApprovals.put(lt.getName(), (short) 0);
+      oldApprovals.put(lt.getName(), null);
+    }
+    for (Map.Entry<String, Short> entry : approvals.entrySet()) {
+      if (entry.getValue() != 0) {
+        allApprovals.put(entry.getKey(), entry.getValue());
+        oldApprovals.put(entry.getKey(), (short) 0);
+      }
+    }
+
+    commentAdded.fire(
+        notes.getChange(),
+        newPatchSet,
+        ctx.getAccount(),
+        null,
+        allApprovals,
+        oldApprovals,
+        ctx.getWhen());
+  }
+
+  public PatchSet getPatchSet() {
+    return newPatchSet;
+  }
+
+  public Change getChange() {
+    return notes.getChange();
+  }
+
+  public String getRejectMessage() {
+    return rejectMessage;
+  }
+
+  public ReceiveCommand getCommand() {
+    return cmd;
+  }
+
+  public ReplaceOp setRequestScopePropagator(RequestScopePropagator requestScopePropagator) {
+    this.requestScopePropagator = requestScopePropagator;
+    return this;
+  }
+
+  private static String findMergedInto(Context ctx, String first, RevCommit commit) {
+    try {
+      RevWalk rw = ctx.getRevWalk();
+      Optional<ObjectId> firstId = ctx.getRepoView().getRef(first);
+      if (firstId.isPresent() && rw.isMergedInto(commit, rw.parseCommit(firstId.get()))) {
+        return first;
+      }
+
+      for (Map.Entry<String, ObjectId> e : ctx.getRepoView().getRefs(R_HEADS).entrySet()) {
+        if (rw.isMergedInto(commit, rw.parseCommit(e.getValue()))) {
+          return R_HEADS + e.getKey();
+        }
+      }
+      return null;
+    } catch (IOException e) {
+      log.warn("Can't check for already submitted change", e);
+      return null;
+    }
+  }
+
+  private boolean shouldPublishComments() {
+    return magicBranch != null && magicBranch.shouldPublishComments();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
index 96b5b55..77aa950 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
@@ -37,7 +37,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 public class CherryPick extends SubmitStrategy {
 
@@ -90,12 +89,16 @@
     }
 
     @Override
-    protected void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
+    protected void updateRepoImpl(RepoContext ctx)
+        throws IntegrationException, IOException, OrmException {
       // If there is only one parent, a cherry-pick can be done by taking the
       // delta relative to that one parent and redoing that on the current merge
       // tip.
       args.rw.parseBody(toMerge);
-      psId = ChangeUtil.nextPatchSetId(args.repo, toMerge.change().currentPatchSetId());
+      psId =
+          ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+              ctx.getRepoView().getRefs(getId().toRefPrefix()),
+              toMerge.change().currentPatchSetId());
       RevCommit mergeTip = args.mergeTip.getCurrentTip();
       args.rw.parseBody(mergeTip);
       String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
@@ -105,8 +108,8 @@
       try {
         newCommit =
             args.mergeUtil.createCherryPickFromCommit(
-                args.repo,
-                args.inserter,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
                 args.mergeTip.getCurrentTip(),
                 toMerge,
                 committer,
@@ -132,7 +135,7 @@
       args.mergeTip.moveTipTo(newCommit, newCommit);
       args.commitStatus.put(newCommit);
 
-      ctx.addRefUpdate(new ReceiveCommand(ObjectId.zeroId(), newCommit, psId.toRefName()));
+      ctx.addRefUpdate(ObjectId.zeroId(), newCommit, psId.toRefName());
       patchSetInfo = args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, psId);
     }
 
@@ -154,7 +157,6 @@
               ctx.getUpdate(psId),
               psId,
               newCommit,
-              false,
               prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of(),
               null,
               null);
@@ -163,7 +165,7 @@
       // Don't copy approvals, as this is already taken care of by
       // SubmitStrategyOp.
 
-      newCommit.setControl(ctx.getControl());
+      newCommit.setNotes(ctx.getNotes());
       return newPs;
     }
   }
@@ -195,9 +197,9 @@
             args.mergeUtil.mergeOneCommit(
                 myIdent,
                 myIdent,
-                args.repo,
                 args.rw,
-                args.inserter,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
                 args.destBranch,
                 mergeTip.getCurrentTip(),
                 toMerge);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
index 7151486..38a193d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/FastForwardOnly.java
@@ -29,8 +29,7 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
       throws IntegrationException {
-    List<CodeReviewCommit> sorted =
-        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge, args.incoming);
+    List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     CodeReviewCommit newTipCommit =
         args.mergeUtil.getFirstFastForward(args.mergeTip.getInitialTip(), args.rw, sorted);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
index ce045f8..1664be4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeAlways.java
@@ -28,8 +28,7 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
       throws IntegrationException {
-    List<CodeReviewCommit> sorted =
-        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge, args.incoming);
+    List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     if (args.mergeTip.getInitialTip() == null && !sorted.isEmpty()) {
       // The branch is unborn. Take a fast-forward resolution to
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
index e7db1a8..d30aab2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeIfNecessary.java
@@ -28,8 +28,7 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
       throws IntegrationException {
-    List<CodeReviewCommit> sorted =
-        args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge, args.incoming);
+    List<CodeReviewCommit> sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, toMerge);
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
 
     if (args.mergeTip.getInitialTip() == null
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
index 2a6680c..3c3812d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/MergeOneOp.java
@@ -28,7 +28,8 @@
   @Override
   public void updateRepoImpl(RepoContext ctx) throws IntegrationException, IOException {
     PersonIdent caller =
-        ctx.getIdentifiedUser().newCommitterIdent(ctx.getWhen(), ctx.getTimeZone());
+        ctx.getIdentifiedUser()
+            .newCommitterIdent(args.serverIdent.getWhen(), args.serverIdent.getTimeZone());
     if (args.mergeTip.getCurrentTip() == null) {
       throw new IllegalStateException(
           "cannot merge commit "
@@ -36,16 +37,13 @@
               + " onto a null tip; expected at least one fast-forward prior to"
               + " this operation");
     }
-    // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
-    // When hoisting BatchUpdate into MergeOp, we will need to teach
-    // BatchUpdate how to produce CodeReviewRevWalks.
     CodeReviewCommit merged =
         args.mergeUtil.mergeOneCommit(
             caller,
             args.serverIdent,
-            ctx.getRepository(),
             args.rw,
             ctx.getInserter(),
+            ctx.getRepoView().getConfig(),
             args.destBranch,
             args.mergeTip.getCurrentTip(),
             toMerge);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
index 43ab01b..5421254 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/RebaseSubmitStrategy.java
@@ -28,8 +28,7 @@
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.MergeIdenticalTreeException;
 import com.google.gerrit.server.git.MergeTip;
-import com.google.gerrit.server.git.RebaseSorter;
-import com.google.gerrit.server.git.validators.CommitValidators;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.update.ChangeContext;
@@ -42,8 +41,8 @@
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 
 /** This strategy covers RebaseAlways and RebaseIfNecessary ones. */
 public class RebaseSubmitStrategy extends SubmitStrategy {
@@ -57,7 +56,12 @@
   @Override
   public List<SubmitStrategyOp> buildOps(Collection<CodeReviewCommit> toMerge)
       throws IntegrationException {
-    List<CodeReviewCommit> sorted = sort(toMerge);
+    List<CodeReviewCommit> sorted;
+    try {
+      sorted = args.rebaseSorter.sort(toMerge);
+    } catch (IOException e) {
+      throw new IntegrationException("Commit sorting failed", e);
+    }
     List<SubmitStrategyOp> ops = new ArrayList<>(sorted.size());
     boolean first = true;
 
@@ -67,7 +71,7 @@
         // MERGE_IF_NECESSARY semantics to avoid creating duplicate
         // commits.
         //
-        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted, args.incoming);
+        sorted = args.mergeUtil.reduceToMinimalMerge(args.mergeSorter, sorted);
         break;
       }
     }
@@ -115,10 +119,7 @@
     @Override
     public void updateRepoImpl(RepoContext ctx)
         throws IntegrationException, InvalidChangeOperationException, RestApiException, IOException,
-            OrmException {
-      // TODO(dborowitz): args.rw is needed because it's a CodeReviewRevWalk.
-      // When hoisting BatchUpdate into MergeOp, we will need to teach
-      // BatchUpdate how to produce CodeReviewRevWalks.
+            OrmException, PermissionBackendException {
       if (args.mergeUtil.canFastForward(
           args.mergeSorter, args.mergeTip.getCurrentTip(), args.rw, toMerge)) {
         if (!rebaseAlways) {
@@ -129,7 +130,10 @@
         }
         // RebaseAlways means we modify commit message.
         args.rw.parseBody(toMerge);
-        newPatchSetId = ChangeUtil.nextPatchSetId(args.repo, toMerge.change().currentPatchSetId());
+        newPatchSetId =
+            ChangeUtil.nextPatchSetIdFromChangeRefsMap(
+                ctx.getRepoView().getRefs(getId().toRefPrefix()),
+                toMerge.change().currentPatchSetId());
         RevCommit mergeTip = args.mergeTip.getCurrentTip();
         args.rw.parseBody(mergeTip);
         String cherryPickCmtMsg = args.mergeUtil.createCommitMessageOnSubmit(toMerge, mergeTip);
@@ -138,8 +142,8 @@
         try {
           newCommit =
               args.mergeUtil.createCherryPickFromCommit(
-                  args.repo,
-                  args.inserter,
+                  ctx.getInserter(),
+                  ctx.getRepoView().getConfig(),
                   args.mergeTip.getCurrentTip(),
                   toMerge,
                   committer,
@@ -156,27 +160,26 @@
           toMerge.setStatusCode(SKIPPED_IDENTICAL_TREE);
           return;
         }
-        ctx.addRefUpdate(
-            new ReceiveCommand(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName()));
+        ctx.addRefUpdate(ObjectId.zeroId(), newCommit, newPatchSetId.toRefName());
       } else {
         // Stale read of patch set is ok; see comments in RebaseChangeOp.
-        PatchSet origPs =
-            args.psUtil.get(ctx.getDb(), toMerge.getControl().getNotes(), toMerge.getPatchsetId());
+        PatchSet origPs = args.psUtil.get(ctx.getDb(), toMerge.getNotes(), toMerge.getPatchsetId());
         rebaseOp =
             args.rebaseFactory
-                .create(toMerge.getControl(), origPs, args.mergeTip.getCurrentTip().name())
+                .create(toMerge.notes(), origPs, args.mergeTip.getCurrentTip())
                 .setFireRevisionCreated(false)
                 // Bypass approval copier since SubmitStrategyOp copy all approvals
                 // later anyway.
                 .setCopyApprovals(false)
-                .setValidatePolicy(CommitValidators.Policy.NONE)
+                .setValidate(false)
                 .setCheckAddPatchSetPermission(false)
                 // RebaseAlways should set always modify commit message like
                 // Cherry-Pick strategy.
                 .setDetailedCommitMessage(rebaseAlways)
                 // Do not post message after inserting new patchset because there
                 // will be one about change being merged already.
-                .setPostMessage(false);
+                .setPostMessage(false)
+                .setMatchAuthorToCommitterDate(args.project.isMatchAuthorToCommitterDate());
         try {
           rebaseOp.updateRepo(ctx);
         } catch (MergeConflictException | NoSuchChangeException e) {
@@ -219,7 +222,6 @@
                 ctx.getUpdate(newPatchSetId),
                 newPatchSetId,
                 newCommit,
-                false,
                 prevPs != null ? prevPs.getGroups() : ImmutableList.<String>of(),
                 null,
                 null);
@@ -227,7 +229,7 @@
       ctx.getChange()
           .setCurrentPatchSet(
               args.patchSetInfoFactory.get(ctx.getRevWalk(), newCommit, newPatchSetId));
-      newCommit.setControl(ctx.getControl());
+      newCommit.setNotes(ctx.getNotes());
       return newPs;
     }
 
@@ -269,9 +271,9 @@
             args.mergeUtil.mergeOneCommit(
                 caller,
                 caller,
-                args.repo,
                 args.rw,
-                args.inserter,
+                ctx.getInserter(),
+                ctx.getRepoView().getConfig(),
                 args.destBranch,
                 mergeTip.getCurrentTip(),
                 toMerge);
@@ -287,27 +289,15 @@
     args.alreadyAccepted.add(mergeTip.getCurrentTip());
   }
 
-  private List<CodeReviewCommit> sort(Collection<CodeReviewCommit> toSort)
-      throws IntegrationException {
-    try {
-      return new RebaseSorter(
-              args.rw,
-              args.mergeTip.getInitialTip(),
-              args.alreadyAccepted,
-              args.canMergeFlag,
-              args.internalChangeQuery)
-          .sort(toSort);
-    } catch (IOException e) {
-      throw new IntegrationException("Commit sorting failed", e);
-    }
-  }
-
   static boolean dryRun(
-      SubmitDryRun.Arguments args, CodeReviewCommit mergeTip, CodeReviewCommit toMerge)
+      SubmitDryRun.Arguments args,
+      Repository repo,
+      CodeReviewCommit mergeTip,
+      CodeReviewCommit toMerge)
       throws IntegrationException {
     // Test for merge instead of cherry pick to avoid false negatives
     // on commit chains.
     return !args.mergeUtil.hasMissingDependencies(args.mergeSorter, toMerge)
-        && args.mergeUtil.canMerge(args.mergeSorter, args.repo, mergeTip, toMerge);
+        && args.mergeUtil.canMerge(args.mergeSorter, repo, mergeTip, toMerge);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
index d375b6e..3a954fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitDryRun.java
@@ -14,7 +14,10 @@
 
 package com.google.gerrit.server.git.strategy;
 
-import com.google.common.collect.FluentIterable;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.server.git.CodeReviewCommit;
@@ -37,6 +40,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -59,10 +63,13 @@
     }
   }
 
-  public static Iterable<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
-    return FluentIterable.from(repo.getRefDatabase().getRefs(Constants.R_HEADS).values())
-        .append(repo.getRefDatabase().getRefs(Constants.R_TAGS).values())
-        .transform(Ref::getObjectId);
+  public static Set<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
+    return Streams.concat(
+            repo.getRefDatabase().getRefs(Constants.R_HEADS).values().stream(),
+            repo.getRefDatabase().getRefs(Constants.R_TAGS).values().stream())
+        .map(Ref::getObjectId)
+        .filter(o -> o != null)
+        .collect(toSet());
   }
 
   public static Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw) throws IOException {
@@ -75,6 +82,9 @@
       throws IOException {
     for (ObjectId id : ids) {
       RevObject obj = rw.parseAny(id);
+      if (obj instanceof RevTag) {
+        obj = rw.peel(obj);
+      }
       if (obj instanceof RevCommit) {
         out.add((RevCommit) obj);
       }
@@ -108,7 +118,7 @@
             repo,
             rw,
             mergeUtilFactory.create(getProject(destBranch)),
-            new MergeSorter(rw, alreadyAccepted, canMerge));
+            new MergeSorter(rw, alreadyAccepted, canMerge, ImmutableSet.of(toMergeCommit)));
 
     switch (submitType) {
       case CHERRY_PICK:
@@ -120,9 +130,9 @@
       case MERGE_IF_NECESSARY:
         return MergeIfNecessary.dryRun(args, tipCommit, toMergeCommit);
       case REBASE_IF_NECESSARY:
-        return RebaseIfNecessary.dryRun(args, tipCommit, toMergeCommit);
+        return RebaseIfNecessary.dryRun(args, repo, tipCommit, toMergeCommit);
       case REBASE_ALWAYS:
-        return RebaseAlways.dryRun(args, tipCommit, toMergeCommit);
+        return RebaseAlways.dryRun(args, repo, tipCommit, toMergeCommit);
       default:
         String errorMsg = "No submit strategy for: " + submitType;
         log.error(errorMsg);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
index f721978..79c0cdb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategy.java
@@ -18,12 +18,13 @@
 
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Sets;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -32,6 +33,7 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.change.RebaseChangeOp;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
@@ -43,27 +45,26 @@
 import com.google.gerrit.server.git.MergeSorter;
 import com.google.gerrit.server.git.MergeTip;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.git.RebaseSorter;
 import com.google.gerrit.server.git.SubmoduleOp;
 import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gerrit.server.update.BatchUpdate;
 import com.google.gerrit.server.util.RequestId;
+import com.google.inject.Inject;
 import com.google.inject.Module;
+import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
-import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 
@@ -92,14 +93,12 @@
           CodeReviewRevWalk rw,
           IdentifiedUser caller,
           MergeTip mergeTip,
-          ObjectInserter inserter,
-          Repository repo,
           RevFlag canMergeFlag,
           ReviewDb db,
           Set<RevCommit> alreadyAccepted,
           Set<CodeReviewCommit> incoming,
           RequestId submissionId,
-          NotifyHandling notifyHandling,
+          SubmitInput submitInput,
           ListMultimap<RecipientType, Account.Id> accountsToNotify,
           SubmoduleOp submoduleOp,
           boolean dryrun);
@@ -107,8 +106,6 @@
 
     final AccountCache accountCache;
     final ApprovalsUtil approvalsUtil;
-    final BatchUpdate.Factory batchUpdateFactory;
-    final ChangeControl.GenericFactory changeControlFactory;
     final ChangeMerged changeMerged;
     final ChangeMessagesUtil cmUtil;
     final EmailMerge.Factory mergedSenderFactory;
@@ -121,36 +118,32 @@
     final RebaseChangeOp.Factory rebaseFactory;
     final OnSubmitValidators.Factory onSubmitValidatorsFactory;
     final TagCache tagCache;
-    final InternalChangeQuery internalChangeQuery;
+    final Provider<InternalChangeQuery> queryProvider;
 
     final Branch.NameKey destBranch;
     final CodeReviewRevWalk rw;
     final CommitStatus commitStatus;
     final IdentifiedUser caller;
     final MergeTip mergeTip;
-    final ObjectInserter inserter;
-    final Repository repo;
     final RevFlag canMergeFlag;
     final ReviewDb db;
     final Set<RevCommit> alreadyAccepted;
-    final Set<CodeReviewCommit> incoming;
     final RequestId submissionId;
     final SubmitType submitType;
-    final NotifyHandling notifyHandling;
+    final SubmitInput submitInput;
     final ListMultimap<RecipientType, Account.Id> accountsToNotify;
     final SubmoduleOp submoduleOp;
 
     final ProjectState project;
     final MergeSorter mergeSorter;
+    final RebaseSorter rebaseSorter;
     final MergeUtil mergeUtil;
     final boolean dryrun;
 
-    @AssistedInject
+    @Inject
     Arguments(
         AccountCache accountCache,
         ApprovalsUtil approvalsUtil,
-        BatchUpdate.Factory batchUpdateFactory,
-        ChangeControl.GenericFactory changeControlFactory,
         ChangeMerged changeMerged,
         ChangeMessagesUtil cmUtil,
         EmailMerge.Factory mergedSenderFactory,
@@ -164,28 +157,24 @@
         RebaseChangeOp.Factory rebaseFactory,
         OnSubmitValidators.Factory onSubmitValidatorsFactory,
         TagCache tagCache,
-        InternalChangeQuery internalChangeQuery,
+        Provider<InternalChangeQuery> queryProvider,
         @Assisted Branch.NameKey destBranch,
         @Assisted CommitStatus commitStatus,
         @Assisted CodeReviewRevWalk rw,
         @Assisted IdentifiedUser caller,
         @Assisted MergeTip mergeTip,
-        @Assisted ObjectInserter inserter,
-        @Assisted Repository repo,
         @Assisted RevFlag canMergeFlag,
         @Assisted ReviewDb db,
         @Assisted Set<RevCommit> alreadyAccepted,
         @Assisted Set<CodeReviewCommit> incoming,
         @Assisted RequestId submissionId,
         @Assisted SubmitType submitType,
-        @Assisted NotifyHandling notifyHandling,
+        @Assisted SubmitInput submitInput,
         @Assisted ListMultimap<RecipientType, Account.Id> accountsToNotify,
         @Assisted SubmoduleOp submoduleOp,
         @Assisted boolean dryrun) {
       this.accountCache = accountCache;
       this.approvalsUtil = approvalsUtil;
-      this.batchUpdateFactory = batchUpdateFactory;
-      this.changeControlFactory = changeControlFactory;
       this.changeMerged = changeMerged;
       this.mergedSenderFactory = mergedSenderFactory;
       this.repoManager = repoManager;
@@ -196,7 +185,7 @@
       this.projectCache = projectCache;
       this.rebaseFactory = rebaseFactory;
       this.tagCache = tagCache;
-      this.internalChangeQuery = internalChangeQuery;
+      this.queryProvider = queryProvider;
 
       this.serverIdent = serverIdent;
       this.destBranch = destBranch;
@@ -204,15 +193,12 @@
       this.rw = rw;
       this.caller = caller;
       this.mergeTip = mergeTip;
-      this.inserter = inserter;
-      this.repo = repo;
       this.canMergeFlag = canMergeFlag;
       this.db = db;
       this.alreadyAccepted = alreadyAccepted;
-      this.incoming = incoming;
       this.submissionId = submissionId;
       this.submitType = submitType;
-      this.notifyHandling = notifyHandling;
+      this.submitInput = submitInput;
       this.accountsToNotify = accountsToNotify;
       this.submoduleOp = submoduleOp;
       this.dryrun = dryrun;
@@ -222,7 +208,10 @@
               projectCache.get(destBranch.getParentKey()),
               "project not found: %s",
               destBranch.getParentKey());
-      this.mergeSorter = new MergeSorter(rw, alreadyAccepted, canMergeFlag);
+      this.mergeSorter = new MergeSorter(rw, alreadyAccepted, canMergeFlag, incoming);
+      this.rebaseSorter =
+          new RebaseSorter(
+              rw, mergeTip.getInitialTip(), alreadyAccepted, canMergeFlag, queryProvider, incoming);
       this.mergeUtil = mergeUtilFactory.create(project);
       this.onSubmitValidatorsFactory = onSubmitValidatorsFactory;
     }
@@ -260,12 +249,21 @@
     List<CodeReviewCommit> difference = new ArrayList<>(Sets.difference(toMerge, added));
     Collections.reverse(difference);
     for (CodeReviewCommit c : difference) {
-      bu.addOp(c.change().getId(), new ImplicitIntegrateOp(args, c));
+      Change.Id id = c.change().getId();
+      bu.addOp(id, new ImplicitIntegrateOp(args, c));
+      maybeAddTestHelperOp(bu, id);
     }
 
     // Then ops for explicitly merged changes
     for (SubmitStrategyOp op : ops) {
       bu.addOp(op.getId(), op);
+      maybeAddTestHelperOp(bu, op.getId());
+    }
+  }
+
+  private void maybeAddTestHelperOp(BatchUpdate bu, Change.Id changeId) {
+    if (args.submitInput instanceof TestSubmitInput) {
+      bu.addOp(changeId, new TestHelperOp(changeId, args));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
index fc4817d..7678623 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyFactory.java
@@ -15,8 +15,8 @@
 package com.google.gerrit.server.git.strategy;
 
 import com.google.common.collect.ListMultimap;
-import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -32,8 +32,6 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.Set;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevFlag;
 import org.slf4j.Logger;
@@ -54,9 +52,7 @@
   public SubmitStrategy create(
       SubmitType submitType,
       ReviewDb db,
-      Repository repo,
       CodeReviewRevWalk rw,
-      ObjectInserter inserter,
       RevFlag canMergeFlag,
       Set<RevCommit> alreadyAccepted,
       Set<CodeReviewCommit> incoming,
@@ -65,7 +61,7 @@
       MergeTip mergeTip,
       CommitStatus commitStatus,
       RequestId submissionId,
-      NotifyHandling notifyHandling,
+      SubmitInput submitInput,
       ListMultimap<RecipientType, Account.Id> accountsToNotify,
       SubmoduleOp submoduleOp,
       boolean dryrun)
@@ -78,14 +74,12 @@
             rw,
             caller,
             mergeTip,
-            inserter,
-            repo,
             canMergeFlag,
             db,
             alreadyAccepted,
             incoming,
             submissionId,
-            notifyHandling,
+            submitInput,
             accountsToNotify,
             submoduleOp,
             dryrun);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
index 89bd560..9a362d4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
@@ -53,7 +53,6 @@
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
@@ -62,7 +61,6 @@
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
@@ -79,7 +77,8 @@
   private ObjectId mergeResultRev;
   private PatchSet mergedPatchSet;
   private Change updatedChange;
-  private CodeReviewCommit alreadyMerged;
+  private CodeReviewCommit alreadyMergedCommit;
+  private boolean changeAlreadyMerged;
 
   protected SubmitStrategyOp(SubmitStrategy.Arguments args, CodeReviewCommit toMerge) {
     this.args = args;
@@ -105,14 +104,20 @@
   @Override
   public final void updateRepo(RepoContext ctx) throws Exception {
     logDebug("{}#updateRepo for change {}", getClass().getSimpleName(), toMerge.change().getId());
+    checkState(
+        ctx.getRevWalk() == args.rw,
+        "SubmitStrategyOp requires callers to call BatchUpdate#setRepository with exactly the same"
+            + " CodeReviewRevWalk instance from the SubmitStrategy.Arguments: %s != %s",
+        ctx.getRevWalk(),
+        args.rw);
     // Run the submit strategy implementation and record the merge tip state so
     // we can create the ref update.
     CodeReviewCommit tipBefore = args.mergeTip.getCurrentTip();
-    alreadyMerged = getAlreadyMergedCommit(ctx);
-    if (alreadyMerged == null) {
+    alreadyMergedCommit = getAlreadyMergedCommit(ctx);
+    if (alreadyMergedCommit == null) {
       updateRepoImpl(ctx);
     } else {
-      logDebug("Already merged as {}", alreadyMerged.name());
+      logDebug("Already merged as {}", alreadyMergedCommit.name());
     }
     CodeReviewCommit tipAfter = args.mergeTip.getCurrentTip();
 
@@ -162,19 +167,20 @@
     }
     CodeReviewRevWalk rw = (CodeReviewRevWalk) ctx.getRevWalk();
     Change.Id id = getId();
+    String refPrefix = id.toRefPrefix();
 
-    Collection<Ref> refs = ctx.getRepository().getRefDatabase().getRefs(id.toRefPrefix()).values();
+    Map<String, ObjectId> refs = ctx.getRepoView().getRefs(refPrefix);
     List<CodeReviewCommit> commits = new ArrayList<>(refs.size());
-    for (Ref ref : refs) {
-      PatchSet.Id psId = PatchSet.Id.fromRef(ref.getName());
+    for (Map.Entry<String, ObjectId> e : refs.entrySet()) {
+      PatchSet.Id psId = PatchSet.Id.fromRef(refPrefix + e.getKey());
       if (psId == null) {
         continue;
       }
       try {
-        CodeReviewCommit c = rw.parseCommit(ref.getObjectId());
+        CodeReviewCommit c = rw.parseCommit(e.getValue());
         c.setPatchsetId(psId);
         commits.add(c);
-      } catch (MissingObjectException | IncorrectObjectTypeException e) {
+      } catch (MissingObjectException | IncorrectObjectTypeException ex) {
         continue; // Bogus ref, can't be merged into tip so we don't care.
       }
     }
@@ -210,12 +216,27 @@
   @Override
   public final boolean updateChange(ChangeContext ctx) throws Exception {
     logDebug("{}#updateChange for change {}", getClass().getSimpleName(), toMerge.change().getId());
-    toMerge.setControl(ctx.getControl()); // Update change and notes from ctx.
+    toMerge.setNotes(ctx.getNotes()); // Update change and notes from ctx.
     PatchSet.Id oldPsId = checkNotNull(toMerge.getPatchsetId());
     PatchSet.Id newPsId;
 
-    if (alreadyMerged != null) {
-      alreadyMerged.setControl(ctx.getControl());
+    if (ctx.getChange().getStatus() == Change.Status.MERGED) {
+      // Either another thread won a race, or we are retrying a whole topic submission after one
+      // repo failed with lock failure.
+      if (alreadyMergedCommit == null) {
+        logDebug(
+            "Change is already merged according to its status, but we were unable to find it"
+                + " merged into the current tip ({})",
+            args.mergeTip.getCurrentTip().name());
+      } else {
+        logDebug("Change is already merged");
+      }
+      changeAlreadyMerged = true;
+      return false;
+    }
+
+    if (alreadyMergedCommit != null) {
+      alreadyMergedCommit.setNotes(ctx.getNotes());
       mergedPatchSet = getOrCreateAlreadyMergedPatchSet(ctx);
       newPsId = mergedPatchSet.getId();
     } else {
@@ -258,12 +279,12 @@
     setApproval(ctx, args.caller);
 
     mergeResultRev =
-        alreadyMerged == null
+        alreadyMergedCommit == null
             ? args.mergeTip.getMergeResults().get(commit)
             // Our fixup code is not smart enough to find a merge commit
             // corresponding to the merge result. This results in a different
             // ChangeMergedEvent in the fixup case, but we'll just live with that.
-            : alreadyMerged;
+            : alreadyMergedCommit;
     try {
       setMerged(ctx, message(ctx, commit, s));
     } catch (OrmException err) {
@@ -279,13 +300,13 @@
 
   private PatchSet getOrCreateAlreadyMergedPatchSet(ChangeContext ctx)
       throws IOException, OrmException {
-    PatchSet.Id psId = alreadyMerged.getPatchsetId();
+    PatchSet.Id psId = alreadyMergedCommit.getPatchsetId();
     logDebug("Fixing up already-merged patch set {}", psId);
     PatchSet prevPs = args.psUtil.current(ctx.getDb(), ctx.getNotes());
-    ctx.getRevWalk().parseBody(alreadyMerged);
+    ctx.getRevWalk().parseBody(alreadyMergedCommit);
     ctx.getChange()
         .setCurrentPatchSet(
-            psId, alreadyMerged.getShortMessage(), ctx.getChange().getOriginalSubject());
+            psId, alreadyMergedCommit.getShortMessage(), ctx.getChange().getOriginalSubject());
     PatchSet existing = args.psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
     if (existing != null) {
       logDebug("Patch set row exists, only updating change");
@@ -295,20 +316,20 @@
     // a patch set ref. Fix up the database. Note that this uses the current
     // user as the uploader, which is as good a guess as any.
     List<String> groups =
-        prevPs != null ? prevPs.getGroups() : GroupCollector.getDefaultGroups(alreadyMerged);
+        prevPs != null ? prevPs.getGroups() : GroupCollector.getDefaultGroups(alreadyMergedCommit);
     return args.psUtil.insert(
         ctx.getDb(),
         ctx.getRevWalk(),
         ctx.getUpdate(psId),
         psId,
-        alreadyMerged,
-        false,
+        alreadyMergedCommit,
         groups,
         null,
         null);
   }
 
-  private void setApproval(ChangeContext ctx, IdentifiedUser user) throws OrmException {
+  private void setApproval(ChangeContext ctx, IdentifiedUser user)
+      throws OrmException, IOException {
     Change.Id id = ctx.getChange().getId();
     List<SubmitRecord> records = args.commitStatus.getSubmitRecords(id);
     PatchSet.Id oldPsId = toMerge.getPatchsetId();
@@ -330,11 +351,17 @@
   }
 
   private LabelNormalizer.Result approve(ChangeContext ctx, ChangeUpdate update)
-      throws OrmException {
+      throws OrmException, IOException {
     PatchSet.Id psId = update.getPatchSetId();
     Map<PatchSetApproval.Key, PatchSetApproval> byKey = new HashMap<>();
     for (PatchSetApproval psa :
-        args.approvalsUtil.byPatchSet(ctx.getDb(), ctx.getControl(), psId)) {
+        args.approvalsUtil.byPatchSet(
+            ctx.getDb(),
+            ctx.getNotes(),
+            ctx.getUser(),
+            psId,
+            ctx.getRevWalk(),
+            ctx.getRepoView().getConfig())) {
       byKey.put(psa.getKey(), psa);
     }
 
@@ -348,7 +375,7 @@
     // was added. So we need to make sure votes are accurate now. This way if
     // permissions get modified in the future, historical records stay accurate.
     LabelNormalizer.Result normalized =
-        args.labelNormalizer.normalize(ctx.getControl(), byKey.values());
+        args.labelNormalizer.normalize(ctx.getNotes(), ctx.getUser(), byKey.values());
     update.putApproval(submitter.getLabel(), submitter.getValue());
     saveApprovals(normalized, ctx, update, false);
     return normalized;
@@ -477,6 +504,18 @@
 
   @Override
   public final void postUpdate(Context ctx) throws Exception {
+    if (changeAlreadyMerged) {
+      // TODO(dborowitz): This is suboptimal behavior in the presence of retries: postUpdate steps
+      // will never get run for changes that submitted successfully on any but the final attempt.
+      // This is primarily a temporary workaround for the fact that the submitter field is not
+      // populated in the changeAlreadyMerged case.
+      //
+      // If we naively execute postUpdate even if the change is already merged when updateChange
+      // being, then we are subject to a race where postUpdate steps are run twice if two submit
+      // processes run at the same time.
+      logDebug("Skipping post-update steps for change {}", getId());
+      return;
+    }
     postUpdateImpl(ctx);
 
     if (command != null) {
@@ -490,7 +529,7 @@
         try (Repository git = args.repoManager.openRepository(getProject())) {
           git.setGitwebDescription(p.getProject().getDescription());
         } catch (IOException e) {
-          log.error("cannot update description of " + p.getProject().getName(), e);
+          log.error("cannot update description of " + p.getName(), e);
         }
       }
     }
@@ -503,7 +542,7 @@
               ctx.getProject(),
               getId(),
               submitter.getAccountId(),
-              args.notifyHandling,
+              args.submitInput.notify,
               args.accountsToNotify)
           .sendAsync();
     } catch (Exception e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/TestHelperOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/TestHelperOp.java
new file mode 100644
index 0000000..8d95045
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/TestHelperOp.java
@@ -0,0 +1,58 @@
+// 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.git.strategy;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.change.Submit.TestSubmitInput;
+import com.google.gerrit.server.update.BatchUpdateOp;
+import com.google.gerrit.server.update.RepoContext;
+import com.google.gerrit.server.util.RequestId;
+import java.io.IOException;
+import java.util.Queue;
+import org.eclipse.jgit.lib.ObjectId;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+class TestHelperOp implements BatchUpdateOp {
+  private static final Logger log = LoggerFactory.getLogger(TestHelperOp.class);
+
+  private final Change.Id changeId;
+  private final TestSubmitInput input;
+  private final RequestId submissionId;
+
+  TestHelperOp(Change.Id changeId, SubmitStrategy.Arguments args) {
+    this.changeId = changeId;
+    this.input = (TestSubmitInput) args.submitInput;
+    this.submissionId = args.submissionId;
+  }
+
+  @Override
+  public void updateRepo(RepoContext ctx) throws IOException {
+    Queue<Boolean> q = input.generateLockFailures;
+    if (q != null && !q.isEmpty() && q.remove()) {
+      logDebug("Adding bogus ref update to trigger lock failure, via change {}", changeId);
+      ctx.addRefUpdate(
+          ObjectId.fromString("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"),
+          ObjectId.zeroId(),
+          "refs/test/" + getClass().getSimpleName());
+    }
+  }
+
+  private void logDebug(String msg, Object... args) {
+    if (log.isDebugEnabled()) {
+      log.debug(submissionId + msg, args);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/AccountValidator.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/AccountValidator.java
new file mode 100644
index 0000000..934b63c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/AccountValidator.java
@@ -0,0 +1,91 @@
+// 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.git.validators;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.mail.send.OutgoingEmailValidator;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class AccountValidator {
+
+  private final Provider<IdentifiedUser> self;
+  private final OutgoingEmailValidator emailValidator;
+
+  @Inject
+  public AccountValidator(Provider<IdentifiedUser> self, OutgoingEmailValidator emailValidator) {
+    this.self = self;
+    this.emailValidator = emailValidator;
+  }
+
+  public List<String> validate(
+      Account.Id accountId, RevWalk rw, @Nullable ObjectId oldId, ObjectId newId)
+      throws IOException {
+    Account oldAccount = null;
+    if (oldId != null && !ObjectId.zeroId().equals(oldId)) {
+      try {
+        oldAccount = loadAccount(accountId, rw, oldId);
+      } catch (ConfigInvalidException e) {
+        // ignore, maybe the new commit is repairing it now
+      }
+    }
+
+    Account newAccount;
+    try {
+      newAccount = loadAccount(accountId, rw, newId);
+    } catch (ConfigInvalidException e) {
+      return ImmutableList.of(
+          String.format(
+              "commit '%s' has an invalid '%s' file for account '%s': %s",
+              newId.name(), AccountConfig.ACCOUNT_CONFIG, accountId.get(), e.getMessage()));
+    }
+
+    List<String> messages = new ArrayList<>();
+    if (accountId.equals(self.get().getAccountId()) && !newAccount.isActive()) {
+      messages.add("cannot deactivate own account");
+    }
+
+    if (newAccount.getPreferredEmail() != null
+        && (oldAccount == null
+            || !newAccount.getPreferredEmail().equals(oldAccount.getPreferredEmail()))) {
+      if (!emailValidator.isValid(newAccount.getPreferredEmail())) {
+        messages.add(
+            String.format(
+                "invalid preferred email '%s' for account '%s'",
+                newAccount.getPreferredEmail(), accountId.get()));
+      }
+    }
+
+    return ImmutableList.copyOf(messages);
+  }
+
+  private Account loadAccount(Account.Id accountId, RevWalk rw, ObjectId commit)
+      throws IOException, ConfigInvalidException {
+    rw.reset();
+    AccountConfig accountConfig = new AccountConfig(null, accountId);
+    accountConfig.load(rw, commit);
+    return accountConfig.getAccount();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
index 24ff379..bffe382 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidationException.java
@@ -14,30 +14,35 @@
 
 package com.google.gerrit.server.git.validators;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.server.validators.ValidationException;
-import java.util.Collections;
 import java.util.List;
 
 public class CommitValidationException extends ValidationException {
   private static final long serialVersionUID = 1L;
-  private final List<CommitValidationMessage> messages;
+  private final ImmutableList<CommitValidationMessage> messages;
+
+  public CommitValidationException(String reason, CommitValidationMessage message) {
+    super(reason);
+    this.messages = ImmutableList.of(message);
+  }
 
   public CommitValidationException(String reason, List<CommitValidationMessage> messages) {
     super(reason);
-    this.messages = messages;
+    this.messages = ImmutableList.copyOf(messages);
   }
 
   public CommitValidationException(String reason) {
     super(reason);
-    this.messages = Collections.emptyList();
+    this.messages = ImmutableList.of();
   }
 
   public CommitValidationException(String reason, Throwable why) {
     super(reason, why);
-    this.messages = Collections.emptyList();
+    this.messages = ImmutableList.of();
   }
 
-  public List<CommitValidationMessage> getMessages() {
+  public ImmutableList<CommitValidationMessage> getMessages() {
     return messages;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
index 2722067..541cbfa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/CommitValidators.java
@@ -15,21 +15,26 @@
 package com.google.gerrit.server.git.validators;
 
 import static com.google.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN;
+import static com.google.gerrit.reviewdb.client.RefNames.REFS_CHANGES;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG;
-import static com.google.gerrit.server.git.ReceiveCommits.NEW_PATCHSET;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.CharMatcher;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
 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.Branch;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.WatchConfig;
+import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GerritServerConfig;
@@ -37,8 +42,11 @@
 import com.google.gerrit.server.git.BanCommit;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.ValidationError;
-import com.google.gerrit.server.project.ProjectControl;
-import com.google.gerrit.server.project.RefControl;
+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.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.ssh.SshInfo;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
@@ -67,19 +75,8 @@
 public class CommitValidators {
   private static final Logger log = LoggerFactory.getLogger(CommitValidators.class);
 
-  public enum Policy {
-    /** Use {@link Factory#forGerritCommits}. */
-    GERRIT,
-
-    /** Use {@link Factory#forReceiveCommits}. */
-    RECEIVE_COMMITS,
-
-    /** Use {@link Factory#forMergedCommits}. */
-    MERGED,
-
-    /** Do not validate commits. */
-    NONE
-  }
+  public static final Pattern NEW_PATCHSET_PATTERN =
+      Pattern.compile("^" + REFS_CHANGES + "(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/[1-9][0-9]*)?$");
 
   @Singleton
   public static class Factory {
@@ -87,7 +84,10 @@
     private final String canonicalWebUrl;
     private final DynamicSet<CommitValidationListener> pluginValidators;
     private final AllUsersName allUsers;
+    private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
+    private final AccountValidator accountValidator;
     private final String installCommitMsgHookCommand;
+    private final ProjectCache projectCache;
 
     @Inject
     Factory(
@@ -95,67 +95,73 @@
         @CanonicalWebUrl @Nullable String canonicalWebUrl,
         @GerritServerConfig Config cfg,
         DynamicSet<CommitValidationListener> pluginValidators,
-        AllUsersName allUsers) {
+        AllUsersName allUsers,
+        ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
+        AccountValidator accountValidator,
+        ProjectCache projectCache) {
       this.gerritIdent = gerritIdent;
       this.canonicalWebUrl = canonicalWebUrl;
       this.pluginValidators = pluginValidators;
       this.allUsers = allUsers;
+      this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
+      this.accountValidator = accountValidator;
       this.installCommitMsgHookCommand =
           cfg != null ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null;
+      this.projectCache = projectCache;
     }
 
-    public CommitValidators create(
-        Policy policy, RefControl refControl, SshInfo sshInfo, Repository repo) throws IOException {
-      switch (policy) {
-        case RECEIVE_COMMITS:
-          return forReceiveCommits(refControl, sshInfo, repo);
-        case GERRIT:
-          return forGerritCommits(refControl, sshInfo, repo);
-        case MERGED:
-          return forMergedCommits(refControl);
-        case NONE:
-          return none();
-        default:
-          throw new IllegalArgumentException("unspported policy: " + policy);
-      }
-    }
-
-    private CommitValidators forReceiveCommits(
-        RefControl refControl, SshInfo sshInfo, Repository repo) throws IOException {
-      try (RevWalk rw = new RevWalk(repo)) {
-        NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
-        return new CommitValidators(
-            ImmutableList.of(
-                new UploadMergesPermissionValidator(refControl),
-                new AmendedGerritMergeCommitValidationListener(refControl, gerritIdent),
-                new AuthorUploaderValidator(refControl, canonicalWebUrl),
-                new CommitterUploaderValidator(refControl, canonicalWebUrl),
-                new SignedOffByValidator(refControl),
-                new ChangeIdValidator(
-                    refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
-                new ConfigValidator(refControl, repo, allUsers),
-                new BannedCommitsValidator(rejectCommits),
-                new PluginCommitValidationListener(pluginValidators),
-                new BlockExternalIdUpdateListener(allUsers)));
-      }
-    }
-
-    private CommitValidators forGerritCommits(
-        RefControl refControl, SshInfo sshInfo, Repository repo) {
+    public CommitValidators forReceiveCommits(
+        PermissionBackend.ForRef perm,
+        Branch.NameKey branch,
+        IdentifiedUser user,
+        SshInfo sshInfo,
+        Repository repo,
+        RevWalk rw)
+        throws IOException {
+      NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw);
+      ProjectState projectState = projectCache.checkedGet(branch.getParentKey());
       return new CommitValidators(
           ImmutableList.of(
-              new UploadMergesPermissionValidator(refControl),
-              new AmendedGerritMergeCommitValidationListener(refControl, gerritIdent),
-              new AuthorUploaderValidator(refControl, canonicalWebUrl),
-              new SignedOffByValidator(refControl),
+              new UploadMergesPermissionValidator(perm),
+              new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
+              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
+              new CommitterUploaderValidator(user, perm, canonicalWebUrl),
+              new SignedOffByValidator(user, perm, projectState),
               new ChangeIdValidator(
-                  refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
-              new ConfigValidator(refControl, repo, allUsers),
+                  projectState, user, canonicalWebUrl, installCommitMsgHookCommand, sshInfo),
+              new ConfigValidator(branch, user, rw, allUsers),
+              new BannedCommitsValidator(rejectCommits),
               new PluginCommitValidationListener(pluginValidators),
-              new BlockExternalIdUpdateListener(allUsers)));
+              new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
+              new AccountCommitValidator(allUsers, accountValidator)));
     }
 
-    private CommitValidators forMergedCommits(RefControl refControl) {
+    public CommitValidators forGerritCommits(
+        PermissionBackend.ForRef perm,
+        Branch.NameKey branch,
+        IdentifiedUser user,
+        SshInfo sshInfo,
+        RevWalk rw)
+        throws IOException {
+      return new CommitValidators(
+          ImmutableList.of(
+              new UploadMergesPermissionValidator(perm),
+              new AmendedGerritMergeCommitValidationListener(perm, gerritIdent),
+              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
+              new SignedOffByValidator(user, perm, projectCache.checkedGet(branch.getParentKey())),
+              new ChangeIdValidator(
+                  projectCache.checkedGet(branch.getParentKey()),
+                  user,
+                  canonicalWebUrl,
+                  installCommitMsgHookCommand,
+                  sshInfo),
+              new ConfigValidator(branch, user, rw, allUsers),
+              new PluginCommitValidationListener(pluginValidators),
+              new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker),
+              new AccountCommitValidator(allUsers, accountValidator)));
+    }
+
+    public CommitValidators forMergedCommits(PermissionBackend.ForRef perm, IdentifiedUser user) {
       // Generally only include validators that are based on permissions of the
       // user creating a change for a merged commit; generally exclude
       // validators that would require amending the change in order to correct.
@@ -171,13 +177,9 @@
       //    formats, so we play it safe and exclude them.
       return new CommitValidators(
           ImmutableList.of(
-              new UploadMergesPermissionValidator(refControl),
-              new AuthorUploaderValidator(refControl, canonicalWebUrl),
-              new CommitterUploaderValidator(refControl, canonicalWebUrl)));
-    }
-
-    private CommitValidators none() {
-      return new CommitValidators(ImmutableList.<CommitValidationListener>of());
+              new UploadMergesPermissionValidator(perm),
+              new AuthorUploaderValidator(user, perm, canonicalWebUrl),
+              new CommitterUploaderValidator(user, perm, canonicalWebUrl)));
     }
   }
 
@@ -219,22 +221,23 @@
             + " line format in commit message footer";
     private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
 
-    private final ProjectControl projectControl;
+    private final ProjectState projectState;
     private final String canonicalWebUrl;
     private final String installCommitMsgHookCommand;
     private final SshInfo sshInfo;
     private final IdentifiedUser user;
 
     public ChangeIdValidator(
-        RefControl refControl,
+        ProjectState projectState,
+        IdentifiedUser user,
         String canonicalWebUrl,
         String installCommitMsgHookCommand,
         SshInfo sshInfo) {
-      this.projectControl = refControl.getProjectControl();
+      this.projectState = projectState;
       this.canonicalWebUrl = canonicalWebUrl;
       this.installCommitMsgHookCommand = installCommitMsgHookCommand;
       this.sshInfo = sshInfo;
-      this.user = projectControl.getUser().asIdentifiedUser();
+      this.user = user;
     }
 
     @Override
@@ -255,7 +258,7 @@
           String errMsg = String.format(MISSING_SUBJECT_MSG, sha1);
           throw new CommitValidationException(errMsg);
         }
-        if (projectControl.getProjectState().isRequireChangeID()) {
+        if (projectState.isRequireChangeID()) {
           String errMsg = String.format(MISSING_CHANGE_ID_MSG, sha1);
           messages.add(getMissingChangeIdErrorMsg(errMsg, commit));
           throw new CommitValidationException(errMsg, messages);
@@ -278,11 +281,10 @@
 
     private static boolean shouldValidateChangeId(CommitReceivedEvent event) {
       return MagicBranch.isMagicBranch(event.command.getRefName())
-          || NEW_PATCHSET.matcher(event.command.getRefName()).matches();
+          || NEW_PATCHSET_PATTERN.matcher(event.command.getRefName()).matches();
     }
 
-    private CommitValidationMessage getMissingChangeIdErrorMsg(
-        final String errMsg, final RevCommit c) {
+    private CommitValidationMessage getMissingChangeIdErrorMsg(String errMsg, RevCommit c) {
       StringBuilder sb = new StringBuilder();
       sb.append("ERROR: ").append(errMsg);
 
@@ -352,27 +354,28 @@
 
   /** If this is the special project configuration branch, validate the config. */
   public static class ConfigValidator implements CommitValidationListener {
-    private final RefControl refControl;
-    private final Repository repo;
+    private final Branch.NameKey branch;
+    private final IdentifiedUser user;
+    private final RevWalk rw;
     private final AllUsersName allUsers;
 
-    public ConfigValidator(RefControl refControl, Repository repo, AllUsersName allUsers) {
-      this.refControl = refControl;
-      this.repo = repo;
+    public ConfigValidator(
+        Branch.NameKey branch, IdentifiedUser user, RevWalk rw, AllUsersName allUsers) {
+      this.branch = branch;
+      this.user = user;
+      this.rw = rw;
       this.allUsers = allUsers;
     }
 
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
-      IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser();
-
-      if (REFS_CONFIG.equals(refControl.getRefName())) {
+      if (REFS_CONFIG.equals(branch.get())) {
         List<CommitValidationMessage> messages = new ArrayList<>();
 
         try {
           ProjectConfig cfg = new ProjectConfig(receiveEvent.project.getNameKey());
-          cfg.load(repo, receiveEvent.command.getNewId());
+          cfg.load(rw, receiveEvent.command.getNewId());
           if (!cfg.getValidationErrors().isEmpty()) {
             addError("Invalid project configuration:", messages);
             for (ValidationError err : cfg.getValidationErrors()) {
@@ -383,24 +386,23 @@
         } catch (ConfigInvalidException | IOException e) {
           log.error(
               "User "
-                  + currentUser.getUserName()
+                  + user.getUserName()
                   + " tried to push an invalid project configuration "
                   + receiveEvent.command.getNewId().name()
                   + " for project "
-                  + receiveEvent.project.getName(),
+                  + receiveEvent.project,
               e);
           throw new CommitValidationException("invalid project configuration", messages);
         }
       }
 
-      if (allUsers.equals(refControl.getProjectControl().getProject().getNameKey())
-          && RefNames.isRefsUsers(refControl.getRefName())) {
+      if (allUsers.equals(branch.getParentKey()) && RefNames.isRefsUsers(branch.get())) {
         List<CommitValidationMessage> messages = new ArrayList<>();
-        Account.Id accountId = Account.Id.fromRef(refControl.getRefName());
+        Account.Id accountId = Account.Id.fromRef(branch.get());
         if (accountId != null) {
           try {
             WatchConfig wc = new WatchConfig(accountId);
-            wc.load(repo, receiveEvent.command.getNewId());
+            wc.load(rw, receiveEvent.command.getNewId());
             if (!wc.getValidationErrors().isEmpty()) {
               addError("Invalid project configuration:", messages);
               for (ValidationError err : wc.getValidationErrors()) {
@@ -411,7 +413,7 @@
           } catch (IOException | ConfigInvalidException e) {
             log.error(
                 "User "
-                    + currentUser.getUserName()
+                    + user.getUserName()
                     + " tried to push an invalid watch configuration "
                     + receiveEvent.command.getNewId().name()
                     + " for account "
@@ -426,21 +428,29 @@
     }
   }
 
-  /** Require permission to upload merges. */
+  /** Require permission to upload merge commits. */
   public static class UploadMergesPermissionValidator implements CommitValidationListener {
-    private final RefControl refControl;
+    private final PermissionBackend.ForRef perm;
 
-    public UploadMergesPermissionValidator(RefControl refControl) {
-      this.refControl = refControl;
+    public UploadMergesPermissionValidator(PermissionBackend.ForRef perm) {
+      this.perm = perm;
     }
 
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
-      if (receiveEvent.commit.getParentCount() > 1 && !refControl.canUploadMerges()) {
-        throw new CommitValidationException("you are not allowed to upload merges");
+      if (receiveEvent.commit.getParentCount() <= 1) {
+        return Collections.emptyList();
       }
-      return Collections.emptyList();
+      try {
+        perm.check(RefPermission.MERGE);
+        return Collections.emptyList();
+      } catch (AuthException e) {
+        throw new CommitValidationException("you are not allowed to upload merges");
+      } catch (PermissionBackendException e) {
+        log.error("cannot check MERGE", e);
+        throw new CommitValidationException("internal auth error");
+      }
     }
   }
 
@@ -471,37 +481,50 @@
   }
 
   public static class SignedOffByValidator implements CommitValidationListener {
-    private final RefControl refControl;
+    private final IdentifiedUser user;
+    private final PermissionBackend.ForRef perm;
+    private final ProjectState state;
 
-    public SignedOffByValidator(RefControl refControl) {
-      this.refControl = refControl;
+    public SignedOffByValidator(
+        IdentifiedUser user, PermissionBackend.ForRef perm, ProjectState state) {
+      this.user = user;
+      this.perm = perm;
+      this.state = state;
     }
 
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
-      IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser();
-      final PersonIdent committer = receiveEvent.commit.getCommitterIdent();
-      final PersonIdent author = receiveEvent.commit.getAuthorIdent();
-      final ProjectControl projectControl = refControl.getProjectControl();
+      if (!state.isUseSignedOffBy()) {
+        return Collections.emptyList();
+      }
 
-      if (projectControl.getProjectState().isUseSignedOffBy()) {
-        boolean sboAuthor = false;
-        boolean sboCommitter = false;
-        boolean sboMe = false;
-        for (final FooterLine footer : receiveEvent.commit.getFooterLines()) {
-          if (footer.matches(FooterKey.SIGNED_OFF_BY)) {
-            final String e = footer.getEmailAddress();
-            if (e != null) {
-              sboAuthor |= author.getEmailAddress().equals(e);
-              sboCommitter |= committer.getEmailAddress().equals(e);
-              sboMe |= currentUser.hasEmailAddress(e);
-            }
+      RevCommit commit = receiveEvent.commit;
+      PersonIdent committer = commit.getCommitterIdent();
+      PersonIdent author = commit.getAuthorIdent();
+
+      boolean sboAuthor = false;
+      boolean sboCommitter = false;
+      boolean sboMe = false;
+      for (FooterLine footer : commit.getFooterLines()) {
+        if (footer.matches(FooterKey.SIGNED_OFF_BY)) {
+          String e = footer.getEmailAddress();
+          if (e != null) {
+            sboAuthor |= author.getEmailAddress().equals(e);
+            sboCommitter |= committer.getEmailAddress().equals(e);
+            sboMe |= user.hasEmailAddress(e);
           }
         }
-        if (!sboAuthor && !sboCommitter && !sboMe && !refControl.canForgeCommitter()) {
+      }
+      if (!sboAuthor && !sboCommitter && !sboMe) {
+        try {
+          perm.check(RefPermission.FORGE_COMMITTER);
+        } catch (AuthException denied) {
           throw new CommitValidationException(
               "not Signed-off-by author/committer/uploader in commit message footer");
+        } catch (PermissionBackendException e) {
+          log.error("cannot check FORGE_COMMITTER", e);
+          throw new CommitValidationException("internal auth error");
         }
       }
       return Collections.emptyList();
@@ -510,56 +533,69 @@
 
   /** Require that author matches the uploader. */
   public static class AuthorUploaderValidator implements CommitValidationListener {
-    private final RefControl refControl;
+    private final IdentifiedUser user;
+    private final PermissionBackend.ForRef perm;
     private final String canonicalWebUrl;
 
-    public AuthorUploaderValidator(RefControl refControl, String canonicalWebUrl) {
-      this.refControl = refControl;
+    public AuthorUploaderValidator(
+        IdentifiedUser user, PermissionBackend.ForRef perm, String canonicalWebUrl) {
+      this.user = user;
+      this.perm = perm;
       this.canonicalWebUrl = canonicalWebUrl;
     }
 
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
-      IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser();
-      final PersonIdent author = receiveEvent.commit.getAuthorIdent();
-
-      if (!currentUser.hasEmailAddress(author.getEmailAddress()) && !refControl.canForgeAuthor()) {
-        List<CommitValidationMessage> messages = new ArrayList<>();
-
-        messages.add(
-            getInvalidEmailError(
-                receiveEvent.commit, "author", author, currentUser, canonicalWebUrl));
-        throw new CommitValidationException("invalid author", messages);
+      PersonIdent author = receiveEvent.commit.getAuthorIdent();
+      if (user.hasEmailAddress(author.getEmailAddress())) {
+        return Collections.emptyList();
       }
-      return Collections.emptyList();
+      try {
+        perm.check(RefPermission.FORGE_AUTHOR);
+        return Collections.emptyList();
+      } catch (AuthException e) {
+        throw new CommitValidationException(
+            "invalid author",
+            invalidEmail(receiveEvent.commit, "author", author, user, canonicalWebUrl));
+      } catch (PermissionBackendException e) {
+        log.error("cannot check FORGE_AUTHOR", e);
+        throw new CommitValidationException("internal auth error");
+      }
     }
   }
 
   /** Require that committer matches the uploader. */
   public static class CommitterUploaderValidator implements CommitValidationListener {
-    private final RefControl refControl;
+    private final IdentifiedUser user;
+    private final PermissionBackend.ForRef perm;
     private final String canonicalWebUrl;
 
-    public CommitterUploaderValidator(RefControl refControl, String canonicalWebUrl) {
-      this.refControl = refControl;
+    public CommitterUploaderValidator(
+        IdentifiedUser user, PermissionBackend.ForRef perm, String canonicalWebUrl) {
+      this.user = user;
+      this.perm = perm;
       this.canonicalWebUrl = canonicalWebUrl;
     }
 
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
-      IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser();
-      final PersonIdent committer = receiveEvent.commit.getCommitterIdent();
-      if (!currentUser.hasEmailAddress(committer.getEmailAddress())
-          && !refControl.canForgeCommitter()) {
-        List<CommitValidationMessage> messages = new ArrayList<>();
-        messages.add(
-            getInvalidEmailError(
-                receiveEvent.commit, "committer", committer, currentUser, canonicalWebUrl));
-        throw new CommitValidationException("invalid committer", messages);
+      PersonIdent committer = receiveEvent.commit.getCommitterIdent();
+      if (user.hasEmailAddress(committer.getEmailAddress())) {
+        return Collections.emptyList();
       }
-      return Collections.emptyList();
+      try {
+        perm.check(RefPermission.FORGE_COMMITTER);
+        return Collections.emptyList();
+      } catch (AuthException e) {
+        throw new CommitValidationException(
+            "invalid committer",
+            invalidEmail(receiveEvent.commit, "committer", committer, user, canonicalWebUrl));
+      } catch (PermissionBackendException e) {
+        log.error("cannot check FORGE_COMMITTER", e);
+        throw new CommitValidationException("internal auth error");
+      }
     }
   }
 
@@ -569,25 +605,36 @@
    */
   public static class AmendedGerritMergeCommitValidationListener
       implements CommitValidationListener {
+    private final PermissionBackend.ForRef perm;
     private final PersonIdent gerritIdent;
-    private final RefControl refControl;
 
     public AmendedGerritMergeCommitValidationListener(
-        final RefControl refControl, final PersonIdent gerritIdent) {
-      this.refControl = refControl;
+        PermissionBackend.ForRef perm, PersonIdent gerritIdent) {
+      this.perm = perm;
       this.gerritIdent = gerritIdent;
     }
 
     @Override
     public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
         throws CommitValidationException {
-      final PersonIdent author = receiveEvent.commit.getAuthorIdent();
-
+      PersonIdent author = receiveEvent.commit.getAuthorIdent();
       if (receiveEvent.commit.getParentCount() > 1
           && author.getName().equals(gerritIdent.getName())
-          && author.getEmailAddress().equals(gerritIdent.getEmailAddress())
-          && !refControl.canForgeGerritServerIdentity()) {
-        throw new CommitValidationException("do not amend merges not made by you");
+          && author.getEmailAddress().equals(gerritIdent.getEmailAddress())) {
+        try {
+          // Stop authors from amending the merge commits that Gerrit itself creates.
+          perm.check(RefPermission.FORGE_SERVER);
+        } catch (AuthException denied) {
+          throw new CommitValidationException(
+              String.format(
+                  "pushing merge commit %s by %s requires '%s' permission",
+                  receiveEvent.commit.getId(),
+                  gerritIdent.getEmailAddress(),
+                  RefPermission.FORGE_SERVER.name()));
+        } catch (PermissionBackendException e) {
+          log.error("cannot check FORGE_SERVER", e);
+          throw new CommitValidationException("internal auth error");
+        }
       }
       return Collections.emptyList();
     }
@@ -618,11 +665,14 @@
     }
   }
 
-  /** Blocks any update to refs/meta/external-ids */
-  public static class BlockExternalIdUpdateListener implements CommitValidationListener {
+  /** Validates updates to refs/meta/external-ids. */
+  public static class ExternalIdUpdateListener implements CommitValidationListener {
     private final AllUsersName allUsers;
+    private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
 
-    public BlockExternalIdUpdateListener(AllUsersName allUsers) {
+    public ExternalIdUpdateListener(
+        AllUsersName allUsers, ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
+      this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
       this.allUsers = allUsers;
     }
 
@@ -631,13 +681,84 @@
         throws CommitValidationException {
       if (allUsers.equals(receiveEvent.project.getNameKey())
           && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) {
-        throw new CommitValidationException("not allowed to update " + RefNames.REFS_EXTERNAL_IDS);
+        try {
+          List<ConsistencyProblemInfo> problems =
+              externalIdsConsistencyChecker.check(receiveEvent.commit);
+          List<CommitValidationMessage> msgs =
+              problems
+                  .stream()
+                  .map(
+                      p ->
+                          new CommitValidationMessage(
+                              p.message, p.status == ConsistencyProblemInfo.Status.ERROR))
+                  .collect(toList());
+          if (msgs.stream().anyMatch(m -> m.isError())) {
+            throw new CommitValidationException("invalid external IDs", msgs);
+          }
+          return msgs;
+        } catch (IOException e) {
+          String m = "error validating external IDs";
+          log.warn(m, e);
+          throw new CommitValidationException(m, e);
+        }
       }
       return Collections.emptyList();
     }
   }
 
-  private static CommitValidationMessage getInvalidEmailError(
+  /** Rejects updates to 'account.config' in user branches. */
+  public static class AccountCommitValidator implements CommitValidationListener {
+    private final AllUsersName allUsers;
+    private final AccountValidator accountValidator;
+
+    public AccountCommitValidator(AllUsersName allUsers, AccountValidator accountValidator) {
+      this.allUsers = allUsers;
+      this.accountValidator = accountValidator;
+    }
+
+    @Override
+    public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
+        throws CommitValidationException {
+      if (!allUsers.equals(receiveEvent.project.getNameKey())) {
+        return Collections.emptyList();
+      }
+
+      if (receiveEvent.command.getRefName().startsWith(MagicBranch.NEW_CHANGE)) {
+        // no validation on push for review, will be checked on submit by
+        // MergeValidators.AccountMergeValidator
+        return Collections.emptyList();
+      }
+
+      Account.Id accountId = Account.Id.fromRef(receiveEvent.refName);
+      if (accountId == null) {
+        return Collections.emptyList();
+      }
+
+      try {
+        List<String> errorMessages =
+            accountValidator.validate(
+                accountId,
+                receiveEvent.revWalk,
+                receiveEvent.command.getOldId(),
+                receiveEvent.commit);
+        if (!errorMessages.isEmpty()) {
+          throw new CommitValidationException(
+              "invalid account configuration",
+              errorMessages
+                  .stream()
+                  .map(m -> new CommitValidationMessage(m, true))
+                  .collect(toList()));
+        }
+      } catch (IOException e) {
+        String m = String.format("Validating update for account %s failed", accountId.get());
+        log.error(m, e);
+        throw new CommitValidationException(m, e);
+      }
+      return Collections.emptyList();
+    }
+  }
+
+  private static CommitValidationMessage invalidEmail(
       RevCommit c,
       String type,
       PersonIdent who,
@@ -651,7 +772,9 @@
         .append(" email address ")
         .append(who.getEmailAddress())
         .append("\n");
-    sb.append("ERROR:  does not match your user account.\n");
+    sb.append("ERROR:  does not match your user account and you have no 'forge ")
+        .append(type)
+        .append("' permission.\n");
     sb.append("ERROR:\n");
     if (currentUser.getEmailAddresses().isEmpty()) {
       sb.append("ERROR:  You have not registered any email addresses.\n");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
index 150965c..8ccf081 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/MergeValidators.java
@@ -14,32 +14,50 @@
 
 package com.google.gerrit.server.git.validators;
 
+import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicMap.Entry;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
 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.IdentifiedUser;
+import com.google.gerrit.server.account.AccountConfig;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.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.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 public class MergeValidators {
+  private static final Logger log = LoggerFactory.getLogger(MergeValidators.class);
+
   private final DynamicSet<MergeValidationListener> mergeValidationListeners;
   private final ProjectConfigValidator.Factory projectConfigValidatorFactory;
+  private final AccountMergeValidator.Factory accountValidatorFactory;
 
   public interface Factory {
     MergeValidators create();
@@ -48,9 +66,11 @@
   @Inject
   MergeValidators(
       DynamicSet<MergeValidationListener> mergeValidationListeners,
-      ProjectConfigValidator.Factory projectConfigValidatorFactory) {
+      ProjectConfigValidator.Factory projectConfigValidatorFactory,
+      AccountMergeValidator.Factory accountValidatorFactory) {
     this.mergeValidationListeners = mergeValidationListeners;
     this.projectConfigValidatorFactory = projectConfigValidatorFactory;
+    this.accountValidatorFactory = accountValidatorFactory;
   }
 
   public void validatePreMerge(
@@ -64,7 +84,8 @@
     List<MergeValidationListener> validators =
         ImmutableList.of(
             new PluginMergeValidationListener(mergeValidationListeners),
-            projectConfigValidatorFactory.create());
+            projectConfigValidatorFactory.create(),
+            accountValidatorFactory.create());
 
     for (MergeValidationListener validator : validators) {
       validator.onPreMerge(repo, commit, destProject, destBranch, patchSetId, caller);
@@ -93,6 +114,7 @@
 
     private final AllProjectsName allProjectsName;
     private final ProjectCache projectCache;
+    private final PermissionBackend permissionBackend;
     private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
 
     public interface Factory {
@@ -103,9 +125,11 @@
     public ProjectConfigValidator(
         AllProjectsName allProjectsName,
         ProjectCache projectCache,
+        PermissionBackend permissionBackend,
         DynamicMap<ProjectConfigEntry> pluginConfigEntries) {
       this.allProjectsName = allProjectsName;
       this.projectCache = projectCache;
+      this.permissionBackend = permissionBackend;
       this.pluginConfigEntries = pluginConfigEntries;
     }
 
@@ -121,7 +145,7 @@
       if (RefNames.REFS_CONFIG.equals(destBranch.get())) {
         final Project.NameKey newParent;
         try {
-          ProjectConfig cfg = new ProjectConfig(destProject.getProject().getNameKey());
+          ProjectConfig cfg = new ProjectConfig(destProject.getNameKey());
           cfg.load(repo, commit);
           newParent = cfg.getProject().getParent(allProjectsName);
           final Project.NameKey oldParent = destProject.getProject().getParent(allProjectsName);
@@ -132,8 +156,13 @@
             }
           } else {
             if (!oldParent.equals(newParent)) {
-              if (!caller.getCapabilities().canAdministrateServer()) {
+              try {
+                permissionBackend.user(caller).check(GlobalPermission.ADMINISTRATE_SERVER);
+              } catch (AuthException e) {
                 throw new MergeValidationException(SET_BY_ADMIN);
+              } catch (PermissionBackendException e) {
+                log.warn("Cannot check ADMINISTRATE_SERVER", e);
+                throw new MergeValidationException("validation unavailable");
               }
 
               if (projectCache.get(newParent) == null) {
@@ -194,4 +223,65 @@
       }
     }
   }
+
+  public static class AccountMergeValidator implements MergeValidationListener {
+    public interface Factory {
+      AccountMergeValidator create();
+    }
+
+    private final Provider<ReviewDb> dbProvider;
+    private final AllUsersName allUsersName;
+    private final ChangeData.Factory changeDataFactory;
+    private final AccountValidator accountValidator;
+
+    @Inject
+    public AccountMergeValidator(
+        Provider<ReviewDb> dbProvider,
+        AllUsersName allUsersName,
+        ChangeData.Factory changeDataFactory,
+        AccountValidator accountValidator) {
+      this.dbProvider = dbProvider;
+      this.allUsersName = allUsersName;
+      this.changeDataFactory = changeDataFactory;
+      this.accountValidator = accountValidator;
+    }
+
+    @Override
+    public void onPreMerge(
+        Repository repo,
+        CodeReviewCommit commit,
+        ProjectState destProject,
+        Branch.NameKey destBranch,
+        PatchSet.Id patchSetId,
+        IdentifiedUser caller)
+        throws MergeValidationException {
+      Account.Id accountId = Account.Id.fromRef(destBranch.get());
+      if (!allUsersName.equals(destProject.getNameKey()) || accountId == null) {
+        return;
+      }
+
+      ChangeData cd =
+          changeDataFactory.create(
+              dbProvider.get(), destProject.getProject().getNameKey(), patchSetId.getParentKey());
+      try {
+        if (!cd.currentFilePaths().contains(AccountConfig.ACCOUNT_CONFIG)) {
+          return;
+        }
+      } catch (IOException | OrmException e) {
+        log.error("Cannot validate account update", e);
+        throw new MergeValidationException("account validation unavailable");
+      }
+
+      try (RevWalk rw = new RevWalk(repo)) {
+        List<String> errorMessages = accountValidator.validate(accountId, rw, null, commit);
+        if (!errorMessages.isEmpty()) {
+          throw new MergeValidationException(
+              "invalid account configuration: " + Joiner.on("; ").join(errorMessages));
+        }
+      } catch (IOException e) {
+        log.error("Cannot validate account update", e);
+        throw new MergeValidationException("account validation unavailable");
+      }
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
index da3c123..a626998 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidationListener.java
@@ -11,15 +11,20 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.server.git.validators;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.extensions.annotations.ExtensionPoint;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.Project.NameKey;
+import com.google.gerrit.server.git.RefCache;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gerrit.server.validators.ValidationException;
-import java.util.Map;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Repository;
+import java.io.IOException;
+import java.util.Optional;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -37,41 +42,58 @@
 public interface OnSubmitValidationListener {
   class Arguments {
     private Project.NameKey project;
-    private Repository repository;
-    private ObjectReader objectReader;
-    private Map<String, ReceiveCommand> commands;
+    private RevWalk rw;
+    private ImmutableMap<String, ReceiveCommand> commands;
+    private RefCache refs;
 
-    public Arguments(
-        NameKey project,
-        Repository repository,
-        ObjectReader objectReader,
-        Map<String, ReceiveCommand> commands) {
-      this.project = project;
-      this.repository = repository;
-      this.objectReader = objectReader;
-      this.commands = commands;
+    /**
+     * @param project project.
+     * @param rw revwalk that can read unflushed objects from {@code refs}.
+     * @param commands commands to be executed.
+     */
+    Arguments(Project.NameKey project, RevWalk rw, ChainedReceiveCommands commands) {
+      this.project = checkNotNull(project);
+      this.rw = checkNotNull(rw);
+      this.refs = checkNotNull(commands);
+      this.commands = ImmutableMap.copyOf(commands.getCommands());
     }
 
+    /** Get the project name for this operation. */
     public Project.NameKey getProject() {
       return project;
     }
 
-    /** @return a read only repository */
-    public Repository getRepository() {
-      return repository;
-    }
-
-    public RevWalk newRevWalk() {
-      return new RevWalk(objectReader);
+    /**
+     * Get a revwalk for this operation.
+     *
+     * <p>This instance is able to read all objects mentioned in {@link #getCommands()} and {@link
+     * #getRef(String)}.
+     *
+     * @return open revwalk.
+     */
+    public RevWalk getRevWalk() {
+      return rw;
     }
 
     /**
-     * @return a map from ref to op on it covering all ref ops to be performed on this repository as
-     *     part of ongoing submit operation.
+     * @return a map from ref to commands covering all ref operations to be performed on this
+     *     repository as part of the ongoing submit operation.
      */
-    public Map<String, ReceiveCommand> getCommands() {
+    public ImmutableMap<String, ReceiveCommand> getCommands() {
       return commands;
     }
+
+    /**
+     * Get a ref from the repository.
+     *
+     * @param name ref name; can be any ref, not just the ones mentioned in {@link #getCommands()}.
+     * @return latest value of a ref in the repository, as if all commands from {@link
+     *     #getCommands()} had already been applied.
+     * @throws IOException if an error occurred reading the ref.
+     */
+    public Optional<ObjectId> getRef(String name) throws IOException {
+      return refs.get(name);
+    }
   }
 
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
index 55935d1..460889c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/OnSubmitValidators.java
@@ -11,18 +11,18 @@
 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 // See the License for the specific language governing permissions and
 // limitations under the License.
+
 package com.google.gerrit.server.git.validators;
 
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.IntegrationException;
 import com.google.gerrit.server.git.validators.OnSubmitValidationListener.Arguments;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
-import java.util.Map;
 import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.revwalk.RevWalk;
 
 public class OnSubmitValidators {
   public interface Factory {
@@ -37,14 +37,12 @@
   }
 
   public void validate(
-      Project.NameKey project,
-      Repository repo,
-      ObjectReader objectReader,
-      Map<String, ReceiveCommand> commands)
+      Project.NameKey project, ObjectReader objectReader, ChainedReceiveCommands commands)
       throws IntegrationException {
-    try {
-      for (OnSubmitValidationListener listener : this.listeners) {
-        listener.preBranchUpdate(new Arguments(project, repo, objectReader, commands));
+    try (RevWalk rw = new RevWalk(objectReader)) {
+      Arguments args = new Arguments(project, rw, commands);
+      for (OnSubmitValidationListener listener : listeners) {
+        listener.preBranchUpdate(args);
       }
     } catch (ValidationException e) {
       throw new IntegrationException(e.getMessage());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
index 80792c3..8047a99a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/validators/RefOperationValidators.java
@@ -14,11 +14,19 @@
 package com.google.gerrit.server.git.validators;
 
 import com.google.common.base.Predicate;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 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.client.RefNames;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.events.RefReceivedEvent;
+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.validators.ValidationException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -42,15 +50,21 @@
         update.getExpectedOldObjectId(), update.getNewObjectId(), update.getName(), type);
   }
 
-  private final RefReceivedEvent event;
+  private final PermissionBackend.WithUser perm;
+  private final AllUsersName allUsersName;
   private final DynamicSet<RefOperationValidationListener> refOperationValidationListeners;
+  private final RefReceivedEvent event;
 
   @Inject
   RefOperationValidators(
+      PermissionBackend permissionBackend,
+      AllUsersName allUsersName,
       DynamicSet<RefOperationValidationListener> refOperationValidationListeners,
       @Assisted Project project,
       @Assisted IdentifiedUser user,
       @Assisted ReceiveCommand cmd) {
+    this.perm = permissionBackend.user(user);
+    this.allUsersName = allUsersName;
     this.refOperationValidationListeners = refOperationValidationListeners;
     event = new RefReceivedEvent();
     event.command = cmd;
@@ -59,11 +73,13 @@
   }
 
   public List<ValidationMessage> validateForRefOperation() throws RefOperationValidationException {
-
     List<ValidationMessage> messages = new ArrayList<>();
     boolean withException = false;
+    List<RefOperationValidationListener> listeners = new ArrayList<>();
+    listeners.add(new DisallowCreationAndDeletionOfUserBranches(perm, allUsersName));
+    refOperationValidationListeners.forEach(l -> listeners.add(l));
     try {
-      for (RefOperationValidationListener listener : refOperationValidationListeners) {
+      for (RefOperationValidationListener listener : listeners) {
         messages.addAll(listener.onRefOperation(event));
       }
     } catch (ValidationException e) {
@@ -95,4 +111,44 @@
       return input.isError();
     }
   }
+
+  private static class DisallowCreationAndDeletionOfUserBranches
+      implements RefOperationValidationListener {
+    private final PermissionBackend.WithUser perm;
+    private final AllUsersName allUsersName;
+
+    DisallowCreationAndDeletionOfUserBranches(
+        PermissionBackend.WithUser perm, AllUsersName allUsersName) {
+      this.perm = perm;
+      this.allUsersName = allUsersName;
+    }
+
+    @Override
+    public List<ValidationMessage> onRefOperation(RefReceivedEvent refEvent)
+        throws ValidationException {
+      if (refEvent.project.getNameKey().equals(allUsersName)
+          && (refEvent.command.getRefName().startsWith(RefNames.REFS_USERS)
+              && !refEvent.command.getRefName().equals(RefNames.REFS_USERS_DEFAULT))) {
+        if (refEvent.command.getType().equals(ReceiveCommand.Type.CREATE)) {
+          try {
+            perm.check(GlobalPermission.ACCESS_DATABASE);
+          } catch (AuthException | PermissionBackendException e) {
+            throw new ValidationException("Not allowed to create user branch.");
+          }
+          if (Account.Id.fromRef(refEvent.command.getRefName()) == null) {
+            throw new ValidationException(
+                String.format(
+                    "Not allowed to create non-user branch under %s.", RefNames.REFS_USERS));
+          }
+        } else if (refEvent.command.getType().equals(ReceiveCommand.Type.DELETE)) {
+          try {
+            perm.check(GlobalPermission.ACCESS_DATABASE);
+          } catch (AuthException | PermissionBackendException e) {
+            throw new ValidationException("Not allowed to delete user branch.");
+          }
+        }
+      }
+      return ImmutableList.of();
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java
deleted file mode 100644
index 040550c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddIncludedGroups.java
+++ /dev/null
@@ -1,180 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Lists;
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.account.GroupIncludeCache;
-import com.google.gerrit.server.group.AddIncludedGroups.Input;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-@Singleton
-public class AddIncludedGroups implements RestModifyView<GroupResource, Input> {
-  public static class Input {
-    @DefaultInput String _oneGroup;
-
-    public List<String> groups;
-
-    public static Input fromGroups(List<String> groups) {
-      Input in = new Input();
-      in.groups = groups;
-      return in;
-    }
-
-    static Input init(Input in) {
-      if (in == null) {
-        in = new Input();
-      }
-      if (in.groups == null) {
-        in.groups = Lists.newArrayListWithCapacity(1);
-      }
-      if (!Strings.isNullOrEmpty(in._oneGroup)) {
-        in.groups.add(in._oneGroup);
-      }
-      return in;
-    }
-  }
-
-  private final GroupsCollection groupsCollection;
-  private final GroupIncludeCache groupIncludeCache;
-  private final Provider<ReviewDb> db;
-  private final GroupJson json;
-  private final AuditService auditService;
-
-  @Inject
-  public AddIncludedGroups(
-      GroupsCollection groupsCollection,
-      GroupIncludeCache groupIncludeCache,
-      Provider<ReviewDb> db,
-      GroupJson json,
-      AuditService auditService) {
-    this.groupsCollection = groupsCollection;
-    this.groupIncludeCache = groupIncludeCache;
-    this.db = db;
-    this.json = json;
-    this.auditService = auditService;
-  }
-
-  @Override
-  public List<GroupInfo> apply(GroupResource resource, Input input)
-      throws MethodNotAllowedException, AuthException, UnprocessableEntityException, OrmException {
-    AccountGroup group = resource.toAccountGroup();
-    if (group == null) {
-      throw new MethodNotAllowedException();
-    }
-    input = Input.init(input);
-
-    GroupControl control = resource.getControl();
-    Map<AccountGroup.UUID, AccountGroupById> newIncludedGroups = new HashMap<>();
-    List<GroupInfo> result = new ArrayList<>();
-    Account.Id me = control.getUser().getAccountId();
-
-    for (String includedGroup : input.groups) {
-      GroupDescription.Basic d = groupsCollection.parse(includedGroup);
-      if (!control.canAddGroup()) {
-        throw new AuthException(String.format("Cannot add group: %s", d.getName()));
-      }
-
-      if (!newIncludedGroups.containsKey(d.getGroupUUID())) {
-        AccountGroupById.Key agiKey = new AccountGroupById.Key(group.getId(), d.getGroupUUID());
-        AccountGroupById agi = db.get().accountGroupById().get(agiKey);
-        if (agi == null) {
-          agi = new AccountGroupById(agiKey);
-          newIncludedGroups.put(d.getGroupUUID(), agi);
-        }
-      }
-      result.add(json.format(d));
-    }
-
-    if (!newIncludedGroups.isEmpty()) {
-      auditService.dispatchAddGroupsToGroup(me, newIncludedGroups.values());
-      db.get().accountGroupById().insert(newIncludedGroups.values());
-      for (AccountGroupById agi : newIncludedGroups.values()) {
-        groupIncludeCache.evictParentGroupsOf(agi.getIncludeUUID());
-      }
-      groupIncludeCache.evictSubgroupsOf(group.getGroupUUID());
-    }
-
-    return result;
-  }
-
-  static class PutIncludedGroup implements RestModifyView<GroupResource, PutIncludedGroup.Input> {
-    static class Input {}
-
-    private final AddIncludedGroups put;
-    private final String id;
-
-    PutIncludedGroup(AddIncludedGroups put, String id) {
-      this.put = put;
-      this.id = id;
-    }
-
-    @Override
-    public GroupInfo apply(GroupResource resource, Input input)
-        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException {
-      AddIncludedGroups.Input in = new AddIncludedGroups.Input();
-      in.groups = ImmutableList.of(id);
-      try {
-        List<GroupInfo> list = put.apply(resource, in);
-        if (list.size() == 1) {
-          return list.get(0);
-        }
-        throw new IllegalStateException();
-      } catch (UnprocessableEntityException e) {
-        throw new ResourceNotFoundException(id);
-      }
-    }
-  }
-
-  @Singleton
-  static class UpdateIncludedGroup
-      implements RestModifyView<IncludedGroupResource, PutIncludedGroup.Input> {
-    private final Provider<GetIncludedGroup> get;
-
-    @Inject
-    UpdateIncludedGroup(Provider<GetIncludedGroup> get) {
-      this.get = get;
-    }
-
-    @Override
-    public GroupInfo apply(IncludedGroupResource resource, PutIncludedGroup.Input input)
-        throws OrmException {
-      // Do nothing, the group is already included.
-      return get.get().apply(resource);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
index 150ac01..6e0e512 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddMembers.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Lists;
-import com.google.gerrit.audit.AuditService;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -27,9 +29,7 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountException;
 import com.google.gerrit.server.account.AccountLoader;
@@ -37,8 +37,8 @@
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.account.externalids.ExternalId;
 import com.google.gerrit.server.config.AuthConfig;
 import com.google.gerrit.server.group.AddMembers.Input;
 import com.google.gwtorm.server.OrmException;
@@ -48,11 +48,10 @@
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class AddMembers implements RestModifyView<GroupResource, Input> {
@@ -81,7 +80,6 @@
     }
   }
 
-  private final Provider<IdentifiedUser> self;
   private final AccountManager accountManager;
   private final AuthType authType;
   private final AccountsCollection accounts;
@@ -89,11 +87,10 @@
   private final AccountCache accountCache;
   private final AccountLoader.Factory infoFactory;
   private final Provider<ReviewDb> db;
-  private final AuditService auditService;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Inject
   AddMembers(
-      Provider<IdentifiedUser> self,
       AccountManager accountManager,
       AuthConfig authConfig,
       AccountsCollection accounts,
@@ -101,29 +98,29 @@
       AccountCache accountCache,
       AccountLoader.Factory infoFactory,
       Provider<ReviewDb> db,
-      AuditService auditService) {
-    this.self = self;
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
     this.accountManager = accountManager;
-    this.auditService = auditService;
     this.authType = authConfig.getAuthType();
     this.accounts = accounts;
     this.accountResolver = accountResolver;
     this.accountCache = accountCache;
     this.infoFactory = infoFactory;
     this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
   }
 
   @Override
   public List<AccountInfo> apply(GroupResource resource, Input input)
       throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
-          IOException {
-    AccountGroup internalGroup = resource.toAccountGroup();
-    if (internalGroup == null) {
-      throw new MethodNotAllowedException();
-    }
+          IOException, ConfigInvalidException, ResourceNotFoundException {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
     input = Input.init(input);
 
     GroupControl control = resource.getControl();
+    if (!control.canAddMember()) {
+      throw new AuthException("Cannot add members to group " + internalGroup.getName());
+    }
 
     Set<Account.Id> newMemberIds = new HashSet<>();
     for (String nameOrEmailOrId : input.members) {
@@ -132,19 +129,21 @@
         throw new UnprocessableEntityException(
             String.format("Account Inactive: %s", nameOrEmailOrId));
       }
-
-      if (!control.canAddMember()) {
-        throw new AuthException("Cannot add member: " + a.getFullName());
-      }
       newMemberIds.add(a.getId());
     }
 
-    addMembers(internalGroup.getId(), newMemberIds);
+    AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+    try {
+      addMembers(groupUuid, newMemberIds);
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+    }
     return toAccountInfoList(newMemberIds);
   }
 
   Account findAccount(String nameOrEmailOrId)
-      throws AuthException, UnprocessableEntityException, OrmException, IOException {
+      throws AuthException, UnprocessableEntityException, OrmException, IOException,
+          ConfigInvalidException {
     try {
       return accounts.parse(nameOrEmailOrId).getAccount();
     } catch (UnprocessableEntityException e) {
@@ -154,7 +153,7 @@
         case HTTP_LDAP:
         case CLIENT_SSL_CERT_LDAP:
         case LDAP:
-          if (accountResolver.find(db.get(), nameOrEmailOrId) == null) {
+          if (accountResolver.find(nameOrEmailOrId) == null) {
             // account does not exist, try to create it
             Account a = createAccountByLdap(nameOrEmailOrId);
             if (a != null) {
@@ -175,27 +174,11 @@
     }
   }
 
-  public void addMembers(AccountGroup.Id groupId, Collection<? extends Account.Id> newMemberIds)
-      throws OrmException, IOException {
-    Map<Account.Id, AccountGroupMember> newAccountGroupMembers = new HashMap<>();
-    for (Account.Id accId : newMemberIds) {
-      if (!newAccountGroupMembers.containsKey(accId)) {
-        AccountGroupMember.Key key = new AccountGroupMember.Key(accId, groupId);
-        AccountGroupMember m = db.get().accountGroupMembers().get(key);
-        if (m == null) {
-          m = new AccountGroupMember(key);
-          newAccountGroupMembers.put(m.getAccountId(), m);
-        }
-      }
-    }
-    if (!newAccountGroupMembers.isEmpty()) {
-      auditService.dispatchAddAccountsToGroup(
-          self.get().getAccountId(), newAccountGroupMembers.values());
-      db.get().accountGroupMembers().insert(newAccountGroupMembers.values());
-      for (AccountGroupMember m : newAccountGroupMembers.values()) {
-        accountCache.evict(m.getAccountId());
-      }
-    }
+  public void addMembers(AccountGroup.UUID groupUuid, Collection<? extends Account.Id> newMemberIds)
+      throws OrmException, IOException, NoSuchGroupException {
+    groupsUpdateProvider
+        .get()
+        .addGroupMembers(db.get(), groupUuid, ImmutableSet.copyOf(newMemberIds));
   }
 
   private Account createAccountByLdap(String user) throws IOException {
@@ -236,7 +219,7 @@
     @Override
     public AccountInfo apply(GroupResource resource, PutMember.Input input)
         throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
-            IOException {
+            IOException, ConfigInvalidException {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = id;
       try {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/AddSubgroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddSubgroups.java
new file mode 100644
index 0000000..2ce168f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/AddSubgroups.java
@@ -0,0 +1,161 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.AddSubgroups.Input;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+@Singleton
+public class AddSubgroups implements RestModifyView<GroupResource, Input> {
+  public static class Input {
+    @DefaultInput String _oneGroup;
+
+    public List<String> groups;
+
+    public static Input fromGroups(List<String> groups) {
+      Input in = new Input();
+      in.groups = groups;
+      return in;
+    }
+
+    static Input init(Input in) {
+      if (in == null) {
+        in = new Input();
+      }
+      if (in.groups == null) {
+        in.groups = Lists.newArrayListWithCapacity(1);
+      }
+      if (!Strings.isNullOrEmpty(in._oneGroup)) {
+        in.groups.add(in._oneGroup);
+      }
+      return in;
+    }
+  }
+
+  private final GroupsCollection groupsCollection;
+  private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+  private final GroupJson json;
+
+  @Inject
+  public AddSubgroups(
+      GroupsCollection groupsCollection,
+      Provider<ReviewDb> db,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
+      GroupJson json) {
+    this.groupsCollection = groupsCollection;
+    this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+    this.json = json;
+  }
+
+  @Override
+  public List<GroupInfo> apply(GroupResource resource, Input input)
+      throws MethodNotAllowedException, AuthException, UnprocessableEntityException, OrmException,
+          ResourceNotFoundException, IOException {
+    GroupDescription.Internal group =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    input = Input.init(input);
+
+    GroupControl control = resource.getControl();
+    if (!control.canAddGroup()) {
+      throw new AuthException(String.format("Cannot add groups to group %s", group.getName()));
+    }
+
+    List<GroupInfo> result = new ArrayList<>();
+    Set<AccountGroup.UUID> subgroupUuids = new HashSet<>();
+    for (String subgroupIdentifier : input.groups) {
+      GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
+      subgroupUuids.add(subgroup.getGroupUUID());
+      result.add(json.format(subgroup));
+    }
+
+    AccountGroup.UUID groupUuid = group.getGroupUUID();
+    try {
+      groupsUpdateProvider.get().addSubgroups(db.get(), groupUuid, subgroupUuids);
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+    }
+    return result;
+  }
+
+  static class PutSubgroup implements RestModifyView<GroupResource, PutSubgroup.Input> {
+    static class Input {}
+
+    private final AddSubgroups addSubgroups;
+    private final String id;
+
+    PutSubgroup(AddSubgroups addSubgroups, String id) {
+      this.addSubgroups = addSubgroups;
+      this.id = id;
+    }
+
+    @Override
+    public GroupInfo apply(GroupResource resource, Input input)
+        throws AuthException, MethodNotAllowedException, ResourceNotFoundException, OrmException,
+            IOException {
+      AddSubgroups.Input in = new AddSubgroups.Input();
+      in.groups = ImmutableList.of(id);
+      try {
+        List<GroupInfo> list = addSubgroups.apply(resource, in);
+        if (list.size() == 1) {
+          return list.get(0);
+        }
+        throw new IllegalStateException();
+      } catch (UnprocessableEntityException e) {
+        throw new ResourceNotFoundException(id);
+      }
+    }
+  }
+
+  @Singleton
+  static class UpdateSubgroup implements RestModifyView<SubgroupResource, PutSubgroup.Input> {
+    private final Provider<GetSubgroup> get;
+
+    @Inject
+    UpdateSubgroup(Provider<GetSubgroup> get) {
+      this.get = get;
+    }
+
+    @Override
+    public GroupInfo apply(SubgroupResource resource, PutSubgroup.Input input) throws OrmException {
+      // Do nothing, the group is already included.
+      return get.get().apply(resource);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
index 4d78a7d..e55397e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/CreateGroup.java
@@ -16,6 +16,8 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
@@ -27,13 +29,13 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
@@ -54,6 +56,8 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Locale;
+import java.util.Optional;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
 
@@ -66,6 +70,7 @@
   private final Provider<IdentifiedUser> self;
   private final PersonIdent serverIdent;
   private final ReviewDb db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final GroupCache groupCache;
   private final GroupsCollection groups;
   private final GroupJson json;
@@ -80,6 +85,7 @@
       Provider<IdentifiedUser> self,
       @GerritPersonIdent PersonIdent serverIdent,
       ReviewDb db,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       GroupCache groupCache,
       GroupsCollection groups,
       GroupJson json,
@@ -91,6 +97,7 @@
     this.self = self;
     this.serverIdent = serverIdent;
     this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
     this.groupCache = groupCache;
     this.groups = groups;
     this.json = json;
@@ -114,7 +121,8 @@
   @Override
   public GroupInfo apply(TopLevelResource resource, GroupInput input)
       throws AuthException, BadRequestException, UnprocessableEntityException,
-          ResourceConflictException, OrmException, IOException {
+          ResourceConflictException, OrmException, IOException, ConfigInvalidException,
+          ResourceNotFoundException {
     if (input == null) {
       input = new GroupInput();
     }
@@ -159,8 +167,8 @@
 
   private AccountGroup.Id owner(GroupInput input) throws UnprocessableEntityException {
     if (input.ownerId != null) {
-      GroupDescription.Basic d = groups.parseInternal(Url.decode(input.ownerId));
-      return GroupDescriptions.toAccountGroup(d).getId();
+      GroupDescription.Internal d = groups.parseInternal(Url.decode(input.ownerId));
+      return d.getId();
     }
     return null;
   }
@@ -168,17 +176,16 @@
   private AccountGroup createGroup(CreateGroupArgs createGroupArgs)
       throws OrmException, ResourceConflictException, IOException {
 
-    // Do not allow creating groups with the same name as system groups
+    String nameLower = createGroupArgs.getGroupName().toLowerCase(Locale.US);
+
     for (String name : systemGroupBackend.getNames()) {
-      if (name.toLowerCase(Locale.US)
-          .equals(createGroupArgs.getGroupName().toLowerCase(Locale.US))) {
+      if (name.toLowerCase(Locale.US).equals(nameLower)) {
         throw new ResourceConflictException("group '" + name + "' already exists");
       }
     }
 
     for (String name : systemGroupBackend.getReservedNames()) {
-      if (name.toLowerCase(Locale.US)
-          .equals(createGroupArgs.getGroupName().toLowerCase(Locale.US))) {
+      if (name.toLowerCase(Locale.US).equals(nameLower)) {
         throw new ResourceConflictException("group name '" + name + "' is reserved");
       }
     }
@@ -188,31 +195,24 @@
         GroupUUID.make(
             createGroupArgs.getGroupName(),
             self.get().newCommitterIdent(serverIdent.getWhen(), serverIdent.getTimeZone()));
-    AccountGroup group = new AccountGroup(createGroupArgs.getGroup(), groupId, uuid);
+    AccountGroup group =
+        new AccountGroup(createGroupArgs.getGroup(), groupId, uuid, TimeUtil.nowTs());
     group.setVisibleToAll(createGroupArgs.visibleToAll);
     if (createGroupArgs.ownerGroupId != null) {
-      AccountGroup ownerGroup = groupCache.get(createGroupArgs.ownerGroupId);
-      if (ownerGroup != null) {
-        group.setOwnerGroupUUID(ownerGroup.getGroupUUID());
-      }
+      Optional<InternalGroup> ownerGroup = groupCache.get(createGroupArgs.ownerGroupId);
+      ownerGroup.map(InternalGroup::getGroupUUID).ifPresent(group::setOwnerGroupUUID);
     }
     if (createGroupArgs.groupDescription != null) {
       group.setDescription(createGroupArgs.groupDescription);
     }
-    AccountGroupName gn = new AccountGroupName(group);
-    // first insert the group name to validate that the group name hasn't
-    // already been used to create another group
     try {
-      db.accountGroupNames().insert(Collections.singleton(gn));
+      groupsUpdateProvider
+          .get()
+          .addGroup(db, group, ImmutableSet.copyOf(createGroupArgs.initialMembers));
     } catch (OrmDuplicateKeyException e) {
       throw new ResourceConflictException(
           "group '" + createGroupArgs.getGroupName() + "' already exists");
     }
-    db.accountGroups().insert(Collections.singleton(group));
-
-    addMembers.addMembers(groupId, createGroupArgs.initialMembers);
-
-    groupCache.onCreateGroup(createGroupArgs.getGroup());
 
     return group;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
index 930d572..0d44289 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DbGroupMemberAuditListener.java
@@ -123,7 +123,7 @@
   public void onDeleteGroupsFromGroup(Account.Id me, Collection<AccountGroupById> removed) {
     final List<AccountGroupByIdAud> auditUpdates = new ArrayList<>();
     try (ReviewDb db = schema.open()) {
-      for (final AccountGroupById g : removed) {
+      for (AccountGroupById g : removed) {
         AccountGroupByIdAud audit = null;
         for (AccountGroupByIdAud a :
             db.accountGroupByIdAud().byGroupInclude(g.getGroupId(), g.getIncludeUUID())) {
@@ -152,7 +152,7 @@
       Account.Id accountId = m.getAccountId();
       String userName = accountCache.get(accountId).getUserName();
       AccountGroup.Id groupId = m.getAccountGroupId();
-      String groupName = groupCache.get(groupId).getName();
+      String groupName = getGroupName(groupId);
 
       descriptions.add(
           MessageFormat.format(
@@ -168,7 +168,7 @@
       AccountGroup.UUID groupUuid = m.getIncludeUUID();
       String groupName = groupBackend.get(groupUuid).getName();
       AccountGroup.Id targetGroupId = m.getGroupId();
-      String targetGroupName = groupCache.get(targetGroupId).getName();
+      String targetGroupName = getGroupName(targetGroupId);
 
       descriptions.add(
           MessageFormat.format(
@@ -178,6 +178,10 @@
     logOrmException(header, me, descriptions, e);
   }
 
+  private String getGroupName(AccountGroup.Id groupId) {
+    return groupCache.get(groupId).map(InternalGroup::getName).orElse("Deleted group " + groupId);
+  }
+
   private void logOrmException(String header, Account.Id me, Iterable<?> values, OrmException e) {
     StringBuilder message = new StringBuilder(header);
     message.append(" ");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java
deleted file mode 100644
index 9f612bf..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteIncludedGroups.java
+++ /dev/null
@@ -1,137 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.audit.AuditService;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.Response;
-import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.account.GroupIncludeCache;
-import com.google.gerrit.server.group.AddIncludedGroups.Input;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-
-@Singleton
-public class DeleteIncludedGroups implements RestModifyView<GroupResource, Input> {
-  private final GroupsCollection groupsCollection;
-  private final GroupIncludeCache groupIncludeCache;
-  private final Provider<ReviewDb> db;
-  private final Provider<CurrentUser> self;
-  private final AuditService auditService;
-
-  @Inject
-  DeleteIncludedGroups(
-      GroupsCollection groupsCollection,
-      GroupIncludeCache groupIncludeCache,
-      Provider<ReviewDb> db,
-      Provider<CurrentUser> self,
-      AuditService auditService) {
-    this.groupsCollection = groupsCollection;
-    this.groupIncludeCache = groupIncludeCache;
-    this.db = db;
-    this.self = self;
-    this.auditService = auditService;
-  }
-
-  @Override
-  public Response<?> apply(GroupResource resource, Input input)
-      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException {
-    AccountGroup internalGroup = resource.toAccountGroup();
-    if (internalGroup == null) {
-      throw new MethodNotAllowedException();
-    }
-    input = Input.init(input);
-
-    final GroupControl control = resource.getControl();
-    final Map<AccountGroup.UUID, AccountGroupById> includedGroups =
-        getIncludedGroups(internalGroup.getId());
-    final List<AccountGroupById> toRemove = new ArrayList<>();
-
-    for (final String includedGroup : input.groups) {
-      GroupDescription.Basic d = groupsCollection.parse(includedGroup);
-      if (!control.canRemoveGroup()) {
-        throw new AuthException(String.format("Cannot delete group: %s", d.getName()));
-      }
-
-      AccountGroupById g = includedGroups.remove(d.getGroupUUID());
-      if (g != null) {
-        toRemove.add(g);
-      }
-    }
-
-    if (!toRemove.isEmpty()) {
-      writeAudits(toRemove);
-      db.get().accountGroupById().delete(toRemove);
-      for (final AccountGroupById g : toRemove) {
-        groupIncludeCache.evictParentGroupsOf(g.getIncludeUUID());
-      }
-      groupIncludeCache.evictSubgroupsOf(internalGroup.getGroupUUID());
-    }
-
-    return Response.none();
-  }
-
-  private Map<AccountGroup.UUID, AccountGroupById> getIncludedGroups(final AccountGroup.Id groupId)
-      throws OrmException {
-    final Map<AccountGroup.UUID, AccountGroupById> groups = new HashMap<>();
-    for (AccountGroupById g : db.get().accountGroupById().byGroup(groupId)) {
-      groups.put(g.getIncludeUUID(), g);
-    }
-    return groups;
-  }
-
-  private void writeAudits(final List<AccountGroupById> toRemoved) {
-    final Account.Id me = self.get().getAccountId();
-    auditService.dispatchDeleteGroupsFromGroup(me, toRemoved);
-  }
-
-  @Singleton
-  static class DeleteIncludedGroup
-      implements RestModifyView<IncludedGroupResource, DeleteIncludedGroup.Input> {
-    static class Input {}
-
-    private final Provider<DeleteIncludedGroups> delete;
-
-    @Inject
-    DeleteIncludedGroup(final Provider<DeleteIncludedGroups> delete) {
-      this.delete = delete;
-    }
-
-    @Override
-    public Response<?> apply(IncludedGroupResource resource, Input input)
-        throws AuthException, MethodNotAllowedException, UnprocessableEntityException,
-            OrmException {
-      AddIncludedGroups.Input in = new AddIncludedGroups.Input();
-      in.groups = ImmutableList.of(resource.getMember().get());
-      return delete.get().apply(resource, in);
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
index e365ce3..1069e1c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteMembers.java
@@ -14,18 +14,17 @@
 
 package com.google.gerrit.server.group;
 
-import com.google.gerrit.audit.AuditService;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 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.CurrentUser;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.group.AddMembers.Input;
@@ -34,83 +33,54 @@
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class DeleteMembers implements RestModifyView<GroupResource, Input> {
   private final AccountsCollection accounts;
-  private final AccountCache accountCache;
   private final Provider<ReviewDb> db;
-  private final Provider<CurrentUser> self;
-  private final AuditService auditService;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Inject
   DeleteMembers(
       AccountsCollection accounts,
-      AccountCache accountCache,
       Provider<ReviewDb> db,
-      Provider<CurrentUser> self,
-      AuditService auditService) {
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
     this.accounts = accounts;
-    this.accountCache = accountCache;
     this.db = db;
-    this.self = self;
-    this.auditService = auditService;
+    this.groupsUpdateProvider = groupsUpdateProvider;
   }
 
   @Override
   public Response<?> apply(GroupResource resource, Input input)
       throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
-          IOException {
-    AccountGroup internalGroup = resource.toAccountGroup();
-    if (internalGroup == null) {
-      throw new MethodNotAllowedException();
-    }
+          IOException, ConfigInvalidException, ResourceNotFoundException {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
     input = Input.init(input);
 
     final GroupControl control = resource.getControl();
-    final Map<Account.Id, AccountGroupMember> members = getMembers(internalGroup.getId());
-    final List<AccountGroupMember> toRemove = new ArrayList<>();
-
-    for (final String nameOrEmail : input.members) {
-      Account a = accounts.parse(nameOrEmail).getAccount();
-
-      if (!control.canRemoveMember()) {
-        throw new AuthException("Cannot delete member: " + a.getFullName());
-      }
-
-      final AccountGroupMember m = members.remove(a.getId());
-      if (m != null) {
-        toRemove.add(m);
-      }
+    if (!control.canRemoveMember()) {
+      throw new AuthException("Cannot delete members from group " + internalGroup.getName());
     }
 
-    writeAudits(toRemove);
-    db.get().accountGroupMembers().delete(toRemove);
-    for (final AccountGroupMember m : toRemove) {
-      accountCache.evict(m.getAccountId());
+    Set<Account.Id> membersToRemove = new HashSet<>();
+    for (String nameOrEmail : input.members) {
+      Account a = accounts.parse(nameOrEmail).getAccount();
+      membersToRemove.add(a.getId());
+    }
+    AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+    try {
+      groupsUpdateProvider.get().removeGroupMembers(db.get(), groupUuid, membersToRemove);
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
     }
 
     return Response.none();
   }
 
-  private void writeAudits(final List<AccountGroupMember> toRemove) {
-    final Account.Id me = self.get().getAccountId();
-    auditService.dispatchDeleteAccountsFromGroup(me, toRemove);
-  }
-
-  private Map<Account.Id, AccountGroupMember> getMembers(final AccountGroup.Id groupId)
-      throws OrmException {
-    final Map<Account.Id, AccountGroupMember> members = new HashMap<>();
-    for (final AccountGroupMember m : db.get().accountGroupMembers().byGroup(groupId)) {
-      members.put(m.getAccountId(), m);
-    }
-    return members;
-  }
-
   @Singleton
   static class DeleteMember implements RestModifyView<MemberResource, DeleteMember.Input> {
     static class Input {}
@@ -118,14 +88,14 @@
     private final Provider<DeleteMembers> delete;
 
     @Inject
-    DeleteMember(final Provider<DeleteMembers> delete) {
+    DeleteMember(Provider<DeleteMembers> delete) {
       this.delete = delete;
     }
 
     @Override
     public Response<?> apply(MemberResource resource, Input input)
         throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
-            IOException {
+            IOException, ConfigInvalidException, ResourceNotFoundException {
       AddMembers.Input in = new AddMembers.Input();
       in._oneMember = resource.getMember().getAccountId().toString();
       return delete.get().apply(resource, in);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteSubgroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteSubgroups.java
new file mode 100644
index 0000000..14df51b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/DeleteSubgroups.java
@@ -0,0 +1,104 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.AddSubgroups.Input;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+
+@Singleton
+public class DeleteSubgroups implements RestModifyView<GroupResource, Input> {
+  private final GroupsCollection groupsCollection;
+  private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
+
+  @Inject
+  DeleteSubgroups(
+      GroupsCollection groupsCollection,
+      Provider<ReviewDb> db,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
+    this.groupsCollection = groupsCollection;
+    this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
+  }
+
+  @Override
+  public Response<?> apply(GroupResource resource, Input input)
+      throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+          ResourceNotFoundException, IOException {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    input = Input.init(input);
+
+    final GroupControl control = resource.getControl();
+    if (!control.canRemoveGroup()) {
+      throw new AuthException(
+          String.format("Cannot delete groups from group %s", internalGroup.getName()));
+    }
+
+    Set<AccountGroup.UUID> subgroupsToRemove = new HashSet<>();
+    for (String subgroupIdentifier : input.groups) {
+      GroupDescription.Basic subgroup = groupsCollection.parse(subgroupIdentifier);
+      subgroupsToRemove.add(subgroup.getGroupUUID());
+    }
+
+    AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+    try {
+      groupsUpdateProvider.get().removeSubgroups(db.get(), groupUuid, subgroupsToRemove);
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+    }
+
+    return Response.none();
+  }
+
+  @Singleton
+  static class DeleteSubgroup implements RestModifyView<SubgroupResource, DeleteSubgroup.Input> {
+    static class Input {}
+
+    private final Provider<DeleteSubgroups> delete;
+
+    @Inject
+    DeleteSubgroup(Provider<DeleteSubgroups> delete) {
+      this.delete = delete;
+    }
+
+    @Override
+    public Response<?> apply(SubgroupResource resource, Input input)
+        throws AuthException, MethodNotAllowedException, UnprocessableEntityException, OrmException,
+            ResourceNotFoundException, IOException {
+      AddSubgroups.Input in = new AddSubgroups.Input();
+      in.groups = ImmutableList.of(resource.getMember().get());
+      return delete.get().apply(resource, in);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
index 09aad6c..ba83e24 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetAuditLog.java
@@ -15,13 +15,11 @@
 package com.google.gerrit.server.group;
 
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupAuditEventInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -39,6 +37,7 @@
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Optional;
 
 @Singleton
 public class GetAuditLog implements RestReadView<GroupResource> {
@@ -64,18 +63,13 @@
 
   @Override
   public List<? extends GroupAuditEventInfo> apply(GroupResource rsrc)
-      throws AuthException, ResourceNotFoundException, MethodNotAllowedException, OrmException {
-    if (rsrc.toAccountGroup() == null) {
-      throw new MethodNotAllowedException();
-    } else if (!rsrc.getControl().isOwner()) {
+      throws AuthException, MethodNotAllowedException, OrmException {
+    GroupDescription.Internal group =
+        rsrc.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (!rsrc.getControl().isOwner()) {
       throw new AuthException("Not group owner");
     }
 
-    AccountGroup group = db.get().accountGroups().get(rsrc.toAccountGroup().getId());
-    if (group == null) {
-      throw new ResourceNotFoundException();
-    }
-
     AccountLoader accountLoader = accountLoaderFactory.create(true);
 
     List<GroupAuditEventInfo> auditEvents = new ArrayList<>();
@@ -100,10 +94,10 @@
     for (AccountGroupByIdAud auditEvent :
         db.get().accountGroupByIdAud().byGroup(group.getId()).toList()) {
       AccountGroup.UUID includedGroupUUID = auditEvent.getKey().getIncludeUUID();
-      AccountGroup includedGroup = groupCache.get(includedGroupUUID);
+      Optional<InternalGroup> includedGroup = groupCache.get(includedGroupUUID);
       GroupInfo member;
-      if (includedGroup != null) {
-        member = groupJson.format(GroupDescriptions.forAccountGroup(includedGroup));
+      if (includedGroup.isPresent()) {
+        member = groupJson.format(new InternalGroupDescription(includedGroup.get()));
       } else {
         GroupDescription.Basic groupDescription = groupBackend.get(includedGroupUUID);
         member = new GroupInfo();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDescription.java
index 6900b83..0610843 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetDescription.java
@@ -15,19 +15,17 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GetDescription implements RestReadView<GroupResource> {
   @Override
   public String apply(GroupResource resource) throws MethodNotAllowedException {
-    AccountGroup group = resource.toAccountGroup();
-    if (group == null) {
-      throw new MethodNotAllowedException();
-    }
+    GroupDescription.Internal group =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
     return Strings.nullToEmpty(group.getDescription());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetIncludedGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetIncludedGroup.java
deleted file mode 100644
index 4cf0cb2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetIncludedGroup.java
+++ /dev/null
@@ -1,36 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-
-@Singleton
-public class GetIncludedGroup implements RestReadView<IncludedGroupResource> {
-  private final GroupJson json;
-
-  @Inject
-  GetIncludedGroup(GroupJson json) {
-    this.json = json;
-  }
-
-  @Override
-  public GroupInfo apply(IncludedGroupResource rsrc) throws OrmException {
-    return json.format(rsrc.getMemberDescription());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetOwner.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetOwner.java
index 464be18..03d0788 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetOwner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetOwner.java
@@ -14,12 +14,12 @@
 
 package com.google.gerrit.server.group;
 
+import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -40,10 +40,8 @@
   @Override
   public GroupInfo apply(GroupResource resource)
       throws MethodNotAllowedException, ResourceNotFoundException, OrmException {
-    AccountGroup group = resource.toAccountGroup();
-    if (group == null) {
-      throw new MethodNotAllowedException();
-    }
+    GroupDescription.Internal group =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
     try {
       GroupControl c = controlFactory.validateFor(group.getOwnerGroupUUID());
       return json.format(c.getGroup());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GetSubgroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetSubgroup.java
new file mode 100644
index 0000000..a710188
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GetSubgroup.java
@@ -0,0 +1,36 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetSubgroup implements RestReadView<SubgroupResource> {
+  private final GroupJson json;
+
+  @Inject
+  GetSubgroup(GroupJson json) {
+    this.json = json;
+  }
+
+  @Override
+  public GroupInfo apply(SubgroupResource rsrc) throws OrmException {
+    return json.format(rsrc.getMemberDescription());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java
index 43e70ff..85be5c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupJson.java
@@ -19,7 +19,6 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
@@ -37,8 +36,7 @@
 public class GroupJson {
   public static GroupOptionsInfo createOptions(GroupDescription.Basic group) {
     GroupOptionsInfo options = new GroupOptionsInfo();
-    AccountGroup ag = GroupDescriptions.toAccountGroup(group);
-    if (ag != null && ag.isVisibleToAll()) {
+    if (isInternalGroup(group) && ((GroupDescription.Internal) group).isVisibleToAll()) {
       options.visibleToAll = true;
     }
     return options;
@@ -47,7 +45,7 @@
   private final GroupBackend groupBackend;
   private final GroupControl.Factory groupControlFactory;
   private final Provider<ListMembers> listMembers;
-  private final Provider<ListIncludedGroups> listIncludes;
+  private final Provider<ListSubgroups> listSubgroups;
   private EnumSet<ListGroupsOption> options;
 
   @Inject
@@ -55,11 +53,11 @@
       GroupBackend groupBackend,
       GroupControl.Factory groupControlFactory,
       Provider<ListMembers> listMembers,
-      Provider<ListIncludedGroups> listIncludes) {
+      Provider<ListSubgroups> listSubgroups) {
     this.groupBackend = groupBackend;
     this.groupControlFactory = groupControlFactory;
     this.listMembers = listMembers;
-    this.listIncludes = listIncludes;
+    this.listSubgroups = listSubgroups;
 
     options = EnumSet.noneOf(ListGroupsOption.class);
   }
@@ -76,7 +74,7 @@
 
   public GroupInfo format(GroupResource rsrc) throws OrmException {
     GroupInfo info = init(rsrc.getGroup());
-    initMembersAndIncludes(rsrc, info);
+    initMembersAndSubgroups(rsrc, info);
     return info;
   }
 
@@ -84,7 +82,7 @@
     GroupInfo info = init(group);
     if (options.contains(MEMBERS) || options.contains(INCLUDES)) {
       GroupResource rsrc = new GroupResource(groupControlFactory.controlFor(group));
-      initMembersAndIncludes(rsrc, info);
+      initMembersAndSubgroups(rsrc, info);
     }
     return info;
   }
@@ -96,24 +94,31 @@
     info.url = Strings.emptyToNull(group.getUrl());
     info.options = createOptions(group);
 
-    AccountGroup g = GroupDescriptions.toAccountGroup(group);
-    if (g != null) {
-      info.description = Strings.emptyToNull(g.getDescription());
-      info.groupId = g.getId().get();
-      if (g.getOwnerGroupUUID() != null) {
-        info.ownerId = Url.encode(g.getOwnerGroupUUID().get());
-        GroupDescription.Basic o = groupBackend.get(g.getOwnerGroupUUID());
+    if (isInternalGroup(group)) {
+      GroupDescription.Internal internalGroup = (GroupDescription.Internal) group;
+      info.description = Strings.emptyToNull(internalGroup.getDescription());
+      info.groupId = internalGroup.getId().get();
+      AccountGroup.UUID ownerGroupUUID = internalGroup.getOwnerGroupUUID();
+      if (ownerGroupUUID != null) {
+        info.ownerId = Url.encode(ownerGroupUUID.get());
+        GroupDescription.Basic o = groupBackend.get(ownerGroupUUID);
         if (o != null) {
           info.owner = o.getName();
         }
       }
+      info.createdOn = internalGroup.getCreatedOn();
     }
 
     return info;
   }
 
-  private GroupInfo initMembersAndIncludes(GroupResource rsrc, GroupInfo info) throws OrmException {
-    if (rsrc.toAccountGroup() == null) {
+  private static boolean isInternalGroup(GroupDescription.Basic group) {
+    return group instanceof GroupDescription.Internal;
+  }
+
+  private GroupInfo initMembersAndSubgroups(GroupResource rsrc, GroupInfo info)
+      throws OrmException {
+    if (!rsrc.isInternalGroup()) {
       return info;
     }
     try {
@@ -122,7 +127,7 @@
       }
 
       if (options.contains(INCLUDES)) {
-        info.includes = listIncludes.get().apply(rsrc);
+        info.includes = listSubgroups.get().apply(rsrc);
       }
       return info;
     } catch (MethodNotAllowedException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupResource.java
index 54fc787..44e770f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupResource.java
@@ -15,12 +15,11 @@
 package com.google.gerrit.server.group;
 
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.inject.TypeLiteral;
+import java.util.Optional;
 
 public class GroupResource implements RestResource {
   public static final TypeLiteral<RestView<GroupResource>> GROUP_KIND =
@@ -44,12 +43,17 @@
     return getGroup().getName();
   }
 
-  public AccountGroup.UUID getGroupUUID() {
-    return getGroup().getGroupUUID();
+  public boolean isInternalGroup() {
+    GroupDescription.Basic group = getGroup();
+    return group instanceof GroupDescription.Internal;
   }
 
-  public AccountGroup toAccountGroup() {
-    return GroupDescriptions.toAccountGroup(getGroup());
+  public Optional<GroupDescription.Internal> asInternalGroup() {
+    GroupDescription.Basic group = getGroup();
+    if (group instanceof GroupDescription.Internal) {
+      return Optional.of((GroupDescription.Internal) group);
+    }
+    return Optional.empty();
   }
 
   public GroupControl getControl() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/Groups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/Groups.java
new file mode 100644
index 0000000..233f36b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/Groups.java
@@ -0,0 +1,271 @@
+// 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.group;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.Streams;
+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.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmDuplicateKeyException;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Singleton;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+
+/**
+ * A database accessor for read calls related to groups.
+ *
+ * <p>All calls which read group related details from the database (either ReviewDb or NoteDb) are
+ * gathered here. Other classes should always use this class instead of accessing the database
+ * directly. There are a few exceptions though: schema classes, wrapper classes, and classes
+ * executed during init. The latter ones should use {@code GroupsOnInit} instead.
+ *
+ * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
+ */
+@Singleton
+public class Groups {
+
+  /**
+   * Returns the {@code AccountGroup} for the specified ID if it exists.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupId the ID of the group
+   * @return the found {@code AccountGroup} if it exists, or else an empty {@code Optional}
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   */
+  public Optional<InternalGroup> getGroup(ReviewDb db, AccountGroup.Id groupId)
+      throws OrmException, NoSuchGroupException {
+    Optional<AccountGroup> accountGroup = Optional.ofNullable(db.accountGroups().get(groupId));
+
+    if (!accountGroup.isPresent()) {
+      return Optional.empty();
+    }
+
+    AccountGroup.UUID groupUuid = accountGroup.get().getGroupUUID();
+    ImmutableSet<Account.Id> members = getMembers(db, groupUuid).collect(toImmutableSet());
+    ImmutableSet<AccountGroup.UUID> subgroups =
+        getSubgroups(db, groupUuid).collect(toImmutableSet());
+    return accountGroup.map(group -> InternalGroup.create(group, members, subgroups));
+  }
+
+  /**
+   * Returns the {@code InternalGroup} for the specified UUID if it exists.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupUuid the UUID of the group
+   * @return the found {@code InternalGroup} if it exists, or else an empty {@code Optional}
+   * @throws OrmDuplicateKeyException if multiple groups are found for the specified UUID
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   */
+  public Optional<InternalGroup> getGroup(ReviewDb db, AccountGroup.UUID groupUuid)
+      throws OrmException, NoSuchGroupException {
+    Optional<AccountGroup> accountGroup = getGroupFromReviewDb(db, groupUuid);
+
+    if (!accountGroup.isPresent()) {
+      return Optional.empty();
+    }
+
+    ImmutableSet<Account.Id> members = getMembers(db, groupUuid).collect(toImmutableSet());
+    ImmutableSet<AccountGroup.UUID> subgroups =
+        getSubgroups(db, groupUuid).collect(toImmutableSet());
+    return accountGroup.map(group -> InternalGroup.create(group, members, subgroups));
+  }
+
+  /**
+   * Returns the {@code AccountGroup} for the specified UUID.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupUuid the UUID of the group
+   * @return the {@code AccountGroup} which has the specified UUID
+   * @throws OrmDuplicateKeyException if multiple groups are found for the specified UUID
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   * @throws NoSuchGroupException if a group with such a UUID doesn't exist
+   */
+  static AccountGroup getExistingGroupFromReviewDb(ReviewDb db, AccountGroup.UUID groupUuid)
+      throws OrmException, NoSuchGroupException {
+    Optional<AccountGroup> group = getGroupFromReviewDb(db, groupUuid);
+    return group.orElseThrow(() -> new NoSuchGroupException(groupUuid));
+  }
+
+  /**
+   * Returns the {@code AccountGroup} for the specified UUID if it exists.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupUuid the UUID of the group
+   * @return the found {@code AccountGroup} if it exists, or else an empty {@code Optional}
+   * @throws OrmDuplicateKeyException if multiple groups are found for the specified UUID
+   * @throws OrmException if the group couldn't be retrieved from ReviewDb
+   */
+  private static Optional<AccountGroup> getGroupFromReviewDb(
+      ReviewDb db, AccountGroup.UUID groupUuid) throws OrmException {
+    List<AccountGroup> accountGroups = db.accountGroups().byUUID(groupUuid).toList();
+    if (accountGroups.size() == 1) {
+      return Optional.of(Iterables.getOnlyElement(accountGroups));
+    } else if (accountGroups.isEmpty()) {
+      return Optional.empty();
+    } else {
+      throw new OrmDuplicateKeyException("Duplicate group UUID " + groupUuid);
+    }
+  }
+
+  public Stream<AccountGroup> getAll(ReviewDb db) throws OrmException {
+    return Streams.stream(db.accountGroups().all());
+  }
+
+  /**
+   * Indicates whether the specified account is a member of the specified group.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the account exists!
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupUuid the UUID of the group
+   * @param accountId the ID of the account
+   * @return {@code true} if the account is a member of the group, or else {@code false}
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws NoSuchGroupException if the specified group doesn't exist
+   */
+  public boolean isMember(ReviewDb db, AccountGroup.UUID groupUuid, Account.Id accountId)
+      throws OrmException, NoSuchGroupException {
+    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
+    AccountGroupMember.Key key = new AccountGroupMember.Key(accountId, group.getId());
+    return db.accountGroupMembers().get(key) != null;
+  }
+
+  /**
+   * Indicates whether the specified group is a subgroup of the specified parent group.
+   *
+   * <p>The parent group must be an internal group whereas the subgroup may either be an internal or
+   * an external group.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the subgroup exists!
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param parentGroupUuid the UUID of the parent group
+   * @param subgroupUuid the UUID of the subgroup
+   * @return {@code true} if the group is a subgroup of the other group, or else {@code false}
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws NoSuchGroupException if the specified parent group doesn't exist
+   */
+  public boolean isSubgroup(
+      ReviewDb db, AccountGroup.UUID parentGroupUuid, AccountGroup.UUID subgroupUuid)
+      throws OrmException, NoSuchGroupException {
+    AccountGroup parentGroup = getExistingGroupFromReviewDb(db, parentGroupUuid);
+    AccountGroupById.Key key = new AccountGroupById.Key(parentGroup.getId(), subgroupUuid);
+    return db.accountGroupById().get(key) != null;
+  }
+
+  /**
+   * Returns the members (accounts) of a group.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the accounts exist!
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupUuid the UUID of the group
+   * @return a stream of the IDs of the members
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws NoSuchGroupException if the specified group doesn't exist
+   */
+  public Stream<Account.Id> getMembers(ReviewDb db, AccountGroup.UUID groupUuid)
+      throws OrmException, NoSuchGroupException {
+    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
+    ResultSet<AccountGroupMember> accountGroupMembers =
+        db.accountGroupMembers().byGroup(group.getId());
+    return Streams.stream(accountGroupMembers).map(AccountGroupMember::getAccountId);
+  }
+
+  /**
+   * Returns the subgroups of a group.
+   *
+   * <p>This parent group must be an internal group whereas the subgroups can either be internal or
+   * external groups.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the subgroups exist!
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param groupUuid the UUID of the parent group
+   * @return a stream of the UUIDs of the subgroups
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   * @throws NoSuchGroupException if the specified parent group doesn't exist
+   */
+  public Stream<AccountGroup.UUID> getSubgroups(ReviewDb db, AccountGroup.UUID groupUuid)
+      throws OrmException, NoSuchGroupException {
+    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
+    ResultSet<AccountGroupById> accountGroupByIds = db.accountGroupById().byGroup(group.getId());
+    return Streams.stream(accountGroupByIds).map(AccountGroupById::getIncludeUUID).distinct();
+  }
+
+  /**
+   * Returns the groups of which the specified account is a member.
+   *
+   * <p><strong>Note</strong>: This method returns an empty stream if the account doesn't exist.
+   * This method doesn't check whether the groups exist.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param accountId the ID of the account
+   * @return a stream of the IDs of the groups of which the account is a member
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   */
+  public static Stream<AccountGroup.Id> getGroupsWithMemberFromReviewDb(
+      ReviewDb db, Account.Id accountId) throws OrmException {
+    ResultSet<AccountGroupMember> accountGroupMembers =
+        db.accountGroupMembers().byAccount(accountId);
+    return Streams.stream(accountGroupMembers).map(AccountGroupMember::getAccountGroupId);
+  }
+
+  /**
+   * Returns the parent groups of the specified (sub)group.
+   *
+   * <p>The subgroup may either be an internal or an external group whereas the returned parent
+   * groups represent only internal groups.
+   *
+   * <p><strong>Note</strong>: This method returns an empty stream if the specified group doesn't
+   * exist. This method doesn't check whether the parent groups exist.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @param subgroupUuid the UUID of the subgroup
+   * @return a stream of the IDs of the parent groups
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   */
+  public static Stream<AccountGroup.Id> getParentGroupsFromReviewDb(
+      ReviewDb db, AccountGroup.UUID subgroupUuid) throws OrmException {
+    ResultSet<AccountGroupById> accountGroupByIds =
+        db.accountGroupById().byIncludeUUID(subgroupUuid);
+    return Streams.stream(accountGroupByIds).map(AccountGroupById::getGroupId);
+  }
+
+  /**
+   * Returns all known external groups. External groups are 'known' when they are specified as a
+   * subgroup of an internal group.
+   *
+   * @param db the {@code ReviewDb} instance to use for lookups
+   * @return a stream of the UUIDs of the known external groups
+   * @throws OrmException if an error occurs while reading from ReviewDb
+   */
+  public Stream<AccountGroup.UUID> getExternalGroups(ReviewDb db) throws OrmException {
+    return Streams.stream(db.accountGroupById().all())
+        .map(AccountGroupById::getIncludeUUID)
+        .distinct()
+        .filter(groupUuid -> !AccountGroup.isInternalGroup(groupUuid));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
index 397bf08..4d3bd11 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsCollection.java
@@ -16,7 +16,6 @@
 
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -142,12 +141,13 @@
    * @throws UnprocessableEntityException thrown if the group ID cannot be resolved, if the group is
    *     not visible to the calling user or if it's an external group
    */
-  public GroupDescription.Basic parseInternal(String id) throws UnprocessableEntityException {
+  public GroupDescription.Internal parseInternal(String id) throws UnprocessableEntityException {
     GroupDescription.Basic group = parse(id);
-    if (GroupDescriptions.toAccountGroup(group) == null) {
-      throw new UnprocessableEntityException(String.format("External Group Not Allowed: %s", id));
+    if (group instanceof GroupDescription.Internal) {
+      return (GroupDescription.Internal) group;
     }
-    return group;
+
+    throw new UnprocessableEntityException(String.format("External Group Not Allowed: %s", id));
   }
 
   /**
@@ -188,7 +188,6 @@
     return null;
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public CreateGroup create(TopLevelResource root, IdString name) {
     return createGroup.create(name.get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsUpdate.java
new file mode 100644
index 0000000..f31e383
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/GroupsUpdate.java
@@ -0,0 +1,414 @@
+// 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.group;
+
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+import static com.google.gerrit.server.group.Groups.getExistingGroupFromReviewDb;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.audit.AuditService;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.errors.NameAlreadyUsedException;
+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.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroupName;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.account.GroupIncludeCache;
+import com.google.gerrit.server.git.RenameGroupOp;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.PersonIdent;
+
+/**
+ * A database accessor for write calls related to groups.
+ *
+ * <p>All calls which write group related details to the database (either ReviewDb or NoteDb) are
+ * gathered here. Other classes should always use this class instead of accessing the database
+ * directly. There are a few exceptions though: schema classes, wrapper classes, and classes
+ * executed during init. The latter ones should use {@code GroupsOnInit} instead.
+ *
+ * <p>If not explicitly stated, all methods of this class refer to <em>internal</em> groups.
+ */
+public class GroupsUpdate {
+  public interface Factory {
+    /**
+     * Creates a {@code GroupsUpdate} which uses the identity of the specified user to mark database
+     * modifications executed by it. For NoteDb, this identity is used as author and committer for
+     * all related commits.
+     *
+     * <p><strong>Note</strong>: Please use this method with care and rather consider to use the
+     * correct annotation on the provider of a {@code GroupsUpdate} instead.
+     *
+     * @param currentUser the user to which modifications should be attributed, or {@code null} if
+     *     the Gerrit server identity should be used
+     */
+    GroupsUpdate create(@Nullable IdentifiedUser currentUser);
+  }
+
+  private final Groups groups;
+  private final GroupCache groupCache;
+  private final GroupIncludeCache groupIncludeCache;
+  private final AuditService auditService;
+  private final RenameGroupOp.Factory renameGroupOpFactory;
+  @Nullable private final IdentifiedUser currentUser;
+  private final PersonIdent committerIdent;
+
+  @Inject
+  GroupsUpdate(
+      Groups groups,
+      GroupCache groupCache,
+      GroupIncludeCache groupIncludeCache,
+      AuditService auditService,
+      RenameGroupOp.Factory renameGroupOpFactory,
+      @GerritPersonIdent PersonIdent serverIdent,
+      @Assisted @Nullable IdentifiedUser currentUser) {
+    this.groups = groups;
+    this.groupCache = groupCache;
+    this.groupIncludeCache = groupIncludeCache;
+    this.auditService = auditService;
+    this.renameGroupOpFactory = renameGroupOpFactory;
+    this.currentUser = currentUser;
+    committerIdent = getCommitterIdent(serverIdent, currentUser);
+  }
+
+  private static PersonIdent getCommitterIdent(
+      PersonIdent serverIdent, @Nullable IdentifiedUser currentUser) {
+    return currentUser != null ? createPersonIdent(serverIdent, currentUser) : serverIdent;
+  }
+
+  private static PersonIdent createPersonIdent(PersonIdent ident, IdentifiedUser user) {
+    return user.newCommitterIdent(ident.getWhen(), ident.getTimeZone());
+  }
+
+  /**
+   * Adds/Creates the specified group for the specified members (accounts).
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param group the group to add
+   * @param memberIds the IDs of the accounts which should be members of the created group
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws IOException if the cache entry of one of the new members couldn't be invalidated, or
+   *     the new group couldn't be indexed
+   */
+  public void addGroup(ReviewDb db, AccountGroup group, Set<Account.Id> memberIds)
+      throws OrmException, IOException {
+    addNewGroup(db, group);
+    addNewGroupMembers(db, group, memberIds);
+    groupCache.onCreateGroup(group);
+  }
+
+  /**
+   * Adds the specified group.
+   *
+   * <p><strong>Note</strong>: This method doesn't update the index! It just adds the group to the
+   * database. Use this method with care.
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param group the group to add
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   */
+  public static void addNewGroup(ReviewDb db, AccountGroup group) throws OrmException {
+    AccountGroupName gn = new AccountGroupName(group);
+    // first insert the group name to validate that the group name hasn't
+    // already been used to create another group
+    db.accountGroupNames().insert(ImmutableList.of(gn));
+    db.accountGroups().insert(ImmutableList.of(group));
+  }
+
+  /**
+   * Updates the specified group.
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param groupUuid the UUID of the group to update
+   * @param groupConsumer a {@code Consumer} which performs the desired updates on the group
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws IOException if the cache entry for the group couldn't be invalidated
+   * @throws NoSuchGroupException if the specified group doesn't exist
+   */
+  public void updateGroup(
+      ReviewDb db, AccountGroup.UUID groupUuid, Consumer<AccountGroup> groupConsumer)
+      throws OrmException, IOException, NoSuchGroupException {
+    AccountGroup updatedGroup = updateGroupInDb(db, groupUuid, groupConsumer);
+    groupCache.evict(updatedGroup.getGroupUUID(), updatedGroup.getId(), updatedGroup.getNameKey());
+  }
+
+  @VisibleForTesting
+  public AccountGroup updateGroupInDb(
+      ReviewDb db, AccountGroup.UUID groupUuid, Consumer<AccountGroup> groupConsumer)
+      throws OrmException, NoSuchGroupException {
+    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
+    groupConsumer.accept(group);
+    db.accountGroups().update(ImmutableList.of(group));
+    return group;
+  }
+
+  /**
+   * Renames the specified group.
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param groupUuid the UUID of the group to rename
+   * @param newName the new name of the group
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws IOException if the cache entry for the group couldn't be invalidated
+   * @throws NoSuchGroupException if the specified group doesn't exist
+   * @throws NameAlreadyUsedException if another group has the name {@code newName}
+   */
+  public void renameGroup(ReviewDb db, AccountGroup.UUID groupUuid, AccountGroup.NameKey newName)
+      throws OrmException, IOException, NameAlreadyUsedException, NoSuchGroupException {
+    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
+    AccountGroup.NameKey oldName = group.getNameKey();
+
+    try {
+      AccountGroupName id = new AccountGroupName(newName, group.getId());
+      db.accountGroupNames().insert(ImmutableList.of(id));
+    } catch (OrmException e) {
+      AccountGroupName other = db.accountGroupNames().get(newName);
+      if (other != null) {
+        // If we are using this identity, don't report the exception.
+        if (other.getId().equals(group.getId())) {
+          return;
+        }
+
+        // Otherwise, someone else has this identity.
+        throw new NameAlreadyUsedException("group with name " + newName + " already exists");
+      }
+      throw e;
+    }
+
+    group.setNameKey(newName);
+    db.accountGroups().update(ImmutableList.of(group));
+
+    db.accountGroupNames().deleteKeys(ImmutableList.of(oldName));
+
+    groupCache.evictAfterRename(oldName);
+    groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
+
+    @SuppressWarnings("unused")
+    Future<?> possiblyIgnoredError =
+        renameGroupOpFactory
+            .create(committerIdent, groupUuid, oldName.get(), newName.get())
+            .start(0, TimeUnit.MILLISECONDS);
+  }
+
+  /**
+   * Adds an account as member to a group. The account is only added as a new member if it isn't
+   * already a member of the group.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the account exists!
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param groupUuid the UUID of the group
+   * @param accountId the ID of the account to add
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws IOException if the cache entry of the new member couldn't be invalidated
+   * @throws NoSuchGroupException if the specified group doesn't exist
+   */
+  public void addGroupMember(ReviewDb db, AccountGroup.UUID groupUuid, Account.Id accountId)
+      throws OrmException, IOException, NoSuchGroupException {
+    addGroupMembers(db, groupUuid, ImmutableSet.of(accountId));
+  }
+
+  /**
+   * Adds several accounts as members to a group. Only accounts which currently aren't members of
+   * the group are added.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the accounts exist!
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param groupUuid the UUID of the group
+   * @param accountIds a set of IDs of accounts to add
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws IOException if the group or one of the new members couldn't be indexed
+   * @throws NoSuchGroupException if the specified group doesn't exist
+   */
+  public void addGroupMembers(ReviewDb db, AccountGroup.UUID groupUuid, Set<Account.Id> accountIds)
+      throws OrmException, IOException, NoSuchGroupException {
+    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
+    Set<Account.Id> newMemberIds = new HashSet<>();
+    for (Account.Id accountId : accountIds) {
+      boolean isMember = groups.isMember(db, groupUuid, accountId);
+      if (!isMember) {
+        newMemberIds.add(accountId);
+      }
+    }
+
+    if (newMemberIds.isEmpty()) {
+      return;
+    }
+
+    addNewGroupMembers(db, group, newMemberIds);
+  }
+
+  private void addNewGroupMembers(ReviewDb db, AccountGroup group, Set<Account.Id> newMemberIds)
+      throws OrmException, IOException {
+    Set<AccountGroupMember> newMembers =
+        newMemberIds
+            .stream()
+            .map(accountId -> new AccountGroupMember.Key(accountId, group.getId()))
+            .map(AccountGroupMember::new)
+            .collect(toImmutableSet());
+
+    if (currentUser != null) {
+      auditService.dispatchAddAccountsToGroup(currentUser.getAccountId(), newMembers);
+    }
+    db.accountGroupMembers().insert(newMembers);
+    groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
+    for (AccountGroupMember newMember : newMembers) {
+      groupIncludeCache.evictGroupsWithMember(newMember.getAccountId());
+    }
+  }
+
+  /**
+   * Removes several members (accounts) from a group. Only accounts which currently are members of
+   * the group are removed.
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param groupUuid the UUID of the group
+   * @param accountIds a set of IDs of accounts to remove
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws IOException if the group or one of the removed members couldn't be indexed
+   * @throws NoSuchGroupException if the specified group doesn't exist
+   */
+  public void removeGroupMembers(
+      ReviewDb db, AccountGroup.UUID groupUuid, Set<Account.Id> accountIds)
+      throws OrmException, IOException, NoSuchGroupException {
+    AccountGroup group = getExistingGroupFromReviewDb(db, groupUuid);
+    AccountGroup.Id groupId = group.getId();
+    Set<AccountGroupMember> membersToRemove = new HashSet<>();
+    for (Account.Id accountId : accountIds) {
+      boolean isMember = groups.isMember(db, groupUuid, accountId);
+      if (isMember) {
+        AccountGroupMember.Key key = new AccountGroupMember.Key(accountId, groupId);
+        membersToRemove.add(new AccountGroupMember(key));
+      }
+    }
+
+    if (membersToRemove.isEmpty()) {
+      return;
+    }
+
+    if (currentUser != null) {
+      auditService.dispatchDeleteAccountsFromGroup(currentUser.getAccountId(), membersToRemove);
+    }
+    db.accountGroupMembers().delete(membersToRemove);
+    groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
+    for (AccountGroupMember member : membersToRemove) {
+      groupIncludeCache.evictGroupsWithMember(member.getAccountId());
+    }
+  }
+
+  /**
+   * Adds several groups as subgroups to a group. Only groups which currently aren't subgroups of
+   * the group are added.
+   *
+   * <p>The parent group must be an internal group whereas the subgroups can either be internal or
+   * external groups.
+   *
+   * <p><strong>Note</strong>: This method doesn't check whether the subgroups exist!
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param parentGroupUuid the UUID of the parent group
+   * @param subgroupUuids a set of IDs of the groups to add as subgroups
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws IOException if the parent group couldn't be indexed
+   * @throws NoSuchGroupException if the specified parent group doesn't exist
+   */
+  public void addSubgroups(
+      ReviewDb db, AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> subgroupUuids)
+      throws OrmException, NoSuchGroupException, IOException {
+    AccountGroup parentGroup = getExistingGroupFromReviewDb(db, parentGroupUuid);
+    AccountGroup.Id parentGroupId = parentGroup.getId();
+    Set<AccountGroupById> newSubgroups = new HashSet<>();
+    for (AccountGroup.UUID includedGroupUuid : subgroupUuids) {
+      boolean isSubgroup = groups.isSubgroup(db, parentGroupUuid, includedGroupUuid);
+      if (!isSubgroup) {
+        AccountGroupById.Key key = new AccountGroupById.Key(parentGroupId, includedGroupUuid);
+        newSubgroups.add(new AccountGroupById(key));
+      }
+    }
+
+    if (newSubgroups.isEmpty()) {
+      return;
+    }
+
+    if (currentUser != null) {
+      auditService.dispatchAddGroupsToGroup(currentUser.getAccountId(), newSubgroups);
+    }
+    db.accountGroupById().insert(newSubgroups);
+    groupCache.evict(parentGroup.getGroupUUID(), parentGroup.getId(), parentGroup.getNameKey());
+    for (AccountGroupById newIncludedGroup : newSubgroups) {
+      groupIncludeCache.evictParentGroupsOf(newIncludedGroup.getIncludeUUID());
+    }
+    groupIncludeCache.evictSubgroupsOf(parentGroupUuid);
+  }
+
+  /**
+   * Removes several subgroups from a parent group. Only groups which currently are subgroups of the
+   * group are removed.
+   *
+   * <p>The parent group must be an internal group whereas the subgroups can either be internal or
+   * external groups.
+   *
+   * @param db the {@code ReviewDb} instance to update
+   * @param parentGroupUuid the UUID of the parent group
+   * @param subgroupUuids a set of IDs of the subgroups to remove from the parent group
+   * @throws OrmException if an error occurs while reading/writing from/to ReviewDb
+   * @throws IOException if the parent group couldn't be indexed
+   * @throws NoSuchGroupException if the specified parent group doesn't exist
+   */
+  public void removeSubgroups(
+      ReviewDb db, AccountGroup.UUID parentGroupUuid, Set<AccountGroup.UUID> subgroupUuids)
+      throws OrmException, NoSuchGroupException, IOException {
+    AccountGroup parentGroup = getExistingGroupFromReviewDb(db, parentGroupUuid);
+    AccountGroup.Id parentGroupId = parentGroup.getId();
+    Set<AccountGroupById> subgroupsToRemove = new HashSet<>();
+    for (AccountGroup.UUID subgroupUuid : subgroupUuids) {
+      boolean isSubgroup = groups.isSubgroup(db, parentGroupUuid, subgroupUuid);
+      if (isSubgroup) {
+        AccountGroupById.Key key = new AccountGroupById.Key(parentGroupId, subgroupUuid);
+        subgroupsToRemove.add(new AccountGroupById(key));
+      }
+    }
+
+    if (subgroupsToRemove.isEmpty()) {
+      return;
+    }
+
+    if (currentUser != null) {
+      auditService.dispatchDeleteGroupsFromGroup(currentUser.getAccountId(), subgroupsToRemove);
+    }
+    db.accountGroupById().delete(subgroupsToRemove);
+    groupCache.evict(parentGroup.getGroupUUID(), parentGroup.getId(), parentGroup.getNameKey());
+    for (AccountGroupById groupToRemove : subgroupsToRemove) {
+      groupIncludeCache.evictParentGroupsOf(groupToRemove.getIncludeUUID());
+    }
+    groupIncludeCache.evictSubgroupsOf(parentGroupUuid);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupResource.java
deleted file mode 100644
index 467de4c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupResource.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.inject.TypeLiteral;
-
-public class IncludedGroupResource extends GroupResource {
-  public static final TypeLiteral<RestView<IncludedGroupResource>> INCLUDED_GROUP_KIND =
-      new TypeLiteral<RestView<IncludedGroupResource>>() {};
-
-  private final GroupDescription.Basic member;
-
-  public IncludedGroupResource(GroupResource group, GroupDescription.Basic member) {
-    super(group);
-    this.member = member;
-  }
-
-  public AccountGroup.UUID getMember() {
-    return getMemberDescription().getGroupUUID();
-  }
-
-  public GroupDescription.Basic getMemberDescription() {
-    return member;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupsCollection.java
deleted file mode 100644
index 865f8b5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/IncludedGroupsCollection.java
+++ /dev/null
@@ -1,98 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.extensions.registration.DynamicMap;
-import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ChildCollection;
-import com.google.gerrit.extensions.restapi.IdString;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
-import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.group.AddIncludedGroups.PutIncludedGroup;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-
-@Singleton
-public class IncludedGroupsCollection
-    implements ChildCollection<GroupResource, IncludedGroupResource>, AcceptsCreate<GroupResource> {
-  private final DynamicMap<RestView<IncludedGroupResource>> views;
-  private final ListIncludedGroups list;
-  private final GroupsCollection groupsCollection;
-  private final Provider<ReviewDb> dbProvider;
-  private final AddIncludedGroups put;
-
-  @Inject
-  IncludedGroupsCollection(
-      DynamicMap<RestView<IncludedGroupResource>> views,
-      ListIncludedGroups list,
-      GroupsCollection groupsCollection,
-      Provider<ReviewDb> dbProvider,
-      AddIncludedGroups put) {
-    this.views = views;
-    this.list = list;
-    this.groupsCollection = groupsCollection;
-    this.dbProvider = dbProvider;
-    this.put = put;
-  }
-
-  @Override
-  public RestView<GroupResource> list() {
-    return list;
-  }
-
-  @Override
-  public IncludedGroupResource parse(GroupResource resource, IdString id)
-      throws MethodNotAllowedException, AuthException, ResourceNotFoundException, OrmException {
-    AccountGroup parent = resource.toAccountGroup();
-    if (parent == null) {
-      throw new MethodNotAllowedException();
-    }
-
-    GroupDescription.Basic member =
-        groupsCollection.parse(TopLevelResource.INSTANCE, id).getGroup();
-    if (isMember(parent, member) && resource.getControl().canSeeGroup()) {
-      return new IncludedGroupResource(resource, member);
-    }
-    throw new ResourceNotFoundException(id);
-  }
-
-  private boolean isMember(AccountGroup parent, GroupDescription.Basic member) throws OrmException {
-    return dbProvider
-            .get()
-            .accountGroupById()
-            .get(new AccountGroupById.Key(parent.getId(), member.getGroupUUID()))
-        != null;
-  }
-
-  @SuppressWarnings("unchecked")
-  @Override
-  public PutIncludedGroup create(GroupResource group, IdString id) {
-    return new PutIncludedGroup(put, id.get());
-  }
-
-  @Override
-  public DynamicMap<RestView<IncludedGroupResource>> views() {
-    return views;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java
index b7b98b2..b61f954 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/Index.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.group;
 
-import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -25,6 +24,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.Optional;
 
 @Singleton
 public class Index implements RestModifyView<GroupResource, Input> {
@@ -44,14 +44,17 @@
       throw new AuthException("not allowed to index group");
     }
 
-    AccountGroup group = GroupDescriptions.toAccountGroup(rsrc.getGroup());
-    if (group == null) {
+    AccountGroup.UUID groupUuid = rsrc.getGroup().getGroupUUID();
+    if (!rsrc.isInternalGroup()) {
       throw new UnprocessableEntityException(
-          String.format("External Group Not Allowed: %s", rsrc.getGroupUUID().get()));
+          String.format("External Group Not Allowed: %s", groupUuid.get()));
     }
 
+    Optional<InternalGroup> group = groupCache.get(groupUuid);
     // evicting the group from the cache, reindexes the group
-    groupCache.evict(group);
+    if (group.isPresent()) {
+      groupCache.evict(group.get().getGroupUUID(), group.get().getId(), group.get().getNameKey());
+    }
     return Response.none();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroup.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroup.java
new file mode 100644
index 0000000..fafc591
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroup.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.server.group;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.io.Serializable;
+import java.sql.Timestamp;
+
+@AutoValue
+public abstract class InternalGroup implements Serializable {
+  private static final long serialVersionUID = 1L;
+
+  public static InternalGroup create(
+      AccountGroup accountGroup,
+      ImmutableSet<Account.Id> members,
+      ImmutableSet<AccountGroup.UUID> subgroups) {
+    return new AutoValue_InternalGroup(
+        accountGroup.getId(),
+        accountGroup.getNameKey(),
+        accountGroup.getDescription(),
+        accountGroup.getOwnerGroupUUID(),
+        accountGroup.isVisibleToAll(),
+        accountGroup.getGroupUUID(),
+        accountGroup.getCreatedOn(),
+        members,
+        subgroups);
+  }
+
+  public abstract AccountGroup.Id getId();
+
+  public String getName() {
+    return getNameKey().get();
+  }
+
+  public abstract AccountGroup.NameKey getNameKey();
+
+  @Nullable
+  public abstract String getDescription();
+
+  public abstract AccountGroup.UUID getOwnerGroupUUID();
+
+  public abstract boolean isVisibleToAll();
+
+  public abstract AccountGroup.UUID getGroupUUID();
+
+  public abstract Timestamp getCreatedOn();
+
+  public abstract ImmutableSet<Account.Id> getMembers();
+
+  public abstract ImmutableSet<AccountGroup.UUID> getSubgroups();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroupDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroupDescription.java
new file mode 100644
index 0000000..c5df2ff
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/InternalGroupDescription.java
@@ -0,0 +1,80 @@
+// 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.group;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import java.sql.Timestamp;
+
+public class InternalGroupDescription implements GroupDescription.Internal {
+
+  private final InternalGroup internalGroup;
+
+  public InternalGroupDescription(InternalGroup internalGroup) {
+    this.internalGroup = checkNotNull(internalGroup);
+  }
+
+  @Override
+  public AccountGroup.UUID getGroupUUID() {
+    return internalGroup.getGroupUUID();
+  }
+
+  @Override
+  public String getName() {
+    return internalGroup.getName();
+  }
+
+  @Nullable
+  @Override
+  public String getEmailAddress() {
+    return null;
+  }
+
+  @Nullable
+  @Override
+  public String getUrl() {
+    return "#" + PageLinks.toGroup(getGroupUUID());
+  }
+
+  @Override
+  public AccountGroup.Id getId() {
+    return internalGroup.getId();
+  }
+
+  @Override
+  @Nullable
+  public String getDescription() {
+    return internalGroup.getDescription();
+  }
+
+  @Override
+  public AccountGroup.UUID getOwnerGroupUUID() {
+    return internalGroup.getOwnerGroupUUID();
+  }
+
+  @Override
+  public boolean isVisibleToAll() {
+    return internalGroup.isVisibleToAll();
+  }
+
+  @Override
+  public Timestamp getCreatedOn() {
+    return internalGroup.getCreatedOn();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
index 7b55a80..480139d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListGroups.java
@@ -14,10 +14,13 @@
 
 package com.google.gerrit.server.group;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.data.GroupReference;
@@ -30,33 +33,37 @@
 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.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.GetGroups;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupComparator;
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import 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.HashSet;
 import java.util.List;
 import java.util.Locale;
-import java.util.Map;
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
 import org.kohsuke.args4j.Option;
 
 /** List groups visible to the calling user. */
 public class ListGroups implements RestReadView<TopLevelResource> {
+  private static final Comparator<GroupDescription.Internal> GROUP_COMPARATOR =
+      Comparator.comparing(GroupDescription.Basic::getName);
 
   protected final GroupCache groupCache;
 
@@ -69,6 +76,8 @@
   private final GetGroups accountGetGroups;
   private final GroupJson json;
   private final GroupBackend groupBackend;
+  private final Groups groups;
+  private final Provider<ReviewDb> db;
 
   private EnumSet<ListGroupsOption> options = EnumSet.noneOf(ListGroupsOption.class);
   private boolean visibleToAll;
@@ -77,6 +86,7 @@
   private int limit;
   private int start;
   private String matchSubstring;
+  private String matchRegex;
   private String suggest;
 
   @Option(
@@ -162,6 +172,15 @@
   }
 
   @Option(
+      name = "--regex",
+      aliases = {"-r"},
+      metaVar = "REGEX",
+      usage = "match group regex")
+  public void setMatchRegex(String matchRegex) {
+    this.matchRegex = matchRegex;
+  }
+
+  @Option(
       name = "--suggest",
       aliases = {"-s"},
       usage = "to get a suggestion of groups")
@@ -188,7 +207,9 @@
       final IdentifiedUser.GenericFactory userFactory,
       final GetGroups accountGetGroups,
       GroupJson json,
-      GroupBackend groupBackend) {
+      GroupBackend groupBackend,
+      Groups groups,
+      Provider<ReviewDb> db) {
     this.groupCache = groupCache;
     this.groupControlFactory = groupControlFactory;
     this.genericGroupControlFactory = genericGroupControlFactory;
@@ -197,6 +218,8 @@
     this.accountGetGroups = accountGetGroups;
     this.json = json;
     this.groupBackend = groupBackend;
+    this.groups = groups;
+    this.db = db;
   }
 
   public void setOptions(EnumSet<ListGroupsOption> options) {
@@ -227,6 +250,10 @@
       return suggestGroups();
     }
 
+    if (!Strings.isNullOrEmpty(matchSubstring) && !Strings.isNullOrEmpty(matchRegex)) {
+      throw new BadRequestException("Specify one of m/r");
+    }
+
     if (owned) {
       return getGroupsOwnedBy(user != null ? userFactory.create(user) : identifiedUser.get());
     }
@@ -239,52 +266,54 @@
   }
 
   private List<GroupInfo> getAllGroups() throws OrmException {
-    List<GroupInfo> groupInfos;
-    List<AccountGroup> groupList;
-    if (!projects.isEmpty()) {
-      Map<AccountGroup.UUID, AccountGroup> groups = new HashMap<>();
-      for (final ProjectControl projectControl : projects) {
-        final Set<GroupReference> groupsRefs = projectControl.getAllGroups();
-        for (final GroupReference groupRef : groupsRefs) {
-          final AccountGroup group = groupCache.get(groupRef.getUUID());
-          if (group != null) {
-            groups.put(group.getGroupUUID(), group);
-          }
-        }
-      }
-      groupList = filterGroups(groups.values());
-    } else {
-      groupList = filterGroups(groupCache.all());
+    Pattern pattern = getRegexPattern();
+    Stream<GroupDescription.Internal> existingGroups =
+        getAllExistingGroups()
+            .filter(group -> !isNotRelevant(pattern, group))
+            .sorted(GROUP_COMPARATOR)
+            .skip(start);
+    if (limit > 0) {
+      existingGroups = existingGroups.limit(limit);
     }
-    groupInfos = Lists.newArrayListWithCapacity(groupList.size());
-    int found = 0;
-    int foundIndex = 0;
-    for (AccountGroup group : groupList) {
-      if (foundIndex++ < start) {
-        continue;
-      }
-      if (limit > 0 && ++found > limit) {
-        break;
-      }
-      groupInfos.add(json.addOptions(options).format(GroupDescriptions.forAccountGroup(group)));
+    List<GroupDescription.Internal> relevantGroups = existingGroups.collect(toImmutableList());
+    List<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(relevantGroups.size());
+    for (GroupDescription.Internal group : relevantGroups) {
+      groupInfos.add(json.addOptions(options).format(group));
     }
     return groupInfos;
   }
 
+  private Stream<GroupDescription.Internal> getAllExistingGroups() throws OrmException {
+    if (!projects.isEmpty()) {
+      return projects
+          .stream()
+          .map(ProjectControl::getProjectState)
+          .map(ProjectState::getAllGroups)
+          .flatMap(Collection::stream)
+          .map(GroupReference::getUUID)
+          .distinct()
+          .map(groupCache::get)
+          .flatMap(Streams::stream)
+          .map(InternalGroupDescription::new);
+    }
+    return groups.getAll(db.get()).map(GroupDescriptions::forAccountGroup);
+  }
+
   private List<GroupInfo> suggestGroups() throws OrmException, BadRequestException {
     if (conflictingSuggestParameters()) {
       throw new BadRequestException(
           "You should only have no more than one --project and -n with --suggest");
     }
-
     List<GroupReference> groupRefs =
         Lists.newArrayList(
             Iterables.limit(
-                groupBackend.suggest(suggest, Iterables.getFirst(projects, null)),
+                groupBackend.suggest(
+                    suggest,
+                    projects.stream().findFirst().map(pc -> pc.getProjectState()).orElse(null)),
                 limit <= 0 ? 10 : Math.min(limit, 10)));
 
     List<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groupRefs.size());
-    for (final GroupReference ref : groupRefs) {
+    for (GroupReference ref : groupRefs) {
       GroupDescription.Basic desc = groupBackend.get(ref.getUUID());
       if (desc != null) {
         groupInfos.add(json.addOptions(options).format(desc));
@@ -318,59 +347,63 @@
     if (!Strings.isNullOrEmpty(matchSubstring)) {
       return true;
     }
+    if (!Strings.isNullOrEmpty(matchRegex)) {
+      return true;
+    }
     return false;
   }
 
   private List<GroupInfo> getGroupsOwnedBy(IdentifiedUser user) throws OrmException {
-    List<GroupInfo> groups = new ArrayList<>();
-    int found = 0;
-    int foundIndex = 0;
-    for (AccountGroup g : filterGroups(groupCache.all())) {
-      GroupControl ctl = groupControlFactory.controlFor(g);
-      try {
-        if (genericGroupControlFactory.controlFor(user, g.getGroupUUID()).isOwner()) {
-          if (foundIndex++ < start) {
-            continue;
-          }
-          if (limit > 0 && ++found > limit) {
-            break;
-          }
-          groups.add(json.addOptions(options).format(ctl.getGroup()));
-        }
-      } catch (NoSuchGroupException e) {
-        continue;
-      }
+    Pattern pattern = getRegexPattern();
+    Stream<GroupDescription.Internal> foundGroups =
+        groups
+            .getAll(db.get())
+            .map(GroupDescriptions::forAccountGroup)
+            .filter(group -> !isNotRelevant(pattern, group))
+            .filter(group -> isOwner(user, group))
+            .sorted(GROUP_COMPARATOR)
+            .skip(start);
+    if (limit > 0) {
+      foundGroups = foundGroups.limit(limit);
     }
-    return groups;
+    List<GroupDescription.Internal> ownedGroups = foundGroups.collect(toImmutableList());
+    List<GroupInfo> groupInfos = new ArrayList<>(ownedGroups.size());
+    for (GroupDescription.Internal group : ownedGroups) {
+      groupInfos.add(json.addOptions(options).format(group));
+    }
+    return groupInfos;
   }
 
-  private List<AccountGroup> filterGroups(Collection<AccountGroup> groups) {
-    List<AccountGroup> filteredGroups = new ArrayList<>(groups.size());
-    boolean isAdmin = identifiedUser.get().getCapabilities().canAdministrateServer();
-    for (AccountGroup group : groups) {
-      if (!Strings.isNullOrEmpty(matchSubstring)) {
-        if (!group
-            .getName()
-            .toLowerCase(Locale.US)
-            .contains(matchSubstring.toLowerCase(Locale.US))) {
-          continue;
-        }
-      }
-      if (visibleToAll && !group.isVisibleToAll()) {
-        continue;
-      }
-      if (!groupsToInspect.isEmpty() && !groupsToInspect.contains(group.getGroupUUID())) {
-        continue;
-      }
-      if (!isAdmin) {
-        GroupControl c = groupControlFactory.controlFor(group);
-        if (!c.isVisible()) {
-          continue;
-        }
-      }
-      filteredGroups.add(group);
+  private boolean isOwner(CurrentUser user, GroupDescription.Internal group) {
+    try {
+      return genericGroupControlFactory.controlFor(user, group.getGroupUUID()).isOwner();
+    } catch (NoSuchGroupException e) {
+      return false;
     }
-    Collections.sort(filteredGroups, new GroupComparator());
-    return filteredGroups;
+  }
+
+  private Pattern getRegexPattern() {
+    return Strings.isNullOrEmpty(matchRegex) ? null : Pattern.compile(matchRegex);
+  }
+
+  private boolean isNotRelevant(Pattern pattern, GroupDescription.Internal group) {
+    if (!Strings.isNullOrEmpty(matchSubstring)) {
+      if (!group.getName().toLowerCase(Locale.US).contains(matchSubstring.toLowerCase(Locale.US))) {
+        return true;
+      }
+    } else if (pattern != null) {
+      if (!pattern.matcher(group.getName()).matches()) {
+        return true;
+      }
+    }
+    if (visibleToAll && !group.isVisibleToAll()) {
+      return true;
+    }
+    if (!groupsToInspect.isEmpty() && !groupsToInspect.contains(group.getGroupUUID())) {
+      return true;
+    }
+
+    GroupControl c = groupControlFactory.controlFor(group);
+    return !c.isVisible();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java
deleted file mode 100644
index 66b2edb..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListIncludedGroups.java
+++ /dev/null
@@ -1,90 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.group;
-
-import static com.google.common.base.Strings.nullToEmpty;
-
-import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-@Singleton
-public class ListIncludedGroups implements RestReadView<GroupResource> {
-  private static final Logger log = LoggerFactory.getLogger(ListIncludedGroups.class);
-
-  private final GroupControl.Factory controlFactory;
-  private final Provider<ReviewDb> dbProvider;
-  private final GroupJson json;
-
-  @Inject
-  ListIncludedGroups(
-      GroupControl.Factory controlFactory, Provider<ReviewDb> dbProvider, GroupJson json) {
-    this.controlFactory = controlFactory;
-    this.dbProvider = dbProvider;
-    this.json = json;
-  }
-
-  @Override
-  public List<GroupInfo> apply(GroupResource rsrc) throws MethodNotAllowedException, OrmException {
-    if (rsrc.toAccountGroup() == null) {
-      throw new MethodNotAllowedException();
-    }
-
-    boolean ownerOfParent = rsrc.getControl().isOwner();
-    List<GroupInfo> included = new ArrayList<>();
-    for (AccountGroupById u :
-        dbProvider.get().accountGroupById().byGroup(rsrc.toAccountGroup().getId())) {
-      try {
-        GroupControl i = controlFactory.controlFor(u.getIncludeUUID());
-        if (ownerOfParent || i.isVisible()) {
-          included.add(json.format(i.getGroup()));
-        }
-      } catch (NoSuchGroupException notFound) {
-        log.warn(
-            "Group {} no longer available, included into {}",
-            u.getIncludeUUID(),
-            rsrc.getGroup().getName());
-        continue;
-      }
-    }
-    Collections.sort(
-        included,
-        new Comparator<GroupInfo>() {
-          @Override
-          public int compare(GroupInfo a, GroupInfo b) {
-            int cmp = nullToEmpty(a.name).compareTo(nullToEmpty(b.name));
-            if (cmp != 0) {
-              return cmp;
-            }
-            return nullToEmpty(a.id).compareTo(nullToEmpty(b.id));
-          }
-        });
-    return included;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
index 8e2c925..af988b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListMembers.java
@@ -14,32 +14,32 @@
 
 package com.google.gerrit.server.group;
 
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.GroupDetail;
-import com.google.gerrit.common.errors.NoSuchGroupException;
+import static com.google.common.collect.ImmutableSet.toImmutableSet;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupById;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupDetailFactory;
+import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.api.accounts.AccountInfoComparator;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import java.util.Collections;
-import java.util.HashMap;
+import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
 import org.kohsuke.args4j.Option;
 
 public class ListMembers implements RestReadView<GroupResource> {
   private final GroupCache groupCache;
-  private final GroupDetailFactory.Factory groupDetailFactory;
+  private final GroupControl.Factory groupControlFactory;
   private final AccountLoader accountLoader;
 
   @Option(name = "--recursive", usage = "to resolve included groups recursively")
@@ -48,10 +48,10 @@
   @Inject
   protected ListMembers(
       GroupCache groupCache,
-      GroupDetailFactory.Factory groupDetailFactory,
+      GroupControl.Factory groupControlFactory,
       AccountLoader.Factory accountLoaderFactory) {
     this.groupCache = groupCache;
-    this.groupDetailFactory = groupDetailFactory;
+    this.groupControlFactory = groupControlFactory;
     this.accountLoader = accountLoaderFactory.create(true);
   }
 
@@ -61,65 +61,47 @@
   }
 
   @Override
-  public List<AccountInfo> apply(final GroupResource resource)
+  public List<AccountInfo> apply(GroupResource resource)
       throws MethodNotAllowedException, OrmException {
-    if (resource.toAccountGroup() == null) {
-      throw new MethodNotAllowedException();
-    }
-
-    return apply(resource.getGroupUUID());
-  }
-
-  public List<AccountInfo> apply(AccountGroup group) throws OrmException {
+    GroupDescription.Internal group =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
     return apply(group.getGroupUUID());
   }
 
   public List<AccountInfo> apply(AccountGroup.UUID groupId) throws OrmException {
-    final Map<Account.Id, AccountInfo> members =
-        getMembers(groupId, new HashSet<AccountGroup.UUID>());
-    final List<AccountInfo> memberInfos = Lists.newArrayList(members.values());
-    Collections.sort(memberInfos, AccountInfoComparator.ORDER_NULLS_FIRST);
+    Set<Account.Id> members = getMembers(groupId, new HashSet<>());
+    List<AccountInfo> memberInfos = new ArrayList<>(members.size());
+    for (Account.Id member : members) {
+      memberInfos.add(accountLoader.get(member));
+    }
+    accountLoader.fill();
+    memberInfos.sort(AccountInfoComparator.ORDER_NULLS_FIRST);
     return memberInfos;
   }
 
-  private Map<Account.Id, AccountInfo> getMembers(
-      final AccountGroup.UUID groupUUID, final HashSet<AccountGroup.UUID> seenGroups)
-      throws OrmException {
+  private Set<Account.Id> getMembers(
+      AccountGroup.UUID groupUUID, HashSet<AccountGroup.UUID> seenGroups) {
     seenGroups.add(groupUUID);
 
-    final Map<Account.Id, AccountInfo> members = new HashMap<>();
-    final AccountGroup group = groupCache.get(groupUUID);
-    if (group == null) {
-      // the included group is an external group and can't be resolved
-      return Collections.emptyMap();
+    Optional<InternalGroup> internalGroup = groupCache.get(groupUUID);
+    if (!internalGroup.isPresent()) {
+      return ImmutableSet.of();
     }
+    InternalGroup group = internalGroup.get();
 
-    final GroupDetail groupDetail;
-    try {
-      groupDetail = groupDetailFactory.create(group.getId()).call();
-    } catch (NoSuchGroupException e) {
-      // the included group is not visible
-      return Collections.emptyMap();
-    }
+    GroupControl groupControl = groupControlFactory.controlFor(new InternalGroupDescription(group));
 
-    if (groupDetail.members != null) {
-      for (final AccountGroupMember m : groupDetail.members) {
-        if (!members.containsKey(m.getAccountId())) {
-          members.put(m.getAccountId(), accountLoader.get(m.getAccountId()));
+    Set<Account.Id> directMembers =
+        group.getMembers().stream().filter(groupControl::canSeeMember).collect(toImmutableSet());
+
+    Set<Account.Id> indirectMembers = new HashSet<>();
+    if (recursive && groupControl.canSeeGroup()) {
+      for (AccountGroup.UUID subgroupUuid : group.getSubgroups()) {
+        if (!seenGroups.contains(subgroupUuid)) {
+          indirectMembers.addAll(getMembers(subgroupUuid, seenGroups));
         }
       }
     }
-
-    if (recursive) {
-      if (groupDetail.includes != null) {
-        for (final AccountGroupById includedGroup : groupDetail.includes) {
-          if (!seenGroups.contains(includedGroup.getIncludeUUID())) {
-            members.putAll(getMembers(includedGroup.getIncludeUUID(), seenGroups));
-          }
-        }
-      }
-    }
-    accountLoader.fill();
-    return members;
+    return Sets.union(directMembers, indirectMembers);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ListSubgroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListSubgroups.java
new file mode 100644
index 0000000..70eefe7
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ListSubgroups.java
@@ -0,0 +1,88 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import static com.google.common.base.Strings.nullToEmpty;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.account.GroupIncludeCache;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class ListSubgroups implements RestReadView<GroupResource> {
+  private static final Logger log = LoggerFactory.getLogger(ListSubgroups.class);
+
+  private final GroupControl.Factory controlFactory;
+  private final GroupIncludeCache groupIncludeCache;
+  private final GroupJson json;
+
+  @Inject
+  ListSubgroups(
+      GroupControl.Factory controlFactory, GroupIncludeCache groupIncludeCache, GroupJson json) {
+    this.controlFactory = controlFactory;
+    this.groupIncludeCache = groupIncludeCache;
+    this.json = json;
+  }
+
+  @Override
+  public List<GroupInfo> apply(GroupResource rsrc) throws MethodNotAllowedException, OrmException {
+    GroupDescription.Internal group =
+        rsrc.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+
+    boolean ownerOfParent = rsrc.getControl().isOwner();
+    List<GroupInfo> included = new ArrayList<>();
+    Collection<AccountGroup.UUID> subgroupUuids =
+        groupIncludeCache.subgroupsOf(group.getGroupUUID());
+    for (AccountGroup.UUID subgroupUuid : subgroupUuids) {
+      try {
+        GroupControl i = controlFactory.controlFor(subgroupUuid);
+        if (ownerOfParent || i.isVisible()) {
+          included.add(json.format(i.getGroup()));
+        }
+      } catch (NoSuchGroupException notFound) {
+        log.warn("Group {} no longer available, subgroup of {}", subgroupUuid, group.getName());
+        continue;
+      }
+    }
+    Collections.sort(
+        included,
+        new Comparator<GroupInfo>() {
+          @Override
+          public int compare(GroupInfo a, GroupInfo b) {
+            int cmp = nullToEmpty(a.name).compareTo(nullToEmpty(b.name));
+            if (cmp != 0) {
+              return cmp;
+            }
+            return nullToEmpty(a.id).compareTo(nullToEmpty(b.id));
+          }
+        });
+    return included;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java
index 8f4d65e..fdfb413 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/MembersCollection.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.group;
 
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -23,7 +25,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountsCollection;
@@ -32,6 +34,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class MembersCollection
@@ -39,6 +43,7 @@
   private final DynamicMap<RestView<MemberResource>> views;
   private final Provider<ListMembers> list;
   private final AccountsCollection accounts;
+  private final Groups groups;
   private final Provider<ReviewDb> db;
   private final AddMembers put;
 
@@ -47,11 +52,13 @@
       DynamicMap<RestView<MemberResource>> views,
       Provider<ListMembers> list,
       AccountsCollection accounts,
+      Groups groups,
       Provider<ReviewDb> db,
       AddMembers put) {
     this.views = views;
     this.list = list;
     this.accounts = accounts;
+    this.groups = groups;
     this.db = db;
     this.put = put;
   }
@@ -63,22 +70,28 @@
 
   @Override
   public MemberResource parse(GroupResource parent, IdString id)
-      throws MethodNotAllowedException, AuthException, ResourceNotFoundException, OrmException {
-    if (parent.toAccountGroup() == null) {
-      throw new MethodNotAllowedException();
-    }
+      throws MethodNotAllowedException, AuthException, ResourceNotFoundException, OrmException,
+          IOException, ConfigInvalidException {
+    GroupDescription.Internal group =
+        parent.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
 
     IdentifiedUser user = accounts.parse(TopLevelResource.INSTANCE, id).getUser();
-    AccountGroupMember.Key key =
-        new AccountGroupMember.Key(user.getAccountId(), parent.toAccountGroup().getId());
-    if (db.get().accountGroupMembers().get(key) != null
-        && parent.getControl().canSeeMember(user.getAccountId())) {
+    if (parent.getControl().canSeeMember(user.getAccountId()) && isMember(group, user)) {
       return new MemberResource(parent, user);
     }
     throw new ResourceNotFoundException(id);
   }
 
-  @SuppressWarnings("unchecked")
+  private boolean isMember(GroupDescription.Internal group, IdentifiedUser user)
+      throws OrmException, ResourceNotFoundException {
+    AccountGroup.UUID groupUuid = group.getGroupUUID();
+    try {
+      return groups.isMember(db.get(), groupUuid, user.getAccountId());
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+    }
+  }
+
   @Override
   public PutMember create(GroupResource group, IdString id) {
     return new PutMember(put, id.get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java
index 366cc4d..5006914 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/Module.java
@@ -15,17 +15,19 @@
 package com.google.gerrit.server.group;
 
 import static com.google.gerrit.server.group.GroupResource.GROUP_KIND;
-import static com.google.gerrit.server.group.IncludedGroupResource.INCLUDED_GROUP_KIND;
 import static com.google.gerrit.server.group.MemberResource.MEMBER_KIND;
+import static com.google.gerrit.server.group.SubgroupResource.SUBGROUP_KIND;
 
 import com.google.gerrit.audit.GroupMemberAuditListener;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestApiModule;
-import com.google.gerrit.server.group.AddIncludedGroups.UpdateIncludedGroup;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.group.AddMembers.UpdateMember;
-import com.google.gerrit.server.group.DeleteIncludedGroups.DeleteIncludedGroup;
+import com.google.gerrit.server.group.AddSubgroups.UpdateSubgroup;
 import com.google.gerrit.server.group.DeleteMembers.DeleteMember;
+import com.google.gerrit.server.group.DeleteSubgroups.DeleteSubgroup;
+import com.google.inject.Provides;
 
 public class Module extends RestApiModule {
   @Override
@@ -34,7 +36,7 @@
 
     DynamicMap.mapOf(binder(), GROUP_KIND);
     DynamicMap.mapOf(binder(), MEMBER_KIND);
-    DynamicMap.mapOf(binder(), INCLUDED_GROUP_KIND);
+    DynamicMap.mapOf(binder(), SUBGROUP_KIND);
 
     get(GROUP_KIND).to(GetGroup.class);
     put(GROUP_KIND).to(PutGroup.class);
@@ -43,9 +45,9 @@
     post(GROUP_KIND, "members").to(AddMembers.class);
     post(GROUP_KIND, "members.add").to(AddMembers.class);
     post(GROUP_KIND, "members.delete").to(DeleteMembers.class);
-    post(GROUP_KIND, "groups").to(AddIncludedGroups.class);
-    post(GROUP_KIND, "groups.add").to(AddIncludedGroups.class);
-    post(GROUP_KIND, "groups.delete").to(DeleteIncludedGroups.class);
+    post(GROUP_KIND, "groups").to(AddSubgroups.class);
+    post(GROUP_KIND, "groups.add").to(AddSubgroups.class);
+    post(GROUP_KIND, "groups.delete").to(DeleteSubgroups.class);
     get(GROUP_KIND, "description").to(GetDescription.class);
     put(GROUP_KIND, "description").to(PutDescription.class);
     delete(GROUP_KIND, "description").to(PutDescription.class);
@@ -62,13 +64,27 @@
     put(MEMBER_KIND).to(UpdateMember.class);
     delete(MEMBER_KIND).to(DeleteMember.class);
 
-    child(GROUP_KIND, "groups").to(IncludedGroupsCollection.class);
-    get(INCLUDED_GROUP_KIND).to(GetIncludedGroup.class);
-    put(INCLUDED_GROUP_KIND).to(UpdateIncludedGroup.class);
-    delete(INCLUDED_GROUP_KIND).to(DeleteIncludedGroup.class);
+    child(GROUP_KIND, "groups").to(SubgroupsCollection.class);
+    get(SUBGROUP_KIND).to(GetSubgroup.class);
+    put(SUBGROUP_KIND).to(UpdateSubgroup.class);
+    delete(SUBGROUP_KIND).to(DeleteSubgroup.class);
 
     factory(CreateGroup.Factory.class);
+    factory(GroupsUpdate.Factory.class);
 
     DynamicSet.bind(binder(), GroupMemberAuditListener.class).to(DbGroupMemberAuditListener.class);
   }
+
+  @Provides
+  @ServerInitiated
+  GroupsUpdate provideServerInitiatedGroupsUpdate(GroupsUpdate.Factory groupsUpdateFactory) {
+    return groupsUpdateFactory.create(null);
+  }
+
+  @Provides
+  @UserInitiated
+  GroupsUpdate provideUserInitiatedGroupsUpdate(
+      GroupsUpdate.Factory groupsUpdateFactory, IdentifiedUser currentUser) {
+    return groupsUpdateFactory.create(currentUser);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
index b04da91..3d6feea 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutDescription.java
@@ -15,6 +15,8 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.base.Strings;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
@@ -23,14 +25,13 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.PutDescription.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
+import java.util.Objects;
 
 @Singleton
 public class PutDescription implements RestModifyView<GroupResource, Input> {
@@ -38,13 +39,14 @@
     @DefaultInput public String description;
   }
 
-  private final GroupCache groupCache;
   private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Inject
-  PutDescription(GroupCache groupCache, Provider<ReviewDb> db) {
-    this.groupCache = groupCache;
+  PutDescription(
+      Provider<ReviewDb> db, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
     this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
   }
 
   @Override
@@ -55,21 +57,24 @@
       input = new Input(); // Delete would set description to null.
     }
 
-    if (resource.toAccountGroup() == null) {
-      throw new MethodNotAllowedException();
-    } else if (!resource.getControl().isOwner()) {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (!resource.getControl().isOwner()) {
       throw new AuthException("Not group owner");
     }
 
-    AccountGroup group = db.get().accountGroups().get(resource.toAccountGroup().getId());
-    if (group == null) {
-      throw new ResourceNotFoundException();
+    String newDescription = Strings.emptyToNull(input.description);
+    if (!Objects.equals(internalGroup.getDescription(), newDescription)) {
+      AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+      try {
+        groupsUpdateProvider
+            .get()
+            .updateGroup(db.get(), groupUuid, group -> group.setDescription(newDescription));
+      } catch (NoSuchGroupException e) {
+        throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+      }
     }
 
-    group.setDescription(Strings.emptyToNull(input.description));
-    db.get().accountGroups().update(Collections.singleton(group));
-    groupCache.evict(group);
-
     return Strings.isNullOrEmpty(input.description)
         ? Response.<String>none()
         : Response.ok(input.description);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
index b78f4a5..75a7eb5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutName.java
@@ -15,32 +15,24 @@
 package com.google.gerrit.server.group;
 
 import com.google.common.base.Strings;
-import com.google.gerrit.common.data.GroupDetail;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NameAlreadyUsedException;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupDetailFactory;
-import com.google.gerrit.server.git.RenameGroupOp;
 import com.google.gerrit.server.group.PutName.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
-import java.util.Date;
-import java.util.TimeZone;
-import java.util.concurrent.Future;
-import java.util.concurrent.TimeUnit;
 
 @Singleton
 public class PutName implements RestModifyView<GroupResource, Input> {
@@ -49,32 +41,21 @@
   }
 
   private final Provider<ReviewDb> db;
-  private final GroupCache groupCache;
-  private final GroupDetailFactory.Factory groupDetailFactory;
-  private final RenameGroupOp.Factory renameGroupOpFactory;
-  private final Provider<IdentifiedUser> currentUser;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Inject
-  PutName(
-      Provider<ReviewDb> db,
-      GroupCache groupCache,
-      GroupDetailFactory.Factory groupDetailFactory,
-      RenameGroupOp.Factory renameGroupOpFactory,
-      Provider<IdentifiedUser> currentUser) {
+  PutName(Provider<ReviewDb> db, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
     this.db = db;
-    this.groupCache = groupCache;
-    this.groupDetailFactory = groupDetailFactory;
-    this.renameGroupOpFactory = renameGroupOpFactory;
-    this.currentUser = currentUser;
+    this.groupsUpdateProvider = groupsUpdateProvider;
   }
 
   @Override
   public String apply(GroupResource rsrc, Input input)
       throws MethodNotAllowedException, AuthException, BadRequestException,
-          ResourceConflictException, OrmException, NoSuchGroupException, IOException {
-    if (rsrc.toAccountGroup() == null) {
-      throw new MethodNotAllowedException();
-    } else if (!rsrc.getControl().isOwner()) {
+          ResourceConflictException, ResourceNotFoundException, OrmException, IOException {
+    GroupDescription.Internal internalGroup =
+        rsrc.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (!rsrc.getControl().isOwner()) {
       throw new AuthException("Not group owner");
     } else if (input == null || Strings.isNullOrEmpty(input.name)) {
       throw new BadRequestException("name is required");
@@ -84,58 +65,25 @@
       throw new BadRequestException("name is required");
     }
 
-    if (rsrc.toAccountGroup().getName().equals(newName)) {
+    if (internalGroup.getName().equals(newName)) {
       return newName;
     }
 
-    return renameGroup(rsrc.toAccountGroup(), newName).group.getName();
+    renameGroup(internalGroup, newName);
+    return newName;
   }
 
-  private GroupDetail renameGroup(AccountGroup group, String newName)
-      throws ResourceConflictException, OrmException, NoSuchGroupException, IOException {
-    AccountGroup.Id groupId = group.getId();
-    AccountGroup.NameKey old = group.getNameKey();
-    AccountGroup.NameKey key = new AccountGroup.NameKey(newName);
-
+  private void renameGroup(GroupDescription.Internal group, String newName)
+      throws ResourceConflictException, ResourceNotFoundException, OrmException, IOException {
+    AccountGroup.UUID groupUuid = group.getGroupUUID();
     try {
-      AccountGroupName id = new AccountGroupName(key, groupId);
-      db.get().accountGroupNames().insert(Collections.singleton(id));
-    } catch (OrmException e) {
-      AccountGroupName other = db.get().accountGroupNames().get(key);
-      if (other != null) {
-        // If we are using this identity, don't report the exception.
-        //
-        if (other.getId().equals(groupId)) {
-          return groupDetailFactory.create(groupId).call();
-        }
-
-        // Otherwise, someone else has this identity.
-        //
-        throw new ResourceConflictException("group with name " + newName + "already exists");
-      }
-      throw e;
+      groupsUpdateProvider
+          .get()
+          .renameGroup(db.get(), groupUuid, new AccountGroup.NameKey(newName));
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+    } catch (NameAlreadyUsedException e) {
+      throw new ResourceConflictException("group with name " + newName + " already exists");
     }
-
-    group.setNameKey(key);
-    db.get().accountGroups().update(Collections.singleton(group));
-
-    AccountGroupName priorName = db.get().accountGroupNames().get(old);
-    if (priorName != null) {
-      db.get().accountGroupNames().delete(Collections.singleton(priorName));
-    }
-
-    groupCache.evict(group);
-    groupCache.evictAfterRename(old, key);
-    @SuppressWarnings("unused")
-    Future<?> possiblyIgnoredError =
-        renameGroupOpFactory
-            .create(
-                currentUser.get().newCommitterIdent(new Date(), TimeZone.getDefault()),
-                group.getGroupUUID(),
-                old.get(),
-                newName)
-            .start(0, TimeUnit.MILLISECONDS);
-
-    return groupDetailFactory.create(groupId).call();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java
index 701d16f..1ea018f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOptions.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.server.group;
 
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupOptionsInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -22,32 +24,30 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.GroupCache;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
 
 @Singleton
 public class PutOptions implements RestModifyView<GroupResource, GroupOptionsInfo> {
-  private final GroupCache groupCache;
   private final Provider<ReviewDb> db;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
 
   @Inject
-  PutOptions(GroupCache groupCache, Provider<ReviewDb> db) {
-    this.groupCache = groupCache;
+  PutOptions(Provider<ReviewDb> db, @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider) {
     this.db = db;
+    this.groupsUpdateProvider = groupsUpdateProvider;
   }
 
   @Override
   public GroupOptionsInfo apply(GroupResource resource, GroupOptionsInfo input)
       throws MethodNotAllowedException, AuthException, BadRequestException,
           ResourceNotFoundException, OrmException, IOException {
-    if (resource.toAccountGroup() == null) {
-      throw new MethodNotAllowedException();
-    } else if (!resource.getControl().isOwner()) {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (!resource.getControl().isOwner()) {
       throw new AuthException("Not group owner");
     }
 
@@ -58,17 +58,19 @@
       input.visibleToAll = false;
     }
 
-    AccountGroup group = db.get().accountGroups().get(resource.toAccountGroup().getId());
-    if (group == null) {
-      throw new ResourceNotFoundException();
+    if (internalGroup.isVisibleToAll() != input.visibleToAll) {
+      AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+      try {
+        groupsUpdateProvider
+            .get()
+            .updateGroup(db.get(), groupUuid, group -> group.setVisibleToAll(input.visibleToAll));
+      } catch (NoSuchGroupException e) {
+        throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+      }
     }
 
-    group.setVisibleToAll(input.visibleToAll);
-    db.get().accountGroups().update(Collections.singleton(group));
-    groupCache.evict(group);
-
     GroupOptionsInfo options = new GroupOptionsInfo();
-    if (group.isVisibleToAll()) {
+    if (input.visibleToAll) {
       options.visibleToAll = true;
     }
     return options;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
index 0c82b9d..20e1dbe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/PutOwner.java
@@ -16,6 +16,7 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -26,14 +27,12 @@
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.group.PutOwner.Input;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.Collections;
 
 @Singleton
 public class PutOwner implements RestModifyView<GroupResource, Input> {
@@ -42,18 +41,18 @@
   }
 
   private final GroupsCollection groupsCollection;
-  private final GroupCache groupCache;
+  private final Provider<GroupsUpdate> groupsUpdateProvider;
   private final Provider<ReviewDb> db;
   private final GroupJson json;
 
   @Inject
   PutOwner(
       GroupsCollection groupsCollection,
-      GroupCache groupCache,
+      @UserInitiated Provider<GroupsUpdate> groupsUpdateProvider,
       Provider<ReviewDb> db,
       GroupJson json) {
     this.groupsCollection = groupsCollection;
-    this.groupCache = groupCache;
+    this.groupsUpdateProvider = groupsUpdateProvider;
     this.db = db;
     this.json = json;
   }
@@ -62,10 +61,9 @@
   public GroupInfo apply(GroupResource resource, Input input)
       throws ResourceNotFoundException, MethodNotAllowedException, AuthException,
           BadRequestException, UnprocessableEntityException, OrmException, IOException {
-    AccountGroup group = resource.toAccountGroup();
-    if (group == null) {
-      throw new MethodNotAllowedException();
-    } else if (!resource.getControl().isOwner()) {
+    GroupDescription.Internal internalGroup =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+    if (!resource.getControl().isOwner()) {
       throw new AuthException("Not group owner");
     }
 
@@ -73,16 +71,17 @@
       throw new BadRequestException("owner is required");
     }
 
-    group = db.get().accountGroups().get(group.getId());
-    if (group == null) {
-      throw new ResourceNotFoundException();
-    }
-
     GroupDescription.Basic owner = groupsCollection.parse(input.owner);
-    if (!group.getOwnerGroupUUID().equals(owner.getGroupUUID())) {
-      group.setOwnerGroupUUID(owner.getGroupUUID());
-      db.get().accountGroups().update(Collections.singleton(group));
-      groupCache.evict(group);
+    if (!internalGroup.getOwnerGroupUUID().equals(owner.getGroupUUID())) {
+      AccountGroup.UUID groupUuid = internalGroup.getGroupUUID();
+      try {
+        groupsUpdateProvider
+            .get()
+            .updateGroup(
+                db.get(), groupUuid, group -> group.setOwnerGroupUUID(owner.getGroupUUID()));
+      } catch (NoSuchGroupException e) {
+        throw new ResourceNotFoundException(String.format("Group %s not found", groupUuid));
+      }
     }
     return json.format(owner);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/QueryGroups.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/QueryGroups.java
index 1b95537..bc77f76 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/QueryGroups.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/QueryGroups.java
@@ -16,18 +16,16 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.extensions.client.ListGroupsOption;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.QueryResult;
 import com.google.gerrit.server.query.group.GroupQueryBuilder;
 import com.google.gerrit.server.query.group.GroupQueryProcessor;
 import com.google.gwtorm.server.OrmException;
@@ -106,6 +104,10 @@
       throw new BadRequestException("missing query field");
     }
 
+    if (queryProcessor.isDisabled()) {
+      throw new MethodNotAllowedException("query disabled");
+    }
+
     GroupIndex searchIndex = indexes.getSearchIndex();
     if (searchIndex == null) {
       throw new MethodNotAllowedException("no group index");
@@ -116,17 +118,17 @@
     }
 
     if (limit != 0) {
-      queryProcessor.setLimit(limit);
+      queryProcessor.setUserProvidedLimit(limit);
     }
 
     try {
-      QueryResult<AccountGroup> result = queryProcessor.query(queryBuilder.parse(query));
-      List<AccountGroup> groups = result.entities();
+      QueryResult<InternalGroup> result = queryProcessor.query(queryBuilder.parse(query));
+      List<InternalGroup> groups = result.entities();
 
       ArrayList<GroupInfo> groupInfos = Lists.newArrayListWithCapacity(groups.size());
       json.addOptions(options);
-      for (AccountGroup group : groups) {
-        groupInfos.add(json.format(GroupDescriptions.forAccountGroup(group)));
+      for (InternalGroup group : groups) {
+        groupInfos.add(json.format(new InternalGroupDescription(group)));
       }
       if (!groupInfos.isEmpty() && result.more()) {
         groupInfos.get(groupInfos.size() - 1)._moreGroups = true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/ServerInitiated.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/ServerInitiated.java
new file mode 100644
index 0000000..6e75fde
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/ServerInitiated.java
@@ -0,0 +1,34 @@
+// 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.group;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * A marker for database modifications which aren't directly related to a user request (e.g. happen
+ * outside of a request context). Those modifications will be attributed to the Gerrit server by
+ * using the Gerrit server identity as author and committer for all related NoteDb commits.
+ */
+@BindingAnnotation
+@Target({FIELD, PARAMETER, METHOD})
+@Retention(RUNTIME)
+public @interface ServerInitiated {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupResource.java
new file mode 100644
index 0000000..50c769d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupResource.java
@@ -0,0 +1,40 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.inject.TypeLiteral;
+
+public class SubgroupResource extends GroupResource {
+  public static final TypeLiteral<RestView<SubgroupResource>> SUBGROUP_KIND =
+      new TypeLiteral<RestView<SubgroupResource>>() {};
+
+  private final GroupDescription.Basic member;
+
+  public SubgroupResource(GroupResource group, GroupDescription.Basic member) {
+    super(group);
+    this.member = member;
+  }
+
+  public AccountGroup.UUID getMember() {
+    return getMemberDescription().getGroupUUID();
+  }
+
+  public GroupDescription.Basic getMemberDescription() {
+    return member;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupsCollection.java
new file mode 100644
index 0000000..720c6df
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/SubgroupsCollection.java
@@ -0,0 +1,99 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.group;
+
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.group.AddSubgroups.PutSubgroup;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class SubgroupsCollection
+    implements ChildCollection<GroupResource, SubgroupResource>, AcceptsCreate<GroupResource> {
+  private final DynamicMap<RestView<SubgroupResource>> views;
+  private final ListSubgroups list;
+  private final GroupsCollection groupsCollection;
+  private final Provider<ReviewDb> dbProvider;
+  private final Groups groups;
+  private final AddSubgroups addSubgroups;
+
+  @Inject
+  SubgroupsCollection(
+      DynamicMap<RestView<SubgroupResource>> views,
+      ListSubgroups list,
+      GroupsCollection groupsCollection,
+      Provider<ReviewDb> dbProvider,
+      Groups groups,
+      AddSubgroups addSubgroups) {
+    this.views = views;
+    this.list = list;
+    this.groupsCollection = groupsCollection;
+    this.dbProvider = dbProvider;
+    this.groups = groups;
+    this.addSubgroups = addSubgroups;
+  }
+
+  @Override
+  public RestView<GroupResource> list() {
+    return list;
+  }
+
+  @Override
+  public SubgroupResource parse(GroupResource resource, IdString id)
+      throws MethodNotAllowedException, AuthException, ResourceNotFoundException, OrmException {
+    GroupDescription.Internal parent =
+        resource.asInternalGroup().orElseThrow(MethodNotAllowedException::new);
+
+    GroupDescription.Basic member =
+        groupsCollection.parse(TopLevelResource.INSTANCE, id).getGroup();
+    if (resource.getControl().canSeeGroup() && isSubgroup(parent, member)) {
+      return new SubgroupResource(resource, member);
+    }
+    throw new ResourceNotFoundException(id);
+  }
+
+  private boolean isSubgroup(GroupDescription.Internal parent, GroupDescription.Basic member)
+      throws OrmException, ResourceNotFoundException {
+    try {
+      return groups.isSubgroup(dbProvider.get(), parent.getGroupUUID(), member.getGroupUUID());
+    } catch (NoSuchGroupException e) {
+      throw new ResourceNotFoundException(
+          String.format("Group %s not found", parent.getGroupUUID()));
+    }
+  }
+
+  @Override
+  public PutSubgroup create(GroupResource group, IdString id) {
+    return new PutSubgroup(addSubgroups, id.get());
+  }
+
+  @Override
+  public DynamicMap<RestView<SubgroupResource>> views() {
+    return views;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
index cbab0fc..5d60790 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/SystemGroupBackend.java
@@ -24,15 +24,17 @@
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.StartupCheck;
 import com.google.gerrit.server.StartupException;
 import com.google.gerrit.server.account.AbstractGroupBackend;
-import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
@@ -42,6 +44,7 @@
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Optional;
 import java.util.Set;
 import java.util.SortedMap;
 import java.util.TreeMap;
@@ -84,7 +87,8 @@
   }
 
   private final ImmutableSet<String> reservedNames;
-  private final SortedMap<String, GroupReference> names;
+  private final SortedMap<String, GroupReference> namesToGroups;
+  private final ImmutableSet<String> names;
   private final ImmutableMap<AccountGroup.UUID, GroupReference> uuids;
 
   @Inject
@@ -105,7 +109,9 @@
       u.put(ref.getUUID(), ref);
     }
     reservedNames = reservedNamesBuilder.build();
-    names = Collections.unmodifiableSortedMap(n);
+    namesToGroups = Collections.unmodifiableSortedMap(n);
+    names =
+        ImmutableSet.copyOf(namesToGroups.values().stream().map(r -> r.getName()).collect(toSet()));
     uuids = u.build();
   }
 
@@ -114,7 +120,7 @@
   }
 
   public Set<String> getNames() {
-    return names.values().stream().map(r -> r.getName()).collect(toSet());
+    return names;
   }
 
   public Set<String> getReservedNames() {
@@ -156,9 +162,9 @@
   }
 
   @Override
-  public Collection<GroupReference> suggest(String name, ProjectControl project) {
+  public Collection<GroupReference> suggest(String name, ProjectState project) {
     String nameLC = name.toLowerCase(Locale.US);
-    SortedMap<String, GroupReference> matches = names.tailMap(nameLC);
+    SortedMap<String, GroupReference> matches = namesToGroups.tailMap(nameLC);
     if (matches.isEmpty()) {
       return Collections.emptyList();
     }
@@ -181,12 +187,14 @@
 
   public static class NameCheck implements StartupCheck {
     private final Config cfg;
-    private final GroupCache groupCache;
+    private final Groups groups;
+    private final SchemaFactory<ReviewDb> schema;
 
     @Inject
-    NameCheck(@GerritServerConfig Config cfg, GroupCache groupCache) {
+    NameCheck(@GerritServerConfig Config cfg, Groups groups, SchemaFactory<ReviewDb> schema) {
       this.cfg = cfg;
-      this.groupCache = groupCache;
+      this.groups = groups;
+      this.schema = schema;
     }
 
     @Override
@@ -203,23 +211,42 @@
       if (configuredNames.isEmpty()) {
         return;
       }
-      for (AccountGroup g : groupCache.all()) {
-        String name = g.getName().toLowerCase(Locale.US);
-        if (byLowerCaseConfiguredName.keySet().contains(name)) {
-          AccountGroup.UUID uuidSystemGroup = byLowerCaseConfiguredName.get(name);
-          throw new StartupException(
-              String.format(
-                  "The configured name '%s' for system group '%s' is ambiguous"
-                      + " with the name '%s' of existing group '%s'."
-                      + " Please remove/change the value for groups.%s.name in"
-                      + " gerrit.config.",
-                  configuredNames.get(uuidSystemGroup),
-                  uuidSystemGroup.get(),
-                  g.getName(),
-                  g.getGroupUUID().get(),
-                  uuidSystemGroup.get()));
-        }
+
+      Optional<AccountGroup> conflictingGroup;
+      try (ReviewDb db = schema.open()) {
+        conflictingGroup =
+            groups
+                .getAll(db)
+                .filter(group -> hasConfiguredName(byLowerCaseConfiguredName, group))
+                .findAny();
+
+      } catch (OrmException ignored) {
+        return;
       }
+
+      if (conflictingGroup.isPresent()) {
+        AccountGroup group = conflictingGroup.get();
+        String groupName = group.getName();
+        AccountGroup.UUID systemGroupUuid = byLowerCaseConfiguredName.get(groupName);
+        throw new StartupException(
+            getAmbiguousNameMessage(groupName, group.getGroupUUID(), systemGroupUuid));
+      }
+    }
+
+    private static boolean hasConfiguredName(
+        Map<String, AccountGroup.UUID> byLowerCaseConfiguredName, AccountGroup group) {
+      String name = group.getName().toLowerCase(Locale.US);
+      return byLowerCaseConfiguredName.keySet().contains(name);
+    }
+
+    private static String getAmbiguousNameMessage(
+        String groupName, AccountGroup.UUID groupUuid, AccountGroup.UUID systemGroupUuid) {
+      return String.format(
+          "The configured name '%s' for system group '%s' is ambiguous"
+              + " with the name '%s' of existing group '%s'."
+              + " Please remove/change the value for groups.%s.name in"
+              + " gerrit.config.",
+          groupName, systemGroupUuid.get(), groupName, groupUuid.get(), systemGroupUuid.get());
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/group/UserInitiated.java b/gerrit-server/src/main/java/com/google/gerrit/server/group/UserInitiated.java
new file mode 100644
index 0000000..2f1567d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/group/UserInitiated.java
@@ -0,0 +1,34 @@
+// 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.group;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import com.google.inject.BindingAnnotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * A marker for database modifications which are directly related to a user request (e.g. happen
+ * inside of a request context). Those modifications will be attributed to the user by using the
+ * user's identity as author and committer for all related NoteDb commits.
+ */
+@BindingAnnotation
+@Target({FIELD, PARAMETER, METHOD})
+@Retention(RUNTIME)
+public @interface UserInitiated {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractIndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractIndexModule.java
index ec78043..d6029d0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractIndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractIndexModule.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.index;
 
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.index.account.AccountIndex;
@@ -65,7 +66,7 @@
 
   protected abstract Class<? extends GroupIndex> getGroupIndex();
 
-  protected abstract Class<? extends AbstractVersionManager> getVersionManager();
+  protected abstract Class<? extends VersionManager> getVersionManager();
 
   @Provides
   @Singleton
@@ -74,14 +75,14 @@
   }
 
   protected IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
-    return IndexConfig.fromConfig(cfg);
+    return IndexConfig.fromConfig(cfg).separateChangeSubIndexes(true).build();
   }
 
   private class MultiVersionModule extends LifecycleModule {
     @Override
     public void configure() {
-      Class<? extends AbstractVersionManager> versionManagerClass = getVersionManager();
-      bind(AbstractVersionManager.class).to(versionManagerClass);
+      Class<? extends VersionManager> versionManagerClass = getVersionManager();
+      bind(VersionManager.class).to(versionManagerClass);
       listener().to(versionManagerClass);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractVersionManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractVersionManager.java
deleted file mode 100644
index 8d0f550..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/AbstractVersionManager.java
+++ /dev/null
@@ -1,237 +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.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.collect.Lists;
-import com.google.common.collect.Maps;
-import com.google.gerrit.extensions.events.LifecycleListener;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.SitePaths;
-import com.google.gerrit.server.index.IndexDefinition.IndexFactory;
-import com.google.inject.ProvisionException;
-import java.io.IOException;
-import java.util.Collection;
-import java.util.List;
-import java.util.Map;
-import java.util.TreeMap;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-
-public abstract class AbstractVersionManager implements LifecycleListener {
-  public static class Version<V> {
-    public final Schema<V> schema;
-    public final int version;
-    public final boolean exists;
-    public final boolean ready;
-
-    public Version(Schema<V> schema, int version, boolean exists, boolean ready) {
-      checkArgument(schema == null || schema.getVersion() == version);
-      this.schema = schema;
-      this.version = version;
-      this.exists = exists;
-      this.ready = ready;
-    }
-  }
-
-  protected final boolean onlineUpgrade;
-  protected final String runReindexMsg;
-  protected final SitePaths sitePaths;
-  protected final Map<String, IndexDefinition<?, ?, ?>> defs;
-  protected final Map<String, OnlineReindexer<?, ?, ?>> reindexers;
-
-  protected AbstractVersionManager(
-      @GerritServerConfig Config cfg,
-      SitePaths sitePaths,
-      Collection<IndexDefinition<?, ?, ?>> defs) {
-    this.sitePaths = sitePaths;
-    this.defs = Maps.newHashMapWithExpectedSize(defs.size());
-    for (IndexDefinition<?, ?, ?> def : defs) {
-      this.defs.put(def.getName(), def);
-    }
-
-    reindexers = Maps.newHashMapWithExpectedSize(defs.size());
-    onlineUpgrade = cfg.getBoolean("index", null, "onlineUpgrade", true);
-    runReindexMsg =
-        "No index versions ready; run java -jar "
-            + sitePaths.gerrit_war.toAbsolutePath()
-            + " reindex";
-  }
-
-  @Override
-  public void start() {
-    GerritIndexStatus cfg = createIndexStatus();
-    for (IndexDefinition<?, ?, ?> def : defs.values()) {
-      initIndex(def, cfg);
-    }
-  }
-
-  @Override
-  public void stop() {
-    // Do nothing; indexes are closed on demand by IndexCollection.
-  }
-
-  /**
-   * Start the online reindexer if the current index is not already the latest.
-   *
-   * @param name index name
-   * @param force start re-index
-   * @return true if started, otherwise false.
-   * @throws ReindexerAlreadyRunningException
-   */
-  public synchronized boolean startReindexer(String name, boolean force)
-      throws ReindexerAlreadyRunningException {
-    OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
-    validateReindexerNotRunning(reindexer);
-    if (force || !isLatestIndexVersion(name, reindexer)) {
-      reindexer.start();
-      return true;
-    }
-    return false;
-  }
-
-  /**
-   * Activate the latest index if the current index is not already the latest.
-   *
-   * @param name index name
-   * @return true if index was activated, otherwise false.
-   * @throws ReindexerAlreadyRunningException
-   */
-  public synchronized boolean activateLatestIndex(String name)
-      throws ReindexerAlreadyRunningException {
-    OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
-    validateReindexerNotRunning(reindexer);
-    if (!isLatestIndexVersion(name, reindexer)) {
-      reindexer.activateIndex();
-      return true;
-    }
-    return false;
-  }
-
-  /**
-   * Tells if an index with this name is currently known or not.
-   *
-   * @param name index name
-   * @return true if index is known and can be used, otherwise false.
-   */
-  public boolean isKnownIndex(String name) {
-    return defs.get(name) != null;
-  }
-
-  protected <K, V, I extends Index<K, V>> void initIndex(
-      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
-    TreeMap<Integer, Version<V>> versions = scanVersions(def, cfg);
-    // Search from the most recent ready version.
-    // Write to the most recent ready version and the most recent version.
-    Version<V> search = null;
-    List<Version<V>> write = Lists.newArrayListWithCapacity(2);
-    for (Version<V> v : versions.descendingMap().values()) {
-      if (v.schema == null) {
-        continue;
-      }
-      if (write.isEmpty() && onlineUpgrade) {
-        write.add(v);
-      }
-      if (v.ready) {
-        search = v;
-        if (!write.contains(v)) {
-          write.add(v);
-        }
-        break;
-      }
-    }
-    if (search == null) {
-      throw new ProvisionException(runReindexMsg);
-    }
-
-    IndexFactory<K, V, I> factory = def.getIndexFactory();
-    I searchIndex = factory.create(search.schema);
-    IndexCollection<K, V, I> indexes = def.getIndexCollection();
-    indexes.setSearchIndex(searchIndex);
-    for (Version<V> v : write) {
-      if (v.version != search.version) {
-        indexes.addWriteIndex(factory.create(v.schema));
-      } else {
-        indexes.addWriteIndex(searchIndex);
-      }
-    }
-
-    markNotReady(def.getName(), versions.values(), write);
-
-    synchronized (this) {
-      if (!reindexers.containsKey(def.getName())) {
-        int latest = write.get(0).version;
-        OnlineReindexer<K, V, I> reindexer = new OnlineReindexer<>(def, latest);
-        reindexers.put(def.getName(), reindexer);
-        if (onlineUpgrade && latest != search.version) {
-          reindexer.start();
-        }
-      }
-    }
-  }
-
-  protected GerritIndexStatus createIndexStatus() {
-    try {
-      return new GerritIndexStatus(sitePaths);
-    } catch (ConfigInvalidException | IOException e) {
-      throw fail(e);
-    }
-  }
-
-  protected abstract <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>> scanVersions(
-      IndexDefinition<K, V, I> def, GerritIndexStatus cfg);
-
-  private <V> boolean isDirty(Collection<Version<V>> inUse, Version<V> v) {
-    return !inUse.contains(v) && v.exists;
-  }
-
-  private boolean isLatestIndexVersion(String name, OnlineReindexer<?, ?, ?> reindexer) {
-    int readVersion = defs.get(name).getIndexCollection().getSearchIndex().getSchema().getVersion();
-    return reindexer == null || reindexer.getVersion() == readVersion;
-  }
-
-  private static void validateReindexerNotRunning(OnlineReindexer<?, ?, ?> reindexer)
-      throws ReindexerAlreadyRunningException {
-    if (reindexer != null && reindexer.isRunning()) {
-      throw new ReindexerAlreadyRunningException();
-    }
-  }
-
-  private <V> void markNotReady(
-      String name, Iterable<Version<V>> versions, Collection<Version<V>> inUse) {
-    GerritIndexStatus cfg = createIndexStatus();
-    boolean dirty = false;
-    for (Version<V> v : versions) {
-      if (isDirty(inUse, v)) {
-        cfg.setReady(name, v.version, false);
-        dirty = true;
-      }
-    }
-    if (dirty) {
-      try {
-        cfg.save();
-      } catch (IOException e) {
-        throw fail(e);
-      }
-    }
-  }
-
-  private ProvisionException fail(Throwable t) {
-    ProvisionException e = new ProvisionException("Error scanning indexes");
-    e.initCause(t);
-    return e;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
index 1706761..481726b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/DummyIndexModule.java
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.server.index;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.DummyChangeIndex;
@@ -40,7 +43,7 @@
 
   private static class DummyGroupIndexFactory implements GroupIndex.Factory {
     @Override
-    public GroupIndex create(Schema<AccountGroup> schema) {
+    public GroupIndex create(Schema<InternalGroup> schema) {
       throw new UnsupportedOperationException();
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
deleted file mode 100644
index d5f1091..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldDef.java
+++ /dev/null
@@ -1,186 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.base.CharMatcher;
-import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.TrackingFooters;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.sql.Timestamp;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * 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;
-  }
-
-  @FunctionalInterface
-  public interface GetterWithArgs<I, T> {
-    T get(I input, FillArgs args) throws OrmException, IOException;
-  }
-
-  /** Arguments needed to fill in missing data in the input object. */
-  public static class FillArgs {
-    public final TrackingFooters trackingFooters;
-    public final boolean allowsDrafts;
-    public final AllUsersName allUsers;
-
-    @Inject
-    FillArgs(
-        TrackingFooters trackingFooters, @GerritServerConfig Config cfg, AllUsersName allUsers) {
-      this.trackingFooters = trackingFooters;
-      this.allowsDrafts = cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true);
-      this.allUsers = allUsers;
-    }
-  }
-
-  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 build((in, a) -> getter.get(in));
-    }
-
-    public <I> FieldDef<I, T> build(GetterWithArgs<I, T> getter) {
-      return new FieldDef<>(name, type, stored, false, getter);
-    }
-
-    public <I> FieldDef<I, Iterable<T>> buildRepeatable(Getter<I, Iterable<T>> getter) {
-      return buildRepeatable((in, a) -> getter.get(in));
-    }
-
-    public <I> FieldDef<I, Iterable<T>> buildRepeatable(GetterWithArgs<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 GetterWithArgs<I, T> getter;
-
-  private FieldDef(
-      String name,
-      FieldType<?> type,
-      boolean stored,
-      boolean repeatable,
-      GetterWithArgs<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.
-   * @param args arbitrary arguments needed to fill in indexable fields of the input object.
-   * @return the field value(s) to index.
-   * @throws OrmException
-   */
-  public T get(I input, FillArgs args) throws OrmException {
-    try {
-      return getter.get(input, args);
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  /** @return whether the field is repeatable. */
-  public boolean isRepeatable() {
-    return repeatable;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldType.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldType.java
deleted file mode 100644
index 820b62a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/FieldType.java
+++ /dev/null
@@ -1,63 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import 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-server/src/main/java/com/google/gerrit/server/index/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java
deleted file mode 100644
index 93cb09c..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/Index.java
+++ /dev/null
@@ -1,129 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.gerrit.server.query.DataSource;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.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.server.query.Predicate} instances into index-aware predicates that use the
- * index search results as a source.
- *
- * <p>Implementations must be thread-safe and should batch inserts/updates where appropriate.
- */
-public interface Index<K, V> {
-  /** @return the schema version used by this index. */
-  Schema<V> getSchema();
-
-  /** 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-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java
deleted file mode 100644
index 1ad8e05..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexCollection.java
+++ /dev/null
@@ -1,102 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.common.collect.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-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
deleted file mode 100644
index a368190..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexConfig.java
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.auto.value.AutoValue;
-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 create(0, 0, DEFAULT_MAX_TERMS);
-  }
-
-  public static IndexConfig fromConfig(Config cfg) {
-    return create(
-        cfg.getInt("index", null, "maxLimit", 0),
-        cfg.getInt("index", null, "maxPages", 0),
-        cfg.getInt("index", null, "maxTerms", 0));
-  }
-
-  public static IndexConfig create(int maxLimit, int maxPages, int maxTerms) {
-    return new AutoValue_IndexConfig(
-        checkLimit(maxLimit, "maxLimit", Integer.MAX_VALUE),
-        checkLimit(maxPages, "maxPages", Integer.MAX_VALUE),
-        checkLimit(maxTerms, "maxTerms", DEFAULT_MAX_TERMS));
-  }
-
-  private static int checkLimit(int limit, String name, int defaultValue) {
-    if (limit == 0) {
-      return defaultValue;
-    }
-    checkArgument(limit > 0, "%s must be positive: %s", name, limit);
-    return 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();
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexDefinition.java
deleted file mode 100644
index 340e35e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexDefinition.java
+++ /dev/null
@@ -1,72 +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.server.index;
-
-import com.google.common.collect.ImmutableSortedMap;
-import com.google.inject.Provider;
-
-/**
- * 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 Provider<SiteIndexer<K, V, I>> siteIndexer;
-
-  protected IndexDefinition(
-      SchemaDefinitions<V> schemaDefs,
-      IndexCollection<K, V, I> indexCollection,
-      IndexFactory<K, V, I> indexFactory,
-      Provider<SiteIndexer<K, V, I>> siteIndexer) {
-    this.schemaDefs = schemaDefs;
-    this.indexCollection = indexCollection;
-    this.indexFactory = indexFactory;
-    this.siteIndexer = siteIndexer;
-  }
-
-  public final String getName() {
-    return schemaDefs.getName();
-  }
-
-  public final ImmutableSortedMap<Integer, Schema<V>> getSchemas() {
-    return schemaDefs.getSchemas();
-  }
-
-  public final Schema<V> getLatest() {
-    return schemaDefs.getLatest();
-  }
-
-  public final IndexCollection<K, V, I> getIndexCollection() {
-    return indexCollection;
-  }
-
-  public final IndexFactory<K, V, I> getIndexFactory() {
-    return indexFactory;
-  }
-
-  public final SiteIndexer<K, V, I> getSiteIndexer() {
-    return siteIndexer.get();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
index b4c47e9..89afec4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
@@ -23,6 +23,9 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.WorkQueue;
@@ -125,6 +128,8 @@
       // registration of the ShutdownIndexExecutors LifecycleListener must happen afterwards.
       listener().to(ShutdownIndexExecutors.class);
     }
+
+    DynamicSet.setOf(binder(), OnlineUpgradeListener.class);
   }
 
   @Provides
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
deleted file mode 100644
index ff9ff03..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexPredicate.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.gerrit.server.query.OperatorPredicate;
-
-/** 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-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
deleted file mode 100644
index 5c1d838..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexRewriter.java
+++ /dev/null
@@ -1,23 +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.server.index;
-
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-
-public interface IndexRewriter<T> {
-
-  Predicate<T> rewrite(Predicate<T> in, QueryOptions opts) throws QueryParseException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
index 7000e04..ea9900b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexUtils.java
@@ -21,9 +21,12 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Sets;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.query.change.SingleGroupUser;
 import java.io.IOException;
 import java.util.Set;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -81,6 +84,16 @@
         : Sets.union(fs, ImmutableSet.of(GroupField.UUID.getName()));
   }
 
+  public static String describe(CurrentUser user) {
+    if (user.isIdentifiedUser()) {
+      return user.getAccountId().toString();
+    }
+    if (user instanceof SingleGroupUser) {
+      return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
+    }
+    return user.toString();
+  }
+
   private IndexUtils() {
     // hide default constructor
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
deleted file mode 100644
index b8f21f5..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexedQuery.java
+++ /dev/null
@@ -1,126 +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.server.index;
-
-import com.google.common.base.MoreObjects;
-import com.google.common.collect.ImmutableList;
-import com.google.gerrit.server.query.DataSource;
-import com.google.gerrit.server.query.Paginated;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-import java.util.Collection;
-import java.util.List;
-
-/**
- * Wrapper combining an {@link IndexPredicate} together with a {@link DataSource} that returns
- * matching results from the index.
- *
- * <p>Appropriate to return as the rootmost predicate that can be processed using the secondary
- * index; such predicates must also implement {@link DataSource} to be chosen by the query
- * processor.
- *
- * @param <I> The type of the IDs by which the entities are stored in the index.
- * @param <T> The type of the entities that are stored in the index.
- */
-public class IndexedQuery<I, T> extends Predicate<T> implements DataSource<T>, Paginated<T> {
-  protected final Index<I, T> index;
-
-  private QueryOptions opts;
-  private final Predicate<T> pred;
-  protected DataSource<T> source;
-
-  public IndexedQuery(Index<I, T> index, Predicate<T> pred, QueryOptions opts)
-      throws QueryParseException {
-    this.index = index;
-    this.opts = opts;
-    this.pred = pred;
-    this.source = index.getSource(pred, this.opts);
-  }
-
-  @Override
-  public int getChildCount() {
-    return 1;
-  }
-
-  @Override
-  public Predicate<T> getChild(int i) {
-    if (i == 0) {
-      return pred;
-    }
-    throw new ArrayIndexOutOfBoundsException(i);
-  }
-
-  @Override
-  public List<Predicate<T>> getChildren() {
-    return ImmutableList.of(pred);
-  }
-
-  @Override
-  public QueryOptions getOptions() {
-    return opts;
-  }
-
-  @Override
-  public int getCardinality() {
-    return source != null ? source.getCardinality() : opts.limit();
-  }
-
-  @Override
-  public ResultSet<T> read() throws OrmException {
-    return source.read();
-  }
-
-  @Override
-  public ResultSet<T> restart(int start) throws OrmException {
-    opts = opts.withStart(start);
-    try {
-      source = index.getSource(pred, opts);
-    } catch (QueryParseException e) {
-      // Don't need to show this exception to the user; the only thing that
-      // changed about pred was its start, and any other QPEs that might happen
-      // should have already thrown from the constructor.
-      throw new OrmException(e);
-    }
-    // Don't convert start to a limit, since the caller of this method (see
-    // AndSource) has calculated the actual number to skip.
-    return read();
-  }
-
-  @Override
-  public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
-    return this;
-  }
-
-  @Override
-  public int hashCode() {
-    return pred.hashCode();
-  }
-
-  @Override
-  public boolean equals(Object other) {
-    if (other == null || getClass() != other.getClass()) {
-      return false;
-    }
-    IndexedQuery<?, ?> o = (IndexedQuery<?, ?>) other;
-    return pred.equals(o.pred) && opts.equals(o.opts);
-  }
-
-  @Override
-  public String toString() {
-    return MoreObjects.toStringHelper("index").add("p", pred).add("opts", opts).toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
deleted file mode 100644
index 5832694..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IntegerRangePredicate.java
+++ /dev/null
@@ -1,53 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.util.RangeUtil;
-import com.google.gerrit.server.util.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-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java
index e40015a..ee2a76e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineReindexer.java
@@ -17,6 +17,11 @@
 import static com.google.common.base.Preconditions.checkNotNull;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexCollection;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.SiteIndexer;
 import java.io.IOException;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -26,16 +31,26 @@
 public class OnlineReindexer<K, V, I extends Index<K, V>> {
   private static final Logger log = LoggerFactory.getLogger(OnlineReindexer.class);
 
+  private final String name;
   private final IndexCollection<K, V, I> indexes;
   private final SiteIndexer<K, V, I> batchIndexer;
-  private final int version;
+  private final int oldVersion;
+  private final int newVersion;
+  private final DynamicSet<OnlineUpgradeListener> listeners;
   private I index;
   private final AtomicBoolean running = new AtomicBoolean();
 
-  public OnlineReindexer(IndexDefinition<K, V, I> def, int version) {
+  public OnlineReindexer(
+      IndexDefinition<K, V, I> def,
+      int oldVersion,
+      int newVersion,
+      DynamicSet<OnlineUpgradeListener> listeners) {
+    this.name = def.getName();
     this.indexes = def.getIndexCollection();
     this.batchIndexer = def.getSiteIndexer();
-    this.version = version;
+    this.oldVersion = oldVersion;
+    this.newVersion = newVersion;
+    this.listeners = listeners;
   }
 
   public void start() {
@@ -44,14 +59,24 @@
           new Thread() {
             @Override
             public void run() {
+              boolean ok = false;
               try {
                 reindex();
+                ok = true;
+              } catch (IOException e) {
+                log.error("Online reindex of {} schema version {} failed", name, version(index), e);
               } finally {
                 running.set(false);
+                if (!ok) {
+                  for (OnlineUpgradeListener listener : listeners) {
+                    listener.onFailure(name, oldVersion, newVersion);
+                  }
+                }
               }
             }
           };
-      t.setName(String.format("Reindex v%d-v%d", version(indexes.getSearchIndex()), version));
+      t.setName(
+          String.format("Reindex %s v%d-v%d", name, version(indexes.getSearchIndex()), newVersion));
       t.start();
     }
   }
@@ -61,42 +86,57 @@
   }
 
   public int getVersion() {
-    return version;
+    return newVersion;
   }
 
   private static int version(Index<?, ?> i) {
     return i.getSchema().getVersion();
   }
 
-  private void reindex() {
+  private void reindex() throws IOException {
+    for (OnlineUpgradeListener listener : listeners) {
+      listener.onStart(name, oldVersion, newVersion);
+    }
     index =
         checkNotNull(
-            indexes.getWriteIndex(version), "not an active write schema version: %s", version);
+            indexes.getWriteIndex(newVersion),
+            "not an active write schema version: %s %s",
+            name,
+            newVersion);
     log.info(
-        "Starting online reindex from schema version {} to {}",
+        "Starting online reindex of {} from schema version {} to {}",
+        name,
         version(indexes.getSearchIndex()),
         version(index));
+
+    if (oldVersion != newVersion) {
+      index.deleteAll();
+    }
     SiteIndexer.Result result = batchIndexer.indexAll(index);
     if (!result.success()) {
       log.error(
-          "Online reindex of schema version {} failed. Successfully"
-              + " indexed {} changes, failed to index {} changes",
+          "Online reindex of {} schema version {} failed. Successfully"
+              + " indexed {}, failed to index {}",
+          name,
           version(index),
           result.doneCount(),
           result.failedCount());
       return;
     }
-    log.info("Reindex to version {} complete", version(index));
+    log.info("Reindex {} to version {} complete", name, version(index));
     activateIndex();
+    for (OnlineUpgradeListener listener : listeners) {
+      listener.onSuccess(name, oldVersion, newVersion);
+    }
   }
 
   public void activateIndex() {
     indexes.setSearchIndex(index);
-    log.info("Using schema version {}", version(index));
+    log.info("Using {} schema version {}", name, version(index));
     try {
       index.markReady(true);
     } catch (IOException e) {
-      log.warn("Error activating new schema version {}", version(index));
+      log.warn("Error activating new {} schema version {}", name, version(index));
     }
 
     List<I> toRemove = Lists.newArrayListWithExpectedSize(1);
@@ -110,7 +150,7 @@
         i.markReady(false);
         indexes.removeWriteIndex(version(i));
       } catch (IOException e) {
-        log.warn("Error deactivating old schema version {}", version(i));
+        log.warn("Error deactivating old {} schema version {}", name, version(i));
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineUpgradeListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineUpgradeListener.java
new file mode 100644
index 0000000..a2d13fe
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineUpgradeListener.java
@@ -0,0 +1,45 @@
+// 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.index;
+
+/** Listener for online schema upgrade events. */
+public interface OnlineUpgradeListener {
+  /**
+   * Called before starting upgrading a single index.
+   *
+   * @param name index definition name.
+   * @param oldVersion old schema version.
+   * @param newVersion new schema version.
+   */
+  void onStart(String name, int oldVersion, int newVersion);
+
+  /**
+   * Called after successfully upgrading a single index.
+   *
+   * @param name index definition name.
+   * @param oldVersion old schema version.
+   * @param newVersion new schema version.
+   */
+  void onSuccess(String name, int oldVersion, int newVersion);
+
+  /**
+   * Called after failing to upgrade a single index.
+   *
+   * @param name index definition name.
+   * @param oldVersion old schema version.
+   * @param newVersion new schema version.
+   */
+  void onFailure(String name, int oldVersion, int newVersion);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineUpgrader.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineUpgrader.java
new file mode 100644
index 0000000..bfcf55f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/OnlineUpgrader.java
@@ -0,0 +1,47 @@
+// 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.index;
+
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.inject.Inject;
+
+/** Listener to handle upgrading index schema versions at startup. */
+public class OnlineUpgrader implements LifecycleListener {
+  public static class Module extends LifecycleModule {
+    @Override
+    protected void configure() {
+      listener().to(OnlineUpgrader.class);
+    }
+  }
+
+  private final VersionManager versionManager;
+
+  @Inject
+  OnlineUpgrader(VersionManager versionManager) {
+    this.versionManager = versionManager;
+  }
+
+  @Override
+  public void start() {
+    versionManager.startOnlineUpgrade();
+  }
+
+  @Override
+  public void stop() {
+    // Do nothing; reindexing threadpools are shut down in another listener, and indexes are closed
+    // on demand by IndexCollection.
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java
deleted file mode 100644
index a26b0ac..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/QueryOptions.java
+++ /dev/null
@@ -1,56 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.auto.value.AutoValue;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.primitives.Ints;
-import java.util.Set;
-
-@AutoValue
-public abstract class QueryOptions {
-  public static QueryOptions create(IndexConfig config, int start, int limit, Set<String> fields) {
-    checkArgument(start >= 0, "start must be nonnegative: %s", start);
-    checkArgument(limit > 0, "limit must be positive: %s", limit);
-    return new AutoValue_QueryOptions(config, start, limit, ImmutableSet.copyOf(fields));
-  }
-
-  public QueryOptions convertForBackend() {
-    // Increase the limit rather than skipping, since we don't know how many
-    // skipped results would have been filtered out by the enclosing AndSource.
-    int backendLimit = config().maxLimit();
-    int limit = Ints.saturatedCast((long) limit() + start());
-    limit = Math.min(limit, backendLimit);
-    return create(config(), 0, limit, fields());
-  }
-
-  public abstract IndexConfig config();
-
-  public abstract int start();
-
-  public abstract int limit();
-
-  public abstract ImmutableSet<String> fields();
-
-  public QueryOptions withLimit(int newLimit) {
-    return create(config(), start(), newLimit, fields());
-  }
-
-  public QueryOptions withStart(int newStart) {
-    return create(config(), newStart, limit(), fields());
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/RefState.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/RefState.java
new file mode 100644
index 0000000..b55afb6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/RefState.java
@@ -0,0 +1,65 @@
+// 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.index;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.auto.value.AutoValue;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.reviewdb.client.Project;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+
+@AutoValue
+public abstract class RefState {
+  public static RefState create(String ref, String sha) {
+    return new AutoValue_RefState(ref, ObjectId.fromString(sha));
+  }
+
+  public static RefState create(String ref, @Nullable ObjectId id) {
+    return new AutoValue_RefState(ref, firstNonNull(id, ObjectId.zeroId()));
+  }
+
+  public static RefState of(Ref ref) {
+    return new AutoValue_RefState(ref.getName(), ref.getObjectId());
+  }
+
+  public byte[] toByteArray(Project.NameKey project) {
+    byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
+    byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
+    System.arraycopy(a, 0, b, 0, a.length);
+    id().copyTo(b, a.length);
+    return b;
+  }
+
+  public static void check(boolean condition, String str) {
+    checkArgument(condition, "invalid RefState: %s", str);
+  }
+
+  public abstract String ref();
+
+  public abstract ObjectId id();
+
+  public boolean match(Repository repo) throws IOException {
+    Ref ref = repo.exactRef(ref());
+    ObjectId expected = ref != null ? ref.getObjectId() : ObjectId.zeroId();
+    return id().equals(expected);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/RegexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/RegexPredicate.java
deleted file mode 100644
index b73674d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/RegexPredicate.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-public abstract class RegexPredicate<I> extends IndexPredicate<I> {
-  protected RegexPredicate(FieldDef<I, ?> def, String value) {
-    super(def, value);
-  }
-
-  protected RegexPredicate(FieldDef<I, ?> def, String name, String value) {
-    super(def, name, value);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
deleted file mode 100644
index c7e5755..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/Schema.java
+++ /dev/null
@@ -1,212 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import static com.google.common.base.Preconditions.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.gerrit.server.index.FieldDef.FillArgs;
-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.
-   * @param fillArgs arguments for filling fields.
-   * @return all non-null field values from the object.
-   */
-  public final Iterable<Values<T>> buildFields(final T obj, final FillArgs fillArgs) {
-    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, fillArgs);
-                } 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-server/src/main/java/com/google/gerrit/server/index/SchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaDefinitions.java
deleted file mode 100644
index 2bcf03a..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaDefinitions.java
+++ /dev/null
@@ -1,56 +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.server.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.common.collect.ImmutableSortedMap;
-
-/**
- * Definitions of the various schema versions over a given Gerrit data type.
- *
- * <p>A <em>schema</em> is a description of the fields that are indexed over the given data type.
- * This class contains all the versions of a schema defined over its data type, exposed as a map of
- * version number to schema definition. If you are interested in the classes responsible for
- * backend-specific runtime implementations, see the implementations of {@link IndexDefinition}.
- */
-public abstract class SchemaDefinitions<V> {
-  private final String name;
-  private final ImmutableSortedMap<Integer, Schema<V>> schemas;
-
-  protected SchemaDefinitions(String name, Class<V> valueClass) {
-    this.name = checkNotNull(name);
-    this.schemas = SchemaUtil.schemasFromClass(getClass(), valueClass);
-  }
-
-  public final String getName() {
-    return name;
-  }
-
-  public final ImmutableSortedMap<Integer, Schema<V>> getSchemas() {
-    return schemas;
-  }
-
-  public final Schema<V> get(int version) {
-    Schema<V> schema = schemas.get(version);
-    checkArgument(schema != null, "Unrecognized %s schema version: %s", name, version);
-    return schema;
-  }
-
-  public final Schema<V> getLatest() {
-    return schemas.lastEntry().getValue();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaUtil.java
deleted file mode 100644
index ea33190..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/SchemaUtil.java
+++ /dev/null
@@ -1,118 +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.server.index;
-
-import static com.google.common.base.Preconditions.checkArgument;
-
-import com.google.common.base.CharMatcher;
-import com.google.common.base.Splitter;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.ImmutableSortedMap;
-import com.google.common.collect.Iterables;
-import 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-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java
index bf28d7d..e3f9d7a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/SingleVersionModule.java
@@ -16,6 +16,9 @@
 
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java
deleted file mode 100644
index c13209f..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/SiteIndexer.java
+++ /dev/null
@@ -1,143 +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.server.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.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 (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 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-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
deleted file mode 100644
index 7e194b7..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/TimestampRangePredicate.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index;
-
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gwtjsonrpc.common.JavaSqlTimestampHelper;
-import java.sql.Timestamp;
-import java.util.Date;
-
-// TODO: Migrate this to IntegerRangePredicate
-public abstract class TimestampRangePredicate<I> extends IndexPredicate<I> {
-  protected static Timestamp parse(String value) throws QueryParseException {
-    try {
-      return JavaSqlTimestampHelper.parseTimestamp(value);
-    } catch (IllegalArgumentException e) {
-      // parseTimestamp's errors are specific and helpful, so preserve them.
-      throw new QueryParseException(e.getMessage(), e);
-    }
-  }
-
-  protected TimestampRangePredicate(FieldDef<I, Timestamp> def, String name, String value) {
-    super(def, name, value);
-  }
-
-  public abstract Date getMinTimestamp();
-
-  public abstract Date getMaxTimestamp();
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/VersionManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/VersionManager.java
new file mode 100644
index 0000000..8aabb60
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/VersionManager.java
@@ -0,0 +1,279 @@
+// 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.index;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+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.IndexCollection;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.IndexDefinition.IndexFactory;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.ProvisionException;
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+public abstract class VersionManager implements LifecycleListener {
+  public static boolean getOnlineUpgrade(Config cfg) {
+    return cfg.getBoolean("index", null, "onlineUpgrade", true);
+  }
+
+  public static class Version<V> {
+    public final Schema<V> schema;
+    public final int version;
+    public final boolean exists;
+    public final boolean ready;
+
+    public Version(Schema<V> schema, int version, boolean exists, boolean ready) {
+      checkArgument(schema == null || schema.getVersion() == version);
+      this.schema = schema;
+      this.version = version;
+      this.exists = exists;
+      this.ready = ready;
+    }
+  }
+
+  protected final boolean onlineUpgrade;
+  protected final String runReindexMsg;
+  protected final SitePaths sitePaths;
+
+  private final DynamicSet<OnlineUpgradeListener> listeners;
+
+  // The following fields must be accessed synchronized on this.
+  protected final Map<String, IndexDefinition<?, ?, ?>> defs;
+  protected final Map<String, OnlineReindexer<?, ?, ?>> reindexers;
+
+  protected VersionManager(
+      SitePaths sitePaths,
+      DynamicSet<OnlineUpgradeListener> listeners,
+      Collection<IndexDefinition<?, ?, ?>> defs,
+      boolean onlineUpgrade) {
+    this.sitePaths = sitePaths;
+    this.listeners = listeners;
+    this.defs = Maps.newHashMapWithExpectedSize(defs.size());
+    for (IndexDefinition<?, ?, ?> def : defs) {
+      this.defs.put(def.getName(), def);
+    }
+
+    this.reindexers = Maps.newHashMapWithExpectedSize(defs.size());
+    this.onlineUpgrade = onlineUpgrade;
+    this.runReindexMsg =
+        "No index versions for index '%s' ready; run java -jar "
+            + sitePaths.gerrit_war.toAbsolutePath()
+            + " reindex --index %s";
+  }
+
+  @Override
+  public void start() {
+    GerritIndexStatus cfg = createIndexStatus();
+    for (IndexDefinition<?, ?, ?> def : defs.values()) {
+      initIndex(def, cfg);
+    }
+  }
+
+  @Override
+  public void stop() {
+    // Do nothing; indexes are closed on demand by IndexCollection.
+  }
+
+  /**
+   * Start the online reindexer if the current index is not already the latest.
+   *
+   * @param name index name
+   * @param force start re-index
+   * @return true if started, otherwise false.
+   * @throws ReindexerAlreadyRunningException
+   */
+  public synchronized boolean startReindexer(String name, boolean force)
+      throws ReindexerAlreadyRunningException {
+    OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
+    validateReindexerNotRunning(reindexer);
+    if (force || !isLatestIndexVersion(name, reindexer)) {
+      reindexer.start();
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Activate the latest index if the current index is not already the latest.
+   *
+   * @param name index name
+   * @return true if index was activated, otherwise false.
+   * @throws ReindexerAlreadyRunningException
+   */
+  public synchronized boolean activateLatestIndex(String name)
+      throws ReindexerAlreadyRunningException {
+    OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
+    validateReindexerNotRunning(reindexer);
+    if (!isLatestIndexVersion(name, reindexer)) {
+      reindexer.activateIndex();
+      return true;
+    }
+    return false;
+  }
+
+  /**
+   * Tells if an index with this name is currently known or not.
+   *
+   * @param name index name
+   * @return true if index is known and can be used, otherwise false.
+   */
+  public boolean isKnownIndex(String name) {
+    return defs.get(name) != null;
+  }
+
+  protected <K, V, I extends Index<K, V>> void initIndex(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg) {
+    TreeMap<Integer, Version<V>> versions = scanVersions(def, cfg);
+    // Search from the most recent ready version.
+    // Write to the most recent ready version and the most recent version.
+    Version<V> search = null;
+    List<Version<V>> write = Lists.newArrayListWithCapacity(2);
+    for (Version<V> v : versions.descendingMap().values()) {
+      if (v.schema == null) {
+        continue;
+      }
+      if (write.isEmpty() && onlineUpgrade) {
+        write.add(v);
+      }
+      if (v.ready) {
+        search = v;
+        if (!write.contains(v)) {
+          write.add(v);
+        }
+        break;
+      }
+    }
+    if (search == null) {
+      throw new ProvisionException(String.format(runReindexMsg, def.getName(), def.getName()));
+    }
+
+    IndexFactory<K, V, I> factory = def.getIndexFactory();
+    I searchIndex = factory.create(search.schema);
+    IndexCollection<K, V, I> indexes = def.getIndexCollection();
+    indexes.setSearchIndex(searchIndex);
+    for (Version<V> v : write) {
+      if (v.version != search.version) {
+        indexes.addWriteIndex(factory.create(v.schema));
+      } else {
+        indexes.addWriteIndex(searchIndex);
+      }
+    }
+
+    markNotReady(def.getName(), versions.values(), write);
+
+    synchronized (this) {
+      if (!reindexers.containsKey(def.getName())) {
+        int latest = write.get(0).version;
+        OnlineReindexer<K, V, I> reindexer =
+            new OnlineReindexer<>(def, search.version, latest, listeners);
+        reindexers.put(def.getName(), reindexer);
+      }
+    }
+  }
+
+  synchronized void startOnlineUpgrade() {
+    checkState(onlineUpgrade, "online upgrade not enabled");
+    for (IndexDefinition<?, ?, ?> def : defs.values()) {
+      String name = def.getName();
+      IndexCollection<?, ?, ?> indexes = def.getIndexCollection();
+      Index<?, ?> search = indexes.getSearchIndex();
+      checkState(
+          search != null, "no search index ready for %s; should have failed at startup", name);
+      int searchVersion = search.getSchema().getVersion();
+
+      List<Index<?, ?>> write = ImmutableList.copyOf(indexes.getWriteIndexes());
+      checkState(
+          !write.isEmpty(),
+          "no write indexes set for %s; should have been initialized at startup",
+          name);
+      int latestWriteVersion = write.get(0).getSchema().getVersion();
+
+      if (latestWriteVersion != searchVersion) {
+        OnlineReindexer<?, ?, ?> reindexer = reindexers.get(name);
+        checkState(
+            reindexer != null,
+            "no reindexer found for %s; should have been initialized at startup",
+            name);
+        reindexer.start();
+      }
+    }
+  }
+
+  protected GerritIndexStatus createIndexStatus() {
+    try {
+      return new GerritIndexStatus(sitePaths);
+    } catch (ConfigInvalidException | IOException e) {
+      throw fail(e);
+    }
+  }
+
+  protected abstract <K, V, I extends Index<K, V>> TreeMap<Integer, Version<V>> scanVersions(
+      IndexDefinition<K, V, I> def, GerritIndexStatus cfg);
+
+  private <V> boolean isDirty(Collection<Version<V>> inUse, Version<V> v) {
+    return !inUse.contains(v) && v.exists;
+  }
+
+  private boolean isLatestIndexVersion(String name, OnlineReindexer<?, ?, ?> reindexer) {
+    int readVersion = defs.get(name).getIndexCollection().getSearchIndex().getSchema().getVersion();
+    return reindexer == null || reindexer.getVersion() == readVersion;
+  }
+
+  private static void validateReindexerNotRunning(OnlineReindexer<?, ?, ?> reindexer)
+      throws ReindexerAlreadyRunningException {
+    if (reindexer != null && reindexer.isRunning()) {
+      throw new ReindexerAlreadyRunningException();
+    }
+  }
+
+  private <V> void markNotReady(
+      String name, Iterable<Version<V>> versions, Collection<Version<V>> inUse) {
+    GerritIndexStatus cfg = createIndexStatus();
+    boolean dirty = false;
+    for (Version<V> v : versions) {
+      if (isDirty(inUse, v)) {
+        cfg.setReady(name, v.version, false);
+        dirty = true;
+      }
+    }
+    if (dirty) {
+      try {
+        cfg.save();
+      } catch (IOException e) {
+        throw fail(e);
+      }
+    }
+  }
+
+  private ProvisionException fail(Throwable t) {
+    ProvisionException e = new ProvisionException("Error scanning indexes");
+    e.initCause(t);
+    return e;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
index 96aec3f..5e12c12 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountField.java
@@ -14,23 +14,29 @@
 
 package com.google.gerrit.server.index.account;
 
-import static com.google.gerrit.server.index.FieldDef.exact;
-import static com.google.gerrit.server.index.FieldDef.integer;
-import static com.google.gerrit.server.index.FieldDef.prefix;
-import static com.google.gerrit.server.index.FieldDef.timestamp;
+import static com.google.gerrit.index.FieldDef.exact;
+import static com.google.gerrit.index.FieldDef.integer;
+import static com.google.gerrit.index.FieldDef.prefix;
+import static com.google.gerrit.index.FieldDef.storedOnly;
+import static com.google.gerrit.index.FieldDef.timestamp;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.Predicates;
 import com.google.common.base.Strings;
 import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.SchemaUtil;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.index.RefState;
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.Locale;
 import java.util.Set;
+import org.eclipse.jgit.lib.ObjectId;
 
 /** Secondary index schemas for accounts. */
 public class AccountField {
@@ -76,6 +82,17 @@
                       .transform(String::toLowerCase)
                       .toSet());
 
+  public static final FieldDef<AccountState, String> PREFERRED_EMAIL =
+      prefix("preferredemail")
+          .build(
+              a -> {
+                String preferredEmail = a.getAccount().getPreferredEmail();
+                return preferredEmail != null ? preferredEmail.toLowerCase() : null;
+              });
+
+  public static final FieldDef<AccountState, String> PREFERRED_EMAIL_EXACT =
+      exact("preferredemail_exact").build(a -> a.getAccount().getPreferredEmail());
+
   public static final FieldDef<AccountState, Timestamp> REGISTERED =
       timestamp("registered").build(a -> a.getAccount().getRegisteredOn());
 
@@ -90,5 +107,43 @@
                       .transform(k -> k.project().get())
                       .toSet());
 
+  /**
+   * All values of all refs that were used in the course of indexing this document, except the
+   * refs/meta/external-ids notes branch which is handled specially (see {@link
+   * #EXTERNAL_ID_STATE}).
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code project:ref/name:[hex sha]}.
+   */
+  public static final FieldDef<AccountState, Iterable<byte[]>> REF_STATE =
+      storedOnly("ref_state")
+          .buildRepeatable(
+              a -> {
+                if (a.getAccount().getMetaId() == null) {
+                  return ImmutableList.of();
+                }
+
+                return ImmutableList.of(
+                    RefState.create(
+                            RefNames.refsUsers(a.getAccount().getId()),
+                            ObjectId.fromString(a.getAccount().getMetaId()))
+                        .toByteArray(a.getAllUsersNameForIndexing()));
+              });
+
+  /**
+   * All note values of all external IDs that were used in the course of indexing this document.
+   *
+   * <p>Emitted as UTF-8 encoded strings of the form {@code [hex sha of external ID]:[hex sha of
+   * note blob]}, or with other words {@code [note ID]:[note data ID]}.
+   */
+  public static final FieldDef<AccountState, Iterable<byte[]>> EXTERNAL_ID_STATE =
+      storedOnly("external_id_state")
+          .buildRepeatable(
+              a ->
+                  a.getExternalIds()
+                      .stream()
+                      .filter(e -> e.blobId() != null)
+                      .map(e -> e.toByteArray())
+                      .collect(toSet()));
+
   private AccountField() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
index ffa94ec..5c1b3dc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndex.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.index.account;
 
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.index.Index;
-import com.google.gerrit.server.index.IndexDefinition;
-import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.account.AccountPredicates;
 
 public interface AccountIndex extends Index<Account.Id, AccountState> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
index 2eb8235..2a14f9b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexCollection.java
@@ -15,16 +15,14 @@
 package com.google.gerrit.server.index.account;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.index.IndexCollection;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
 public class AccountIndexCollection
     extends IndexCollection<Account.Id, AccountState, AccountIndex> {
-  @Inject
   @VisibleForTesting
   public AccountIndexCollection() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
index 72f23be..af23b52 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexDefinition.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.index.account;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.index.IndexDefinition;
 import com.google.inject.Inject;
-import com.google.inject.util.Providers;
 
 public class AccountIndexDefinition
     extends IndexDefinition<Account.Id, AccountState, AccountIndex> {
@@ -29,10 +28,6 @@
       AccountIndexCollection indexCollection,
       AccountIndex.Factory indexFactory,
       @Nullable AllAccountsIndexer allAccountsIndexer) {
-    super(
-        AccountSchemaDefinitions.INSTANCE,
-        indexCollection,
-        indexFactory,
-        Providers.of(allAccountsIndexer));
+    super(AccountSchemaDefinitions.INSTANCE, indexCollection, indexFactory, allAccountsIndexer);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
index 4fd7833..bc0970e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexRewriter.java
@@ -16,12 +16,11 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
+import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.index.IndexRewriter;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.account.AccountPredicates;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -38,9 +37,6 @@
   @Override
   public Predicate<AccountState> rewrite(Predicate<AccountState> in, QueryOptions opts)
       throws QueryParseException {
-    if (!AccountPredicates.hasActive(in)) {
-      in = Predicate.and(in, AccountPredicates.isActive());
-    }
     AccountIndex index = indexes.getSearchIndex();
     checkNotNull(index, "no active search index configured for accounts");
     return new IndexedAccountQuery(index, in, opts);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
index 8796360..6ec1260 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountIndexerImpl.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.AccountIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.Index;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.index.Index;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
index 888ee4a..dcdf9e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AccountSchemaDefinitions.java
@@ -14,29 +14,33 @@
 
 package com.google.gerrit.server.index.account;
 
-import static com.google.gerrit.server.index.SchemaUtil.schema;
+import static com.google.gerrit.index.SchemaUtil.schema;
 
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.index.SchemaDefinitions;
 
 public class AccountSchemaDefinitions extends SchemaDefinitions<AccountState> {
   @Deprecated
-  static final Schema<AccountState> V1 =
+  static final Schema<AccountState> V4 =
       schema(
-          AccountField.ID,
           AccountField.ACTIVE,
           AccountField.EMAIL,
           AccountField.EXTERNAL_ID,
+          AccountField.FULL_NAME,
+          AccountField.ID,
           AccountField.NAME_PART,
           AccountField.REGISTERED,
-          AccountField.USERNAME);
+          AccountField.USERNAME,
+          AccountField.WATCHED_PROJECT);
 
-  @Deprecated static final Schema<AccountState> V2 = schema(V1, AccountField.WATCHED_PROJECT);
+  @Deprecated static final Schema<AccountState> V5 = schema(V4, AccountField.PREFERRED_EMAIL);
 
-  @Deprecated static final Schema<AccountState> V3 = schema(V2, AccountField.FULL_NAME);
+  @Deprecated
+  static final Schema<AccountState> V6 =
+      schema(V5, AccountField.REF_STATE, AccountField.EXTERNAL_ID_STATE);
 
-  static final Schema<AccountState> V4 = schema(V3);
+  static final Schema<AccountState> V7 = schema(V6, AccountField.PREFERRED_EMAIL_EXACT);
 
   public static final String NAME = "accounts";
   public static final AccountSchemaDefinitions INSTANCE = new AccountSchemaDefinitions();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
index c7c740b..c66ef30 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/AllAccountsIndexer.java
@@ -20,19 +20,17 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.Accounts;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.SiteIndexer;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -45,29 +43,29 @@
 public class AllAccountsIndexer extends SiteIndexer<Account.Id, AccountState, AccountIndex> {
   private static final Logger log = LoggerFactory.getLogger(AllAccountsIndexer.class);
 
-  private final SchemaFactory<ReviewDb> schemaFactory;
   private final ListeningExecutorService executor;
+  private final Accounts accounts;
   private final AccountCache accountCache;
 
   @Inject
   AllAccountsIndexer(
-      SchemaFactory<ReviewDb> schemaFactory,
       @IndexExecutor(BATCH) ListeningExecutorService executor,
+      Accounts accounts,
       AccountCache accountCache) {
-    this.schemaFactory = schemaFactory;
     this.executor = executor;
+    this.accounts = accounts;
     this.accountCache = accountCache;
   }
 
   @Override
-  public SiteIndexer.Result indexAll(final AccountIndex index) {
+  public SiteIndexer.Result indexAll(AccountIndex index) {
     ProgressMonitor progress = new TextProgressMonitor(newPrintWriter(progressOut));
     progress.start(2);
     Stopwatch sw = Stopwatch.createStarted();
     List<Account.Id> ids;
     try {
       ids = collectAccounts(progress);
-    } catch (OrmException e) {
+    } catch (IOException e) {
       log.error("Error collecting accounts", e);
       return new SiteIndexer.Result(sw, false, 0, 0);
     }
@@ -75,31 +73,28 @@
   }
 
   private SiteIndexer.Result reindexAccounts(
-      final AccountIndex index, List<Account.Id> ids, ProgressMonitor progress) {
+      AccountIndex index, List<Account.Id> ids, ProgressMonitor progress) {
     progress.beginTask("Reindexing accounts", ids.size());
     List<ListenableFuture<?>> futures = new ArrayList<>(ids.size());
     AtomicBoolean ok = new AtomicBoolean(true);
-    final AtomicInteger done = new AtomicInteger();
-    final AtomicInteger failed = new AtomicInteger();
+    AtomicInteger done = new AtomicInteger();
+    AtomicInteger failed = new AtomicInteger();
     Stopwatch sw = Stopwatch.createStarted();
-    for (final Account.Id id : ids) {
-      final String desc = "account " + id;
+    for (Account.Id id : ids) {
+      String desc = "account " + id;
       ListenableFuture<?> future =
           executor.submit(
-              new Callable<Void>() {
-                @Override
-                public Void call() throws Exception {
-                  try {
-                    accountCache.evict(id);
-                    index.replace(accountCache.get(id));
-                    verboseWriter.println("Reindexed " + desc);
-                    done.incrementAndGet();
-                  } catch (Exception e) {
-                    failed.incrementAndGet();
-                    throw e;
-                  }
-                  return null;
+              () -> {
+                try {
+                  accountCache.evict(id);
+                  index.replace(accountCache.get(id));
+                  verboseWriter.println("Reindexed " + desc);
+                  done.incrementAndGet();
+                } catch (Exception e) {
+                  failed.incrementAndGet();
+                  throw e;
                 }
+                return null;
               });
       addErrorListener(future, desc, progress, ok);
       futures.add(future);
@@ -116,13 +111,12 @@
     return new SiteIndexer.Result(sw, ok.get(), done.get(), failed.get());
   }
 
-  private List<Account.Id> collectAccounts(ProgressMonitor progress) throws OrmException {
+  private List<Account.Id> collectAccounts(ProgressMonitor progress) throws IOException {
     progress.beginTask("Collecting accounts", ProgressMonitor.UNKNOWN);
     List<Account.Id> ids = new ArrayList<>();
-    try (ReviewDb db = schemaFactory.open()) {
-      for (Account account : db.accounts().all()) {
-        ids.add(account.getId());
-      }
+    for (Account.Id accountId : accounts.allIds()) {
+      ids.add(accountId);
+      progress.update(1);
     }
     progress.endTask();
     return ids;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
index d5a2462..644f1eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/account/IndexedAccountQuery.java
@@ -16,15 +16,15 @@
 
 import static com.google.common.base.Preconditions.checkState;
 
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexedQuery;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.Matchable;
+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.AccountState;
-import com.google.gerrit.server.index.Index;
-import com.google.gerrit.server.index.IndexedQuery;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.query.DataSource;
-import com.google.gerrit.server.query.Matchable;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 public class IndexedAccountQuery extends IndexedQuery<Account.Id, AccountState>
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
index 58969d7..54bf0dc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/AllChangesIndexer.java
@@ -14,54 +14,45 @@
 
 package com.google.gerrit.server.index.change;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.util.concurrent.Futures.successfulAsList;
 import static com.google.common.util.concurrent.Futures.transform;
 import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
-import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
 import com.google.common.base.Stopwatch;
 import com.google.common.collect.ComparisonChain;
-import com.google.common.collect.ListMultimap;
-import com.google.common.collect.Lists;
-import com.google.common.collect.MultimapBuilder;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MultiProgressMonitor;
 import com.google.gerrit.server.git.MultiProgressMonitor.Task;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.SiteIndexer;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeNotes.Factory.ChangeNotesResult;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import java.io.IOException;
-import java.io.PrintWriter;
 import java.util.ArrayList;
-import java.util.Collection;
-import java.util.Iterator;
 import java.util.List;
-import java.util.Map;
+import java.util.Objects;
 import java.util.SortedSet;
 import java.util.TreeSet;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ProgressMonitor;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.lib.TextProgressMonitor;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -95,16 +86,17 @@
   }
 
   private static class ProjectHolder implements Comparable<ProjectHolder> {
-    private Project.NameKey name;
-    private int size;
+    final Project.NameKey name;
+    private final long size;
 
-    ProjectHolder(Project.NameKey name, int size) {
+    ProjectHolder(Project.NameKey name, long size) {
       this.name = name;
       this.size = size;
     }
 
     @Override
     public int compareTo(ProjectHolder other) {
+      // Sort projects based on size first to maximize utilization of threads early on.
       return ComparisonChain.start()
           .compare(other.size, size)
           .compare(other.name.get(), name.get())
@@ -122,7 +114,7 @@
     int projectsFailed = 0;
     for (Project.NameKey name : projectCache.all()) {
       try (Repository repo = repoManager.openRepository(name)) {
-        int size = ChangeNotes.Factory.scan(repo).size();
+        long size = estimateSize(repo);
         changeCount += size;
         projects.add(new ProjectHolder(name, size));
       } catch (IOException e) {
@@ -140,31 +132,36 @@
     return indexAll(index, projects);
   }
 
-  public SiteIndexer.Result indexAll(ChangeIndex index, Iterable<ProjectHolder> projects) {
+  private long estimateSize(Repository repo) throws IOException {
+    // Estimate size based on IDs that show up in ref names. This is not perfect, since patch set
+    // refs may exist for changes whose metadata was never successfully stored. But that's ok, as
+    // the estimate is just used as a heuristic for sorting projects.
+    return repo.getRefDatabase()
+        .getRefs(RefNames.REFS_CHANGES)
+        .values()
+        .stream()
+        .map(r -> Change.Id.fromRef(r.getName()))
+        .filter(Objects::nonNull)
+        .distinct()
+        .count();
+  }
+
+  private SiteIndexer.Result indexAll(ChangeIndex index, SortedSet<ProjectHolder> projects) {
     Stopwatch sw = Stopwatch.createStarted();
-    final MultiProgressMonitor mpm = new MultiProgressMonitor(progressOut, "Reindexing changes");
-    final Task projTask =
-        mpm.beginSubTask(
-            "projects",
-            (projects instanceof Collection)
-                ? ((Collection<?>) projects).size()
-                : MultiProgressMonitor.UNKNOWN);
-    final Task doneTask =
-        mpm.beginSubTask(null, totalWork >= 0 ? totalWork : MultiProgressMonitor.UNKNOWN);
-    final Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
+    MultiProgressMonitor mpm = new MultiProgressMonitor(progressOut, "Reindexing changes");
+    Task projTask = mpm.beginSubTask("projects", projects.size());
+    checkState(totalWork >= 0);
+    Task doneTask = mpm.beginSubTask(null, totalWork);
+    Task failedTask = mpm.beginSubTask("failed", MultiProgressMonitor.UNKNOWN);
 
-    final List<ListenableFuture<?>> futures = new ArrayList<>();
-    final AtomicBoolean ok = new AtomicBoolean(true);
+    List<ListenableFuture<?>> futures = new ArrayList<>();
+    AtomicBoolean ok = new AtomicBoolean(true);
 
-    for (final ProjectHolder project : projects) {
+    for (ProjectHolder project : projects) {
       ListenableFuture<?> future =
           executor.submit(
               reindexProject(
-                  indexerFactory.create(executor, index),
-                  project.name,
-                  doneTask,
-                  failedTask,
-                  verboseWriter));
+                  indexerFactory.create(executor, index), project.name, doneTask, failedTask));
       addErrorListener(future, "project " + project.name, projTask, ok);
       futures.add(future);
     }
@@ -199,117 +196,57 @@
   }
 
   public Callable<Void> reindexProject(
-      final ChangeIndexer indexer,
-      final Project.NameKey project,
-      final Task done,
-      final Task failed,
-      final PrintWriter verboseWriter) {
-    return new Callable<Void>() {
-      @Override
-      public Void call() throws Exception {
-        ListMultimap<ObjectId, ChangeData> byId =
-            MultimapBuilder.hashKeys().arrayListValues().build();
-        // TODO(dborowitz): Opening all repositories in a live server may be
-        // wasteful; see if we can determine which ones it is safe to close
-        // with RepositoryCache.close(repo).
-        try (Repository repo = repoManager.openRepository(project);
-            ReviewDb db = schemaFactory.open()) {
-          Map<String, Ref> refs = repo.getRefDatabase().getRefs(ALL);
-          // TODO(dborowitz): Pre-loading all notes is almost certainly a
-          // terrible idea for performance. If we can get rid of walking by
-          // commit (see note below), then all we need to discover here is the
-          // change IDs.
-          for (ChangeNotes cn : notesFactory.scan(repo, db, project)) {
-            Ref r = refs.get(cn.getChange().currentPatchSetId().toRefName());
-            if (r != null) {
-              byId.put(r.getObjectId(), changeDataFactory.create(db, cn));
-            }
-          }
-          new ProjectIndexer(indexer, byId, repo, done, failed, verboseWriter).call();
-        } catch (RepositoryNotFoundException rnfe) {
-          log.error(rnfe.getMessage());
-        }
-        return null;
-      }
-
-      @Override
-      public String toString() {
-        return "Index all changes of project " + project.get();
-      }
-    };
+      ChangeIndexer indexer, Project.NameKey project, Task done, Task failed) {
+    return new ProjectIndexer(indexer, project, done, failed);
   }
 
-  private static class ProjectIndexer implements Callable<Void> {
+  private class ProjectIndexer implements Callable<Void> {
     private final ChangeIndexer indexer;
-    private final ListMultimap<ObjectId, ChangeData> byId;
+    private final Project.NameKey project;
     private final ProgressMonitor done;
     private final ProgressMonitor failed;
-    private final PrintWriter verboseWriter;
-    private final Repository repo;
 
     private ProjectIndexer(
         ChangeIndexer indexer,
-        ListMultimap<ObjectId, ChangeData> changesByCommitId,
-        Repository repo,
+        Project.NameKey project,
         ProgressMonitor done,
-        ProgressMonitor failed,
-        PrintWriter verboseWriter) {
+        ProgressMonitor failed) {
       this.indexer = indexer;
-      this.byId = changesByCommitId;
-      this.repo = repo;
+      this.project = project;
       this.done = done;
       this.failed = failed;
-      this.verboseWriter = verboseWriter;
     }
 
     @Override
     public Void call() throws Exception {
-      try (RevWalk walk = new RevWalk(repo)) {
-        // Walk only refs first to cover as many changes as we can without having
-        // to mark every single change.
-        for (Ref ref : repo.getRefDatabase().getRefs(Constants.R_HEADS).values()) {
-          RevObject o = walk.parseAny(ref.getObjectId());
-          if (o instanceof RevCommit) {
-            walk.markStart((RevCommit) o);
-          }
-        }
-
-        RevCommit bCommit;
-        while ((bCommit = walk.next()) != null && !byId.isEmpty()) {
-          if (byId.containsKey(bCommit)) {
-            index(bCommit);
-            byId.removeAll(bCommit);
-          }
-        }
-
-        for (ObjectId id : byId.keySet()) {
-          index(id);
-        }
+      try (Repository repo = repoManager.openRepository(project);
+          ReviewDb db = schemaFactory.open()) {
+        // Order of scanning changes is undefined. This is ok if we assume that packfile locality is
+        // not important for indexing, since sites should have a fully populated DiffSummary cache.
+        // It does mean that reindexing after invalidating the DiffSummary cache will be expensive,
+        // but the goal is to invalidate that cache as infrequently as we possibly can. And besides,
+        // we don't have concrete proof that improving packfile locality would help.
+        notesFactory.scan(repo, db, project).forEach(r -> index(db, r));
+      } catch (RepositoryNotFoundException rnfe) {
+        log.error(rnfe.getMessage());
       }
       return null;
     }
 
-    private void index(ObjectId b) throws Exception {
-      List<ChangeData> cds = Lists.newArrayList(byId.get(b));
+    private void index(ReviewDb db, ChangeNotesResult r) {
+      if (r.error().isPresent()) {
+        fail("Failed to read change " + r.id() + " for indexing", true, r.error().get());
+        return;
+      }
       try {
-        if (!cds.isEmpty()) {
-          Iterator<ChangeData> cdit = cds.iterator();
-          for (ChangeData cd; cdit.hasNext(); cdit.remove()) {
-            cd = cdit.next();
-            try {
-              indexer.index(cd);
-              done.update(1);
-              verboseWriter.println("Reindexed change " + cd.getId());
-            } catch (Exception e) {
-              fail("Failed to index change " + cd.getId(), true, e);
-            }
-          }
-        }
+        indexer.index(changeDataFactory.create(db, r.notes()));
+        done.update(1);
+        verboseWriter.println("Reindexed change " + r.id());
+      } catch (RejectedExecutionException e) {
+        // Server shutdown, don't spam the logs.
+        failSilently();
       } catch (Exception e) {
-        fail("Failed to index commit " + b.name(), false, e);
-        for (ChangeData cd : cds) {
-          fail("Failed to index change " + cd.getId(), true, null);
-        }
+        fail("Failed to index change " + r.id(), true, e);
       }
     }
 
@@ -326,5 +263,14 @@
 
       verboseWriter.println(error);
     }
+
+    private void failSilently() {
+      this.failed.update(1);
+    }
+
+    @Override
+    public String toString() {
+      return "Index all changes of project " + project.get();
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
index b8acadc..1f8b540 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeField.java
@@ -16,13 +16,13 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.index.FieldDef.exact;
-import static com.google.gerrit.server.index.FieldDef.fullText;
-import static com.google.gerrit.server.index.FieldDef.intRange;
-import static com.google.gerrit.server.index.FieldDef.integer;
-import static com.google.gerrit.server.index.FieldDef.prefix;
-import static com.google.gerrit.server.index.FieldDef.storedOnly;
-import static com.google.gerrit.server.index.FieldDef.timestamp;
+import static com.google.gerrit.index.FieldDef.exact;
+import static com.google.gerrit.index.FieldDef.fullText;
+import static com.google.gerrit.index.FieldDef.intRange;
+import static com.google.gerrit.index.FieldDef.integer;
+import static com.google.gerrit.index.FieldDef.prefix;
+import static com.google.gerrit.index.FieldDef.storedOnly;
+import static com.google.gerrit.index.FieldDef.timestamp;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toSet;
@@ -34,25 +34,25 @@
 import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Sets;
 import com.google.common.collect.Table;
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
-import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.StarredChangesUtil;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.SchemaUtil;
-import com.google.gerrit.server.index.change.StalenessChecker.RefState;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
@@ -73,9 +73,12 @@
 import java.util.Collection;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
+import java.util.Objects;
 import java.util.Set;
 import java.util.function.Function;
-import org.eclipse.jgit.revwalk.FooterLine;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.PersonIdent;
 
 /**
  * Fields indexed on change documents.
@@ -142,10 +145,13 @@
           .buildRepeatable(cd -> firstNonNull(cd.currentFilePaths(), ImmutableList.of()));
 
   public static Set<String> getFileParts(ChangeData cd) throws OrmException {
-    List<String> paths = cd.currentFilePaths();
-    if (paths == null) {
-      return ImmutableSet.of();
+    List<String> paths;
+    try {
+      paths = cd.currentFilePaths();
+    } catch (IOException e) {
+      throw new OrmException(e);
     }
+
     Splitter s = Splitter.on('/').omitEmptyStrings();
     Set<String> r = new HashSet<>();
     for (String path : paths) {
@@ -184,6 +190,29 @@
   public static final FieldDef<ChangeData, Iterable<String>> REVIEWER =
       exact("reviewer2").stored().buildRepeatable(cd -> getReviewerFieldValues(cd.reviewers()));
 
+  /** Reviewer(s) associated with the change that do not have a gerrit account. */
+  public static final FieldDef<ChangeData, Iterable<String>> REVIEWER_BY_EMAIL =
+      exact("reviewer_by_email")
+          .stored()
+          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.reviewersByEmail()));
+
+  /** Reviewer(s) modified during change's current WIP phase. */
+  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER =
+      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER)
+          .stored()
+          .buildRepeatable(cd -> getReviewerFieldValues(cd.pendingReviewers()));
+
+  /** Reviewer(s) by email modified during change's current WIP phase. */
+  public static final FieldDef<ChangeData, Iterable<String>> PENDING_REVIEWER_BY_EMAIL =
+      exact(ChangeQueryBuilder.FIELD_PENDING_REVIEWER_BY_EMAIL)
+          .stored()
+          .buildRepeatable(cd -> getReviewerByEmailFieldValues(cd.pendingReviewersByEmail()));
+
+  /** References a change that this change reverts. */
+  public static final FieldDef<ChangeData, Integer> REVERT_OF =
+      integer(ChangeQueryBuilder.FIELD_REVERTOF)
+          .build(cd -> cd.change().getRevertOf() != null ? cd.change().getRevertOf().get() : null);
+
   @VisibleForTesting
   static List<String> getReviewerFieldValues(ReviewerSet reviewers) {
     List<String> r = new ArrayList<>(reviewers.asTable().size() * 2);
@@ -200,6 +229,27 @@
     return state.toString() + ',' + id;
   }
 
+  @VisibleForTesting
+  static List<String> getReviewerByEmailFieldValues(ReviewerByEmailSet reviewersByEmail) {
+    List<String> r = new ArrayList<>(reviewersByEmail.asTable().size() * 2);
+    for (Table.Cell<ReviewerStateInternal, Address, Timestamp> c :
+        reviewersByEmail.asTable().cellSet()) {
+      String v = getReviewerByEmailFieldValue(c.getRowKey(), c.getColumnKey());
+      r.add(v);
+      if (c.getColumnKey().getName() != null) {
+        // Add another entry without the name to provide search functionality on the email
+        Address emailOnly = new Address(c.getColumnKey().getEmail());
+        r.add(getReviewerByEmailFieldValue(c.getRowKey(), emailOnly));
+      }
+      r.add(v + ',' + c.getValue().getTime());
+    }
+    return r;
+  }
+
+  public static String getReviewerByEmailFieldValue(ReviewerStateInternal state, Address adr) {
+    return state.toString() + ',' + adr;
+  }
+
   public static ReviewerSet parseReviewerFieldValues(Iterable<String> values) {
     ImmutableTable.Builder<ReviewerStateInternal, Account.Id, Timestamp> b =
         ImmutableTable.builder();
@@ -220,6 +270,25 @@
     return ReviewerSet.fromTable(b.build());
   }
 
+  public static ReviewerByEmailSet parseReviewerByEmailFieldValues(Iterable<String> values) {
+    ImmutableTable.Builder<ReviewerStateInternal, Address, Timestamp> b = ImmutableTable.builder();
+    for (String v : values) {
+      int f = v.indexOf(',');
+      if (f < 0) {
+        continue;
+      }
+      int l = v.lastIndexOf(',');
+      if (l == f) {
+        continue;
+      }
+      b.put(
+          ReviewerStateInternal.valueOf(v.substring(0, f)),
+          Address.parse(v.substring(f + 1, l)),
+          new Timestamp(Long.valueOf(v.substring(l + 1, v.length()))));
+    }
+    return ReviewerByEmailSet.fromTable(b.build());
+  }
+
   /** Commit ID of any patch set on the change, using prefix match. */
   public static final FieldDef<ChangeData, Iterable<String>> COMMIT =
       prefix(ChangeQueryBuilder.FIELD_COMMIT).buildRepeatable(ChangeField::getRevisions);
@@ -241,22 +310,10 @@
   /** Tracking id extracted from a footer. */
   public static final FieldDef<ChangeData, Iterable<String>> TR =
       exact(ChangeQueryBuilder.FIELD_TR)
-          .buildRepeatable(
-              (ChangeData cd, FillArgs a) -> {
-                List<FooterLine> footers = cd.commitFooters();
-                if (footers == null) {
-                  return ImmutableSet.of();
-                }
-                return Sets.newHashSet(a.trackingFooters.extract(footers).values());
-              });
-
-  /** List of labels on the current patch set. */
-  @Deprecated
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
-      exact(ChangeQueryBuilder.FIELD_LABEL).buildRepeatable(cd -> getLabels(cd, false));
+          .buildRepeatable(cd -> ImmutableSet.copyOf(cd.trackingFooters().values()));
 
   /** List of labels on the current patch set including change owner votes. */
-  public static final FieldDef<ChangeData, Iterable<String>> LABEL2 =
+  public static final FieldDef<ChangeData, Iterable<String>> LABEL =
       exact("label2").buildRepeatable(cd -> getLabels(cd, true));
 
   private static Iterable<String> getLabels(ChangeData cd, boolean owners) throws OrmException {
@@ -280,10 +337,36 @@
     return SchemaUtil.getPersonParts(cd.getAuthor());
   }
 
+  public static Set<String> getAuthorNameAndEmail(ChangeData cd) throws OrmException, IOException {
+    return getNameAndEmail(cd.getAuthor());
+  }
+
   public static Set<String> getCommitterParts(ChangeData cd) throws OrmException, IOException {
     return SchemaUtil.getPersonParts(cd.getCommitter());
   }
 
+  public static Set<String> getCommitterNameAndEmail(ChangeData cd)
+      throws OrmException, IOException {
+    return getNameAndEmail(cd.getCommitter());
+  }
+
+  private static Set<String> getNameAndEmail(PersonIdent person) {
+    if (person == null) {
+      return ImmutableSet.of();
+    }
+
+    String name = person.getName().toLowerCase(Locale.US);
+    String email = person.getEmailAddress().toLowerCase(Locale.US);
+
+    StringBuilder nameEmailBuilder = new StringBuilder();
+    PersonIdent.appendSanitized(nameEmailBuilder, name);
+    nameEmailBuilder.append(" <");
+    PersonIdent.appendSanitized(nameEmailBuilder, email);
+    nameEmailBuilder.append('>');
+
+    return ImmutableSet.of(name, email, nameEmailBuilder.toString());
+  }
+
   /**
    * The exact email address, or any part of the author name or email address, in the current patch
    * set.
@@ -291,6 +374,11 @@
   public static final FieldDef<ChangeData, Iterable<String>> AUTHOR =
       fullText(ChangeQueryBuilder.FIELD_AUTHOR).buildRepeatable(ChangeField::getAuthorParts);
 
+  /** The exact name, email address and NameEmail of the author. */
+  public static final FieldDef<ChangeData, Iterable<String>> EXACT_AUTHOR =
+      exact(ChangeQueryBuilder.FIELD_EXACTAUTHOR)
+          .buildRepeatable(ChangeField::getAuthorNameAndEmail);
+
   /**
    * The exact email address, or any part of the committer name or email address, in the current
    * patch set.
@@ -298,6 +386,11 @@
   public static final FieldDef<ChangeData, Iterable<String>> COMMITTER =
       fullText(ChangeQueryBuilder.FIELD_COMMITTER).buildRepeatable(ChangeField::getCommitterParts);
 
+  /** The exact name, email address, and NameEmail of the committer. */
+  public static final FieldDef<ChangeData, Iterable<String>> EXACT_COMMITTER =
+      exact(ChangeQueryBuilder.FIELD_EXACTCOMMITTER)
+          .buildRepeatable(ChangeField::getCommitterNameAndEmail);
+
   public static final ProtobufCodec<Change> CHANGE_CODEC = CodecFactory.encoder(Change.class);
 
   /** Serialized change object, used for pre-populating results. */
@@ -338,16 +431,11 @@
   public static final FieldDef<ChangeData, Iterable<String>> COMMENT =
       fullText(ChangeQueryBuilder.FIELD_COMMENT)
           .buildRepeatable(
-              cd -> {
-                Set<String> r = new HashSet<>();
-                for (Comment c : cd.publishedComments()) {
-                  r.add(c.message);
-                }
-                for (ChangeMessage m : cd.messages()) {
-                  r.add(m.getMessage());
-                }
-                return r;
-              });
+              cd ->
+                  Stream.concat(
+                          cd.publishedComments().stream().map(c -> c.message),
+                          cd.messages().stream().map(ChangeMessage::getMessage))
+                      .collect(toSet()));
 
   /** Number of unresolved comments of the change. */
   public static final FieldDef<ChangeData, Integer> UNRESOLVED_COMMENT_COUNT =
@@ -385,22 +473,30 @@
       intRange(ChangeQueryBuilder.FIELD_DELTA)
           .build(cd -> cd.changedLines().map(c -> c.insertions + c.deletions).orElse(null));
 
+  /** Determines if this change is private. */
+  public static final FieldDef<ChangeData, String> PRIVATE =
+      exact(ChangeQueryBuilder.FIELD_PRIVATE).build(cd -> cd.change().isPrivate() ? "1" : "0");
+
+  /** Determines if this change is work in progress. */
+  public static final FieldDef<ChangeData, String> WIP =
+      exact(ChangeQueryBuilder.FIELD_WIP).build(cd -> cd.change().isWorkInProgress() ? "1" : "0");
+
+  /** Determines if this change has started review. */
+  public static final FieldDef<ChangeData, String> STARTED =
+      exact(ChangeQueryBuilder.FIELD_STARTED)
+          .build(cd -> cd.change().hasReviewStarted() ? "1" : "0");
+
   /** Users who have commented on this change. */
   public static final FieldDef<ChangeData, Iterable<Integer>> COMMENTBY =
       integer(ChangeQueryBuilder.FIELD_COMMENTBY)
           .buildRepeatable(
-              cd -> {
-                Set<Integer> r = new HashSet<>();
-                for (ChangeMessage m : cd.messages()) {
-                  if (m.getAuthor() != null) {
-                    r.add(m.getAuthor().get());
-                  }
-                }
-                for (Comment c : cd.publishedComments()) {
-                  r.add(c.author.getId().get());
-                }
-                return r;
-              });
+              cd ->
+                  Stream.concat(
+                          cd.messages().stream().map(ChangeMessage::getAuthor),
+                          cd.publishedComments().stream().map(c -> c.author.getId()))
+                      .filter(Objects::nonNull)
+                      .map(Account.Id::get)
+                      .collect(toSet()));
 
   /** Star labels on this change in the format: &lt;account-id&gt;:&lt;label&gt; */
   public static final FieldDef<ChangeData, Iterable<String>> STAR =
@@ -423,13 +519,8 @@
   public static final FieldDef<ChangeData, Iterable<String>> GROUP =
       exact(ChangeQueryBuilder.FIELD_GROUP)
           .buildRepeatable(
-              cd -> {
-                Set<String> r = Sets.newHashSetWithExpectedSize(1);
-                for (PatchSet ps : cd.patchSets()) {
-                  r.addAll(ps.getGroups());
-                }
-                return r;
-              });
+              cd ->
+                  cd.patchSets().stream().flatMap(ps -> ps.getGroups().stream()).collect(toSet()));
 
   public static final ProtobufCodec<PatchSet> PATCH_SET_CODEC =
       CodecFactory.encoder(PatchSet.class);
@@ -469,17 +560,13 @@
                 if (reviewedBy.isEmpty()) {
                   return ImmutableSet.of(NOT_REVIEWED);
                 }
-                List<Integer> result = new ArrayList<>(reviewedBy.size());
-                for (Account.Id id : reviewedBy) {
-                  result.add(id.get());
-                }
-                return result;
+                return reviewedBy.stream().map(Account.Id::get).collect(toList());
               });
 
   // Submit rule options in this class should never use fastEvalLabels. This
   // slows down indexing slightly but produces correct search results.
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_LENIENT =
-      SubmitRuleOptions.defaults().allowClosed(true).allowDraft(true).build();
+      SubmitRuleOptions.defaults().allowClosed(true).build();
 
   public static final SubmitRuleOptions SUBMIT_RULE_OPTIONS_STRICT =
       SubmitRuleOptions.defaults().build();
@@ -615,7 +702,7 @@
   public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE =
       storedOnly("ref_state")
           .buildRepeatable(
-              (cd, a) -> {
+              cd -> {
                 List<byte[]> result = new ArrayList<>();
                 Project.NameKey project = cd.change().getProject();
 
@@ -624,7 +711,7 @@
                     .forEach(r -> result.add(RefState.of(r).toByteArray(project)));
                 cd.starRefs()
                     .values()
-                    .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(a.allUsers)));
+                    .forEach(r -> result.add(RefState.of(r.ref()).toByteArray(allUsers(cd))));
 
                 if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) {
                   ChangeNotes notes = cd.notes();
@@ -637,7 +724,7 @@
                           .toByteArray(project));
                   cd.draftRefs()
                       .values()
-                      .forEach(r -> result.add(RefState.of(r).toByteArray(a.allUsers)));
+                      .forEach(r -> result.add(RefState.of(r).toByteArray(allUsers(cd))));
                 }
 
                 return result;
@@ -652,7 +739,7 @@
   public static final FieldDef<ChangeData, Iterable<byte[]>> REF_STATE_PATTERN =
       storedOnly("ref_state_pattern")
           .buildRepeatable(
-              (cd, a) -> {
+              cd -> {
                 Change.Id id = cd.getId();
                 Project.NameKey project = cd.change().getProject();
                 List<byte[]> result = new ArrayList<>(3);
@@ -662,11 +749,11 @@
                         .toByteArray(project));
                 result.add(
                     RefStatePattern.create(RefNames.refsStarredChangesPrefix(id) + "*")
-                        .toByteArray(a.allUsers));
+                        .toByteArray(allUsers(cd)));
                 if (PrimaryStorage.of(cd.change()) == PrimaryStorage.NOTE_DB) {
                   result.add(
                       RefStatePattern.create(RefNames.refsDraftCommentsPrefix(id) + "*")
-                          .toByteArray(a.allUsers));
+                          .toByteArray(allUsers(cd)));
                 }
                 return result;
               });
@@ -700,4 +787,8 @@
   private static <T> FieldDef.Getter<ChangeData, T> changeGetter(Function<Change, T> func) {
     return in -> in.change() != null ? func.apply(in.change()) : null;
   }
+
+  private static AllUsersName allUsers(ChangeData cd) {
+    return cd.getAllUsersNameForIndexing();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
index 27b0c26..855bfe9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndex.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.index.change;
 
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.Index;
-import com.google.gerrit.server.index.IndexDefinition;
-import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.LegacyChangeIdPredicate;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
index f8acb74..a353a2a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexCollection.java
@@ -15,15 +15,13 @@
 package com.google.gerrit.server.index.change;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexCollection;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
 public class ChangeIndexCollection extends IndexCollection<Change.Id, ChangeData, ChangeIndex> {
-  @Inject
   @VisibleForTesting
   public ChangeIndexCollection() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
index 4404298..7945429 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexDefinition.java
@@ -15,11 +15,10 @@
 package com.google.gerrit.server.index.change;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexDefinition;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.Inject;
-import com.google.inject.util.Providers;
 
 public class ChangeIndexDefinition extends IndexDefinition<Change.Id, ChangeData, ChangeIndex> {
 
@@ -28,10 +27,6 @@
       ChangeIndexCollection indexCollection,
       ChangeIndex.Factory indexFactory,
       @Nullable AllChangesIndexer allChangesIndexer) {
-    super(
-        ChangeSchemaDefinitions.INSTANCE,
-        indexCollection,
-        indexFactory,
-        Providers.of(allChangesIndexer));
+    super(ChangeSchemaDefinitions.INSTANCE, indexCollection, indexFactory, allChangesIndexer);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
index a9e1362..824fd4f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexRewriter.java
@@ -18,18 +18,20 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.AndPredicate;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.NotPredicate;
+import com.google.gerrit.index.query.OrPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
-import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.IndexRewriter;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.query.AndPredicate;
-import com.google.gerrit.server.query.LimitPredicate;
-import com.google.gerrit.server.query.NotPredicate;
-import com.google.gerrit.server.query.OrPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.AndChangeSource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
@@ -81,7 +83,8 @@
 
   private static EnumSet<Change.Status> extractStatus(Predicate<ChangeData> in) {
     if (in instanceof ChangeStatusPredicate) {
-      return EnumSet.of(((ChangeStatusPredicate) in).getStatus());
+      Status status = ((ChangeStatusPredicate) in).getStatus();
+      return status != null ? EnumSet.of(status) : null;
     } else if (in instanceof NotPredicate) {
       EnumSet<Status> s = extractStatus(in.getChild(0));
       return s != null ? EnumSet.complementOf(s) : null;
@@ -188,6 +191,9 @@
       // and included that in their limit computation.
       return new LimitPredicate<>(ChangeQueryBuilder.FIELD_LIMIT, opts.limit());
     } else if (!isRewritePossible(in)) {
+      if (in instanceof IndexPredicate) {
+        throw new QueryParseException("Unsupported index predicate: " + in.toString());
+      }
       return null; // magic to indicate "in" cannot be rewritten
     }
 
@@ -195,6 +201,7 @@
     BitSet isIndexed = new BitSet(n);
     BitSet notIndexed = new BitSet(n);
     BitSet rewritten = new BitSet(n);
+    BitSet changeSource = new BitSet(n);
     List<Predicate<ChangeData>> newChildren = Lists.newArrayListWithCapacity(n);
     for (int i = 0; i < n; i++) {
       Predicate<ChangeData> c = in.getChild(i);
@@ -206,6 +213,9 @@
         notIndexed.set(i);
         newChildren.add(c);
       } else {
+        if (nc instanceof ChangeDataSource) {
+          changeSource.set(i);
+        }
         rewritten.set(i);
         newChildren.add(nc);
       }
@@ -216,7 +226,11 @@
     } else if (notIndexed.cardinality() == n) {
       return null; // Can't rewrite any children, so cannot rewrite in.
     } else if (rewritten.cardinality() == n) {
-      return in.copy(newChildren); // All children were rewritten.
+      // All children were rewritten.
+      if (changeSource.cardinality() == n) {
+        return copy(in, newChildren);
+      }
+      return in.copy(newChildren);
     }
     return partitionChildren(in, newChildren, isIndexed, index, opts);
   }
@@ -226,7 +240,10 @@
       return false;
     }
     IndexPredicate<ChangeData> p = (IndexPredicate<ChangeData>) in;
-    return index.getSchema().hasField(p.getField());
+
+    FieldDef<ChangeData, ?> def = p.getField();
+    Schema<ChangeData> schema = index.getSchema();
+    return schema.hasField(def);
   }
 
   private Predicate<ChangeData> partitionChildren(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
index eb5ccb1..f1a7e85 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeIndexer.java
@@ -24,12 +24,12 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.extensions.events.ChangeIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.Index;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexExecutor;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -166,7 +166,7 @@
   }
 
   private static boolean autoReindexIfStale(Config cfg) {
-    return cfg.getBoolean("index", null, "autoReindexIfStale", true);
+    return cfg.getBoolean("index", null, "autoReindexIfStale", false);
   }
 
   /**
@@ -206,7 +206,7 @@
     for (Index<?, ChangeData> i : getWriteIndexes()) {
       i.replace(cd);
     }
-    fireChangeIndexedEvent(cd.getId().get());
+    fireChangeIndexedEvent(cd.project().get(), cd.getId().get());
 
     // Always double-check whether the change might be stale immediately after
     // interactively indexing it. This fixes up the case where two writers write
@@ -229,10 +229,10 @@
     autoReindexIfStale(cd);
   }
 
-  private void fireChangeIndexedEvent(int id) {
+  private void fireChangeIndexedEvent(String projectName, int id) {
     for (ChangeIndexedListener listener : indexedListeners) {
       try {
-        listener.onChangeIndexed(id);
+        listener.onChangeIndexed(projectName, id);
       } catch (Exception e) {
         logEventListenerError(listener, e);
       }
@@ -312,12 +312,8 @@
     return submit(new ReindexIfStaleTask(project, id), batchExecutor);
   }
 
-  private void autoReindexIfStale(ChangeData cd) throws IOException {
-    try {
-      autoReindexIfStale(cd.project(), cd.getId());
-    } catch (OrmException e) {
-      throw new IOException(e);
-    }
+  private void autoReindexIfStale(ChangeData cd) {
+    autoReindexIfStale(cd.project(), cd.getId());
   }
 
   private void autoReindexIfStale(Project.NameKey project, Change.Id id) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
index d988612..95bdaab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ChangeSchemaDefinitions.java
@@ -14,83 +14,87 @@
 
 package com.google.gerrit.server.index.change;
 
-import static com.google.gerrit.server.index.SchemaUtil.schema;
+import static com.google.gerrit.index.SchemaUtil.schema;
 
-import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.index.SchemaDefinitions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.server.query.change.ChangeData;
 
 public class ChangeSchemaDefinitions extends SchemaDefinitions<ChangeData> {
   @Deprecated
-  static final Schema<ChangeData> V32 =
+  static final Schema<ChangeData> V39 =
       schema(
-          ChangeField.LEGACY_ID,
+          ChangeField.ADDED,
+          ChangeField.APPROVAL,
+          ChangeField.ASSIGNEE,
+          ChangeField.AUTHOR,
+          ChangeField.CHANGE,
+          ChangeField.COMMENT,
+          ChangeField.COMMENTBY,
+          ChangeField.COMMIT,
+          ChangeField.COMMITTER,
+          ChangeField.COMMIT_MESSAGE,
+          ChangeField.DELETED,
+          ChangeField.DELTA,
+          ChangeField.DRAFTBY,
+          ChangeField.EDITBY,
+          ChangeField.EXACT_COMMIT,
+          ChangeField.EXACT_TOPIC,
+          ChangeField.FILE_PART,
+          ChangeField.FUZZY_TOPIC,
+          ChangeField.GROUP,
+          ChangeField.HASHTAG,
+          ChangeField.HASHTAG_CASE_AWARE,
           ChangeField.ID,
-          ChangeField.STATUS,
+          ChangeField.LABEL,
+          ChangeField.LEGACY_ID,
+          ChangeField.MERGEABLE,
+          ChangeField.OWNER,
+          ChangeField.PATCH_SET,
+          ChangeField.PATH,
           ChangeField.PROJECT,
           ChangeField.PROJECTS,
           ChangeField.REF,
-          ChangeField.EXACT_TOPIC,
-          ChangeField.FUZZY_TOPIC,
-          ChangeField.UPDATED,
-          ChangeField.FILE_PART,
-          ChangeField.PATH,
-          ChangeField.OWNER,
-          ChangeField.COMMIT,
-          ChangeField.TR,
-          ChangeField.LABEL,
-          ChangeField.COMMIT_MESSAGE,
-          ChangeField.COMMENT,
-          ChangeField.CHANGE,
-          ChangeField.APPROVAL,
-          ChangeField.MERGEABLE,
-          ChangeField.ADDED,
-          ChangeField.DELETED,
-          ChangeField.DELTA,
-          ChangeField.HASHTAG,
-          ChangeField.COMMENTBY,
-          ChangeField.PATCH_SET,
-          ChangeField.GROUP,
-          ChangeField.SUBMISSIONID,
-          ChangeField.EDITBY,
+          ChangeField.REF_STATE,
+          ChangeField.REF_STATE_PATTERN,
           ChangeField.REVIEWEDBY,
-          ChangeField.EXACT_COMMIT,
-          ChangeField.AUTHOR,
-          ChangeField.COMMITTER,
-          ChangeField.DRAFTBY,
-          ChangeField.HASHTAG_CASE_AWARE,
+          ChangeField.REVIEWER,
           ChangeField.STAR,
           ChangeField.STARBY,
-          ChangeField.REVIEWER);
-
-  @Deprecated static final Schema<ChangeData> V33 = schema(V32, ChangeField.ASSIGNEE);
-
-  @Deprecated
-  static final Schema<ChangeData> V34 =
-      new Schema.Builder<ChangeData>()
-          .add(V33)
-          .remove(ChangeField.LABEL)
-          .add(ChangeField.LABEL2)
-          .build();
-
-  @Deprecated
-  static final Schema<ChangeData> V35 =
-      schema(
-          V34,
-          ChangeField.SUBMIT_RECORD,
+          ChangeField.STATUS,
           ChangeField.STORED_SUBMIT_RECORD_LENIENT,
-          ChangeField.STORED_SUBMIT_RECORD_STRICT);
+          ChangeField.STORED_SUBMIT_RECORD_STRICT,
+          ChangeField.SUBMISSIONID,
+          ChangeField.SUBMIT_RECORD,
+          ChangeField.TR,
+          ChangeField.UNRESOLVED_COMMENT_COUNT,
+          ChangeField.UPDATED);
+
+  @Deprecated static final Schema<ChangeData> V40 = schema(V39, ChangeField.PRIVATE);
+  @Deprecated static final Schema<ChangeData> V41 = schema(V40, ChangeField.REVIEWER_BY_EMAIL);
+  @Deprecated static final Schema<ChangeData> V42 = schema(V41, ChangeField.WIP);
 
   @Deprecated
-  static final Schema<ChangeData> V36 =
-      schema(V35, ChangeField.REF_STATE, ChangeField.REF_STATE_PATTERN);
-
-  @Deprecated static final Schema<ChangeData> V37 = schema(V36);
+  static final Schema<ChangeData> V43 =
+      schema(V42, ChangeField.EXACT_AUTHOR, ChangeField.EXACT_COMMITTER);
 
   @Deprecated
-  static final Schema<ChangeData> V38 = schema(V37, ChangeField.UNRESOLVED_COMMENT_COUNT);
+  static final Schema<ChangeData> V44 =
+      schema(
+          V43,
+          ChangeField.STARTED,
+          ChangeField.PENDING_REVIEWER,
+          ChangeField.PENDING_REVIEWER_BY_EMAIL);
 
-  static final Schema<ChangeData> V39 = schema(V38);
+  @Deprecated static final Schema<ChangeData> V45 = schema(V44, ChangeField.REVERT_OF);
+
+  @Deprecated static final Schema<ChangeData> V46 = schema(V45);
+
+  // Removal of draft change workflow requires reindexing
+  @Deprecated static final Schema<ChangeData> V47 = schema(V46);
+
+  // Rename of star label 'mute' to 'reviewed' requires reindexing
+  static final Schema<ChangeData> V48 = schema(V47);
 
   public static final String NAME = "changes";
   public static final ChangeSchemaDefinitions INSTANCE = new ChangeSchemaDefinitions();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
index 6cbc1cb..f6cee6d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/DummyChangeIndex.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.index.change;
 
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import java.io.IOException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
index f99f3b4..66f8df2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/IndexedChangeQuery.java
@@ -21,15 +21,15 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.IndexedQuery;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.DataSource;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.IndexedQuery;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.query.DataSource;
-import com.google.gerrit.server.query.Matchable;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
new file mode 100644
index 0000000..a2a4507
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterRefUpdate.java
@@ -0,0 +1,199 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.index.change;
+
+import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
+import static com.google.gerrit.server.query.change.ChangeData.asChanges;
+
+import com.google.common.util.concurrent.FutureCallback;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.QueueProvider.QueueType;
+import com.google.gerrit.server.index.IndexExecutor;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.Callable;
+import java.util.concurrent.Future;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class ReindexAfterRefUpdate implements GitReferenceUpdatedListener {
+  private static final Logger log = LoggerFactory.getLogger(ReindexAfterRefUpdate.class);
+
+  private final OneOffRequestContext requestContext;
+  private final Provider<InternalChangeQuery> queryProvider;
+  private final ChangeIndexer.Factory indexerFactory;
+  private final ChangeIndexCollection indexes;
+  private final ChangeNotes.Factory notesFactory;
+  private final AllUsersName allUsersName;
+  private final AccountCache accountCache;
+  private final ListeningExecutorService executor;
+  private final boolean enabled;
+
+  @Inject
+  ReindexAfterRefUpdate(
+      @GerritServerConfig Config cfg,
+      OneOffRequestContext requestContext,
+      Provider<InternalChangeQuery> queryProvider,
+      ChangeIndexer.Factory indexerFactory,
+      ChangeIndexCollection indexes,
+      ChangeNotes.Factory notesFactory,
+      AllUsersName allUsersName,
+      AccountCache accountCache,
+      @IndexExecutor(QueueType.BATCH) ListeningExecutorService executor) {
+    this.requestContext = requestContext;
+    this.queryProvider = queryProvider;
+    this.indexerFactory = indexerFactory;
+    this.indexes = indexes;
+    this.notesFactory = notesFactory;
+    this.allUsersName = allUsersName;
+    this.accountCache = accountCache;
+    this.executor = executor;
+    this.enabled = cfg.getBoolean("index", null, "reindexAfterRefUpdate", true);
+  }
+
+  @Override
+  public void onGitReferenceUpdated(Event event) {
+    if (allUsersName.get().equals(event.getProjectName())) {
+      Account.Id accountId = Account.Id.fromRef(event.getRefName());
+      if (accountId != null && !event.getRefName().startsWith(RefNames.REFS_STARRED_CHANGES)) {
+        try {
+          accountCache.evict(accountId);
+        } catch (IOException e) {
+          log.error("Reindex account {} failed.", accountId, e);
+        }
+      }
+    }
+
+    if (!enabled
+        || event.getRefName().startsWith(RefNames.REFS_CHANGES)
+        || event.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
+        || event.getRefName().startsWith(RefNames.REFS_USERS)) {
+      return;
+    }
+    Futures.addCallback(
+        executor.submit(new GetChanges(event)),
+        new FutureCallback<List<Change>>() {
+          @Override
+          public void onSuccess(List<Change> changes) {
+            for (Change c : changes) {
+              // Don't retry indefinitely; if this fails changes may be stale.
+              @SuppressWarnings("unused")
+              Future<?> possiblyIgnoredError = executor.submit(new Index(event, c.getId()));
+            }
+          }
+
+          @Override
+          public void onFailure(Throwable ignored) {
+            // Logged by {@link GetChanges#call()}.
+          }
+        },
+        directExecutor());
+  }
+
+  private abstract class Task<V> implements Callable<V> {
+    protected Event event;
+
+    protected Task(Event event) {
+      this.event = event;
+    }
+
+    @Override
+    public final V call() throws Exception {
+      try (ManualRequestContext ctx = requestContext.open()) {
+        return impl(ctx);
+      } catch (Exception e) {
+        log.error("Failed to reindex changes after " + event, e);
+        throw e;
+      }
+    }
+
+    protected abstract V impl(RequestContext ctx) throws Exception;
+  }
+
+  private class GetChanges extends Task<List<Change>> {
+    private GetChanges(Event event) {
+      super(event);
+    }
+
+    @Override
+    protected List<Change> impl(RequestContext ctx) throws OrmException {
+      String ref = event.getRefName();
+      Project.NameKey project = new Project.NameKey(event.getProjectName());
+      if (ref.equals(RefNames.REFS_CONFIG)) {
+        return asChanges(queryProvider.get().byProjectOpen(project));
+      }
+      return asChanges(queryProvider.get().byBranchNew(new Branch.NameKey(project, ref)));
+    }
+
+    @Override
+    public String toString() {
+      return "Get changes to reindex caused by "
+          + event.getRefName()
+          + " update of project "
+          + event.getProjectName();
+    }
+  }
+
+  private class Index extends Task<Void> {
+    private final Change.Id id;
+
+    Index(Event event, Change.Id id) {
+      super(event);
+      this.id = id;
+    }
+
+    @Override
+    protected Void impl(RequestContext ctx) throws OrmException, IOException {
+      // Reload change, as some time may have passed since GetChanges.
+      ReviewDb db = ctx.getReviewDbProvider().get();
+      try {
+        Change c =
+            notesFactory
+                .createChecked(db, new Project.NameKey(event.getProjectName()), id)
+                .getChange();
+        indexerFactory.create(executor, indexes).index(db, c);
+      } catch (NoSuchChangeException e) {
+        indexerFactory.create(executor, indexes).delete(id);
+      }
+      return null;
+    }
+
+    @Override
+    public String toString() {
+      return "Index change " + id.get() + " of project " + event.getProjectName();
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
deleted file mode 100644
index 2f6f898..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/ReindexAfterUpdate.java
+++ /dev/null
@@ -1,173 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.index.change;
-
-import static com.google.common.util.concurrent.MoreExecutors.directExecutor;
-import static com.google.gerrit.server.query.change.ChangeData.asChanges;
-
-import com.google.common.util.concurrent.FutureCallback;
-import com.google.common.util.concurrent.Futures;
-import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
-import com.google.gerrit.reviewdb.client.Branch;
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.QueueProvider.QueueType;
-import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
-import com.google.gerrit.server.util.ManualRequestContext;
-import com.google.gerrit.server.util.OneOffRequestContext;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gwtorm.server.OrmException;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.IOException;
-import java.util.List;
-import java.util.concurrent.Callable;
-import java.util.concurrent.Future;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
-public class ReindexAfterUpdate implements GitReferenceUpdatedListener {
-  private static final Logger log = LoggerFactory.getLogger(ReindexAfterUpdate.class);
-
-  private final OneOffRequestContext requestContext;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final ChangeIndexer.Factory indexerFactory;
-  private final ChangeIndexCollection indexes;
-  private final ChangeNotes.Factory notesFactory;
-  private final ListeningExecutorService executor;
-
-  @Inject
-  ReindexAfterUpdate(
-      OneOffRequestContext requestContext,
-      Provider<InternalChangeQuery> queryProvider,
-      ChangeIndexer.Factory indexerFactory,
-      ChangeIndexCollection indexes,
-      ChangeNotes.Factory notesFactory,
-      @IndexExecutor(QueueType.BATCH) ListeningExecutorService executor) {
-    this.requestContext = requestContext;
-    this.queryProvider = queryProvider;
-    this.indexerFactory = indexerFactory;
-    this.indexes = indexes;
-    this.notesFactory = notesFactory;
-    this.executor = executor;
-  }
-
-  @Override
-  public void onGitReferenceUpdated(final Event event) {
-    if (event.getRefName().startsWith(RefNames.REFS_CHANGES)
-        || event.getRefName().startsWith(RefNames.REFS_DRAFT_COMMENTS)
-        || event.getRefName().startsWith(RefNames.REFS_USERS)) {
-      return;
-    }
-    Futures.addCallback(
-        executor.submit(new GetChanges(event)),
-        new FutureCallback<List<Change>>() {
-          @Override
-          public void onSuccess(List<Change> changes) {
-            for (Change c : changes) {
-              // Don't retry indefinitely; if this fails changes may be stale.
-              @SuppressWarnings("unused")
-              Future<?> possiblyIgnoredError = executor.submit(new Index(event, c.getId()));
-            }
-          }
-
-          @Override
-          public void onFailure(Throwable ignored) {
-            // Logged by {@link GetChanges#call()}.
-          }
-        },
-        directExecutor());
-  }
-
-  private abstract class Task<V> implements Callable<V> {
-    protected Event event;
-
-    protected Task(Event event) {
-      this.event = event;
-    }
-
-    @Override
-    public final V call() throws Exception {
-      try (ManualRequestContext ctx = requestContext.open()) {
-        return impl(ctx);
-      } catch (Exception e) {
-        log.error("Failed to reindex changes after " + event, e);
-        throw e;
-      }
-    }
-
-    protected abstract V impl(RequestContext ctx) throws Exception;
-  }
-
-  private class GetChanges extends Task<List<Change>> {
-    private GetChanges(Event event) {
-      super(event);
-    }
-
-    @Override
-    protected List<Change> impl(RequestContext ctx) throws OrmException {
-      String ref = event.getRefName();
-      Project.NameKey project = new Project.NameKey(event.getProjectName());
-      if (ref.equals(RefNames.REFS_CONFIG)) {
-        return asChanges(queryProvider.get().byProjectOpen(project));
-      }
-      return asChanges(queryProvider.get().byBranchNew(new Branch.NameKey(project, ref)));
-    }
-
-    @Override
-    public String toString() {
-      return "Get changes to reindex caused by "
-          + event.getRefName()
-          + " update of project "
-          + event.getProjectName();
-    }
-  }
-
-  private class Index extends Task<Void> {
-    private final Change.Id id;
-
-    Index(Event event, Change.Id id) {
-      super(event);
-      this.id = id;
-    }
-
-    @Override
-    protected Void impl(RequestContext ctx) throws OrmException, IOException {
-      // Reload change, as some time may have passed since GetChanges.
-      ReviewDb db = ctx.getReviewDbProvider().get();
-      try {
-        Change c =
-            notesFactory
-                .createChecked(db, new Project.NameKey(event.getProjectName()), id)
-                .getChange();
-        indexerFactory.create(executor, indexes).index(db, c);
-      } catch (NoSuchChangeException e) {
-        indexerFactory.create(executor, indexes).delete(id);
-      }
-      return null;
-    }
-
-    @Override
-    public String toString() {
-      return "Index change " + id.get() + " of project " + event.getProjectName();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
index 54787c6..a9cd070 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/change/StalenessChecker.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.index.change;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.joining;
 
@@ -27,13 +27,15 @@
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.Url;
+import com.google.gerrit.index.IndexConfig;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -46,9 +48,6 @@
 import java.util.Optional;
 import java.util.Set;
 import java.util.regex.Pattern;
-import java.util.stream.StreamSupport;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.slf4j.Logger;
@@ -136,15 +135,24 @@
 
   @VisibleForTesting
   static boolean reviewDbChangeIsStale(Change indexChange, @Nullable Change reviewDbChange) {
+    checkNotNull(indexChange);
+    PrimaryStorage storageFromIndex = PrimaryStorage.of(indexChange);
+    PrimaryStorage storageFromReviewDb = PrimaryStorage.of(reviewDbChange);
     if (reviewDbChange == null) {
-      return false; // Nothing the caller can do.
+      if (storageFromIndex == PrimaryStorage.REVIEW_DB) {
+        return true; // Index says it should have been in ReviewDb, but it wasn't.
+      }
+      return false; // Not in ReviewDb, but that's ok.
     }
     checkArgument(
         indexChange.getId().equals(reviewDbChange.getId()),
         "mismatched change ID: %s != %s",
         indexChange.getId(),
         reviewDbChange.getId());
-    if (PrimaryStorage.of(reviewDbChange) != PrimaryStorage.REVIEW_DB) {
+    if (storageFromIndex != storageFromReviewDb) {
+      return true; // Primary storage differs, definitely stale.
+    }
+    if (storageFromReviewDb != PrimaryStorage.REVIEW_DB) {
       return false; // Not a ReviewDb change, don't check rowVersion.
     }
     return reviewDbChange.getRowVersion() != indexChange.getRowVersion();
@@ -213,43 +221,6 @@
     }
   }
 
-  @AutoValue
-  public abstract static class RefState {
-    static RefState create(String ref, String sha) {
-      return new AutoValue_StalenessChecker_RefState(ref, ObjectId.fromString(sha));
-    }
-
-    static RefState create(String ref, @Nullable ObjectId id) {
-      return new AutoValue_StalenessChecker_RefState(ref, firstNonNull(id, ObjectId.zeroId()));
-    }
-
-    static RefState of(Ref ref) {
-      return new AutoValue_StalenessChecker_RefState(ref.getName(), ref.getObjectId());
-    }
-
-    byte[] toByteArray(Project.NameKey project) {
-      byte[] a = (project.toString() + ':' + ref() + ':').getBytes(UTF_8);
-      byte[] b = new byte[a.length + Constants.OBJECT_ID_STRING_LENGTH];
-      System.arraycopy(a, 0, b, 0, a.length);
-      id().copyTo(b, a.length);
-      return b;
-    }
-
-    private static void check(boolean condition, String str) {
-      checkArgument(condition, "invalid RefState: %s", str);
-    }
-
-    abstract String ref();
-
-    abstract ObjectId id();
-
-    private boolean match(Repository repo) throws IOException {
-      Ref ref = repo.exactRef(ref());
-      ObjectId expected = ref != null ? ref.getObjectId() : ObjectId.zeroId();
-      return id().equals(expected);
-    }
-  }
-
   /**
    * Pattern for matching refs.
    *
@@ -267,7 +238,7 @@
 
       // Quote everything except the '*'s, which become ".*".
       String regex =
-          StreamSupport.stream(Splitter.on('*').split(pattern).spliterator(), false)
+          Streams.stream(Splitter.on('*').split(pattern))
               .map(Pattern::quote)
               .collect(joining(".*", "^", "$"));
       return new AutoValue_StalenessChecker_RefStatePattern(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
index 5ad0f1e..3584961 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/AllGroupsIndexer.java
@@ -14,24 +14,27 @@
 
 package com.google.gerrit.server.index.group;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
 import com.google.common.base.Stopwatch;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.index.SiteIndexer;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.group.Groups;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.IndexExecutor;
-import com.google.gerrit.server.index.SiteIndexer;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.Callable;
+import java.util.Optional;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.concurrent.atomic.AtomicInteger;
@@ -41,21 +44,24 @@
 import org.slf4j.LoggerFactory;
 
 @Singleton
-public class AllGroupsIndexer extends SiteIndexer<AccountGroup.UUID, AccountGroup, GroupIndex> {
+public class AllGroupsIndexer extends SiteIndexer<AccountGroup.UUID, InternalGroup, GroupIndex> {
   private static final Logger log = LoggerFactory.getLogger(AllGroupsIndexer.class);
 
   private final SchemaFactory<ReviewDb> schemaFactory;
   private final ListeningExecutorService executor;
   private final GroupCache groupCache;
+  private final Groups groups;
 
   @Inject
   AllGroupsIndexer(
       SchemaFactory<ReviewDb> schemaFactory,
       @IndexExecutor(BATCH) ListeningExecutorService executor,
-      GroupCache groupCache) {
+      GroupCache groupCache,
+      Groups groups) {
     this.schemaFactory = schemaFactory;
     this.executor = executor;
     this.groupCache = groupCache;
+    this.groups = groups;
   }
 
   @Override
@@ -78,30 +84,33 @@
     progress.beginTask("Reindexing groups", uuids.size());
     List<ListenableFuture<?>> futures = new ArrayList<>(uuids.size());
     AtomicBoolean ok = new AtomicBoolean(true);
-    final AtomicInteger done = new AtomicInteger();
-    final AtomicInteger failed = new AtomicInteger();
+    AtomicInteger done = new AtomicInteger();
+    AtomicInteger failed = new AtomicInteger();
     Stopwatch sw = Stopwatch.createStarted();
-    for (final AccountGroup.UUID uuid : uuids) {
-      final String desc = "group " + uuid;
+    for (AccountGroup.UUID uuid : uuids) {
+      String desc = "group " + uuid;
       ListenableFuture<?> future =
           executor.submit(
-              new Callable<Void>() {
-                @Override
-                public Void call() throws Exception {
-                  try {
-                    AccountGroup oldGroup = groupCache.get(uuid);
-                    if (oldGroup != null) {
-                      groupCache.evict(oldGroup);
-                    }
-                    index.replace(groupCache.get(uuid));
-                    verboseWriter.println("Reindexed " + desc);
-                    done.incrementAndGet();
-                  } catch (Exception e) {
-                    failed.incrementAndGet();
-                    throw e;
+              () -> {
+                try {
+                  Optional<InternalGroup> oldGroup = groupCache.get(uuid);
+                  if (oldGroup.isPresent()) {
+                    InternalGroup group = oldGroup.get();
+                    groupCache.evict(group.getGroupUUID(), group.getId(), group.getNameKey());
                   }
-                  return null;
+                  Optional<InternalGroup> internalGroup = groupCache.get(uuid);
+                  if (internalGroup.isPresent()) {
+                    index.replace(internalGroup.get());
+                  } else {
+                    index.delete(uuid);
+                  }
+                  verboseWriter.println("Reindexed " + desc);
+                  done.incrementAndGet();
+                } catch (Exception e) {
+                  failed.incrementAndGet();
+                  throw e;
                 }
+                return null;
               });
       addErrorListener(future, desc, progress, ok);
       futures.add(future);
@@ -120,13 +129,10 @@
 
   private List<AccountGroup.UUID> collectGroups(ProgressMonitor progress) throws OrmException {
     progress.beginTask("Collecting groups", ProgressMonitor.UNKNOWN);
-    List<AccountGroup.UUID> uuids = new ArrayList<>();
     try (ReviewDb db = schemaFactory.open()) {
-      for (AccountGroup group : db.accountGroups().all()) {
-        uuids.add(group.getGroupUUID());
-      }
+      return groups.getAll(db).map(AccountGroup::getGroupUUID).collect(toImmutableList());
+    } finally {
+      progress.endTask();
     }
-    progress.endTask();
-    return uuids;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupField.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupField.java
index 5e72327..078433a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupField.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupField.java
@@ -14,42 +14,62 @@
 
 package com.google.gerrit.server.index.group;
 
-import static com.google.gerrit.server.index.FieldDef.exact;
-import static com.google.gerrit.server.index.FieldDef.fullText;
-import static com.google.gerrit.server.index.FieldDef.integer;
-import static com.google.gerrit.server.index.FieldDef.prefix;
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.gerrit.index.FieldDef.exact;
+import static com.google.gerrit.index.FieldDef.fullText;
+import static com.google.gerrit.index.FieldDef.integer;
+import static com.google.gerrit.index.FieldDef.prefix;
+import static com.google.gerrit.index.FieldDef.timestamp;
 
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.SchemaUtil;
+import com.google.gerrit.server.group.InternalGroup;
+import java.sql.Timestamp;
 
 /** Secondary index schemas for groups. */
 public class GroupField {
   /** Legacy group ID. */
-  public static final FieldDef<AccountGroup, Integer> ID =
+  public static final FieldDef<InternalGroup, Integer> ID =
       integer("id").build(g -> g.getId().get());
 
   /** Group UUID. */
-  public static final FieldDef<AccountGroup, String> UUID =
+  public static final FieldDef<InternalGroup, String> UUID =
       exact("uuid").stored().build(g -> g.getGroupUUID().get());
 
   /** Group owner UUID. */
-  public static final FieldDef<AccountGroup, String> OWNER_UUID =
+  public static final FieldDef<InternalGroup, String> OWNER_UUID =
       exact("owner_uuid").build(g -> g.getOwnerGroupUUID().get());
 
+  /** Timestamp indicating when this group was created. */
+  public static final FieldDef<InternalGroup, Timestamp> CREATED_ON =
+      timestamp("created_on").build(InternalGroup::getCreatedOn);
+
   /** Group name. */
-  public static final FieldDef<AccountGroup, String> NAME =
-      exact("name").build(AccountGroup::getName);
+  public static final FieldDef<InternalGroup, String> NAME =
+      exact("name").build(InternalGroup::getName);
 
   /** Prefix match on group name parts. */
-  public static final FieldDef<AccountGroup, Iterable<String>> NAME_PART =
+  public static final FieldDef<InternalGroup, Iterable<String>> NAME_PART =
       prefix("name_part").buildRepeatable(g -> SchemaUtil.getNameParts(g.getName()));
 
   /** Group description. */
-  public static final FieldDef<AccountGroup, String> DESCRIPTION =
-      fullText("description").build(AccountGroup::getDescription);
+  public static final FieldDef<InternalGroup, String> DESCRIPTION =
+      fullText("description").build(InternalGroup::getDescription);
 
   /** Whether the group is visible to all users. */
-  public static final FieldDef<AccountGroup, String> IS_VISIBLE_TO_ALL =
+  public static final FieldDef<InternalGroup, String> IS_VISIBLE_TO_ALL =
       exact("is_visible_to_all").build(g -> g.isVisibleToAll() ? "1" : "0");
+
+  public static final FieldDef<InternalGroup, Iterable<Integer>> MEMBER =
+      integer("member")
+          .buildRepeatable(
+              g -> g.getMembers().stream().map(Account.Id::get).collect(toImmutableList()));
+
+  public static final FieldDef<InternalGroup, Iterable<String>> SUBGROUP =
+      exact("subgroup")
+          .buildRepeatable(
+              g ->
+                  g.getSubgroups().stream().map(AccountGroup.UUID::get).collect(toImmutableList()));
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndex.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndex.java
index 48480f8..6a430f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndex.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndex.java
@@ -14,18 +14,19 @@
 
 package com.google.gerrit.server.index.group;
 
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexDefinition;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.index.Index;
-import com.google.gerrit.server.index.IndexDefinition;
-import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.query.group.GroupPredicates;
 
-public interface GroupIndex extends Index<AccountGroup.UUID, AccountGroup> {
+public interface GroupIndex extends Index<AccountGroup.UUID, InternalGroup> {
   public interface Factory
-      extends IndexDefinition.IndexFactory<AccountGroup.UUID, AccountGroup, GroupIndex> {}
+      extends IndexDefinition.IndexFactory<AccountGroup.UUID, InternalGroup, GroupIndex> {}
 
   @Override
-  default Predicate<AccountGroup> keyPredicate(AccountGroup.UUID uuid) {
+  default Predicate<InternalGroup> keyPredicate(AccountGroup.UUID uuid) {
     return GroupPredicates.uuid(uuid);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
index 2f0d8e0..531c446 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexCollection.java
@@ -15,15 +15,14 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.index.IndexCollection;
-import com.google.inject.Inject;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GroupIndexCollection
-    extends IndexCollection<AccountGroup.UUID, AccountGroup, GroupIndex> {
-  @Inject
+    extends IndexCollection<AccountGroup.UUID, InternalGroup, GroupIndex> {
   @VisibleForTesting
   public GroupIndexCollection() {}
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
index 0dbea79..d117dfd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexDefinition.java
@@ -15,23 +15,19 @@
 package com.google.gerrit.server.index.group;
 
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.IndexDefinition;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
-import com.google.inject.util.Providers;
 
 public class GroupIndexDefinition
-    extends IndexDefinition<AccountGroup.UUID, AccountGroup, GroupIndex> {
+    extends IndexDefinition<AccountGroup.UUID, InternalGroup, GroupIndex> {
 
   @Inject
   GroupIndexDefinition(
       GroupIndexCollection indexCollection,
       GroupIndex.Factory indexFactory,
       @Nullable AllGroupsIndexer allGroupsIndexer) {
-    super(
-        GroupSchemaDefinitions.INSTANCE,
-        indexCollection,
-        indexFactory,
-        Providers.of(allGroupsIndexer));
+    super(GroupSchemaDefinitions.INSTANCE, indexCollection, indexFactory, allGroupsIndexer);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
index 82f55ed..c658173 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexRewriter.java
@@ -16,16 +16,16 @@
 
 import static com.google.common.base.Preconditions.checkNotNull;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.index.IndexRewriter;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.index.IndexRewriter;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @Singleton
-public class GroupIndexRewriter implements IndexRewriter<AccountGroup> {
+public class GroupIndexRewriter implements IndexRewriter<InternalGroup> {
   private final GroupIndexCollection indexes;
 
   @Inject
@@ -34,7 +34,7 @@
   }
 
   @Override
-  public Predicate<AccountGroup> rewrite(Predicate<AccountGroup> in, QueryOptions opts)
+  public Predicate<InternalGroup> rewrite(Predicate<InternalGroup> in, QueryOptions opts)
       throws QueryParseException {
     GroupIndex index = indexes.getSearchIndex();
     checkNotNull(index, "no active search index configured for groups");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
index b137fb3..69b29bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupIndexerImpl.java
@@ -18,14 +18,16 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.events.GroupIndexedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.index.Index;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Optional;
 
 public class GroupIndexerImpl implements GroupIndexer {
   public interface Factory {
@@ -63,8 +65,13 @@
 
   @Override
   public void index(AccountGroup.UUID uuid) throws IOException {
-    for (Index<?, AccountGroup> i : getWriteIndexes()) {
-      i.replace(groupCache.get(uuid));
+    for (Index<AccountGroup.UUID, InternalGroup> i : getWriteIndexes()) {
+      Optional<InternalGroup> internalGroup = groupCache.get(uuid);
+      if (internalGroup.isPresent()) {
+        i.replace(internalGroup.get());
+      } else {
+        i.delete(uuid);
+      }
     }
     fireGroupIndexedEvent(uuid.get());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
index 6ba46cb..b280b25 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/GroupSchemaDefinitions.java
@@ -14,29 +14,31 @@
 
 package com.google.gerrit.server.index.group;
 
-import static com.google.gerrit.server.index.SchemaUtil.schema;
+import static com.google.gerrit.index.SchemaUtil.schema;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.index.SchemaDefinitions;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaDefinitions;
+import com.google.gerrit.server.group.InternalGroup;
 
-public class GroupSchemaDefinitions extends SchemaDefinitions<AccountGroup> {
+public class GroupSchemaDefinitions extends SchemaDefinitions<InternalGroup> {
   @Deprecated
-  static final Schema<AccountGroup> V1 =
+  static final Schema<InternalGroup> V2 =
       schema(
+          GroupField.DESCRIPTION,
           GroupField.ID,
-          GroupField.UUID,
-          GroupField.OWNER_UUID,
+          GroupField.IS_VISIBLE_TO_ALL,
           GroupField.NAME,
           GroupField.NAME_PART,
-          GroupField.DESCRIPTION,
-          GroupField.IS_VISIBLE_TO_ALL);
+          GroupField.OWNER_UUID,
+          GroupField.UUID);
 
-  static final Schema<AccountGroup> V2 = schema(V1);
+  @Deprecated static final Schema<InternalGroup> V3 = schema(V2, GroupField.CREATED_ON);
+
+  static final Schema<InternalGroup> V4 = schema(V3, GroupField.MEMBER, GroupField.SUBGROUP);
 
   public static final GroupSchemaDefinitions INSTANCE = new GroupSchemaDefinitions();
 
   private GroupSchemaDefinitions() {
-    super("groups", AccountGroup.class);
+    super("groups", InternalGroup.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
index 1ea4478..255df32 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/group/IndexedGroupQuery.java
@@ -14,19 +14,22 @@
 
 package com.google.gerrit.server.index.group;
 
+import com.google.gerrit.index.Index;
+import com.google.gerrit.index.IndexedQuery;
+import com.google.gerrit.index.QueryOptions;
+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.index.Index;
-import com.google.gerrit.server.index.IndexedQuery;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.query.DataSource;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.group.InternalGroup;
 
-public class IndexedGroupQuery extends IndexedQuery<AccountGroup.UUID, AccountGroup>
-    implements DataSource<AccountGroup> {
+public class IndexedGroupQuery extends IndexedQuery<AccountGroup.UUID, InternalGroup>
+    implements DataSource<InternalGroup> {
 
   public IndexedGroupQuery(
-      Index<AccountGroup.UUID, AccountGroup> index, Predicate<AccountGroup> pred, QueryOptions opts)
+      Index<AccountGroup.UUID, InternalGroup> index,
+      Predicate<InternalGroup> pred,
+      QueryOptions opts)
       throws QueryParseException {
     super(index, pred, opts.convertForBackend());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java
index 5f77877..c7f2ecd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/BasicSerialization.java
@@ -41,7 +41,7 @@
 public class BasicSerialization {
   private static final byte[] NO_BYTES = {};
 
-  private static int safeRead(final InputStream input) throws IOException {
+  private static int safeRead(InputStream input) throws IOException {
     final int b = input.read();
     if (b == -1) {
       throw new EOFException();
@@ -50,20 +50,20 @@
   }
 
   /** Read a fixed-width 64 bit integer in network byte order (big-endian). */
-  public static long readFixInt64(final InputStream input) throws IOException {
+  public static long readFixInt64(InputStream input) throws IOException {
     final long h = readFixInt32(input);
     final long l = readFixInt32(input) & 0xFFFFFFFFL;
     return (h << 32) | l;
   }
 
   /** Write a fixed-width 64 bit integer in network byte order (big-endian). */
-  public static void writeFixInt64(final OutputStream output, final long val) throws IOException {
+  public static void writeFixInt64(OutputStream output, long val) throws IOException {
     writeFixInt32(output, (int) (val >>> 32));
     writeFixInt32(output, (int) (val & 0xFFFFFFFFL));
   }
 
   /** Read a fixed-width 32 bit integer in network byte order (big-endian). */
-  public static int readFixInt32(final InputStream input) throws IOException {
+  public static int readFixInt32(InputStream input) throws IOException {
     final int b1 = safeRead(input);
     final int b2 = safeRead(input);
     final int b3 = safeRead(input);
@@ -72,7 +72,7 @@
   }
 
   /** Write a fixed-width 32 bit integer in network byte order (big-endian). */
-  public static void writeFixInt32(final OutputStream output, final int val) throws IOException {
+  public static void writeFixInt32(OutputStream output, int val) throws IOException {
     output.write((val >>> 24) & 0xFF);
     output.write((val >>> 16) & 0xFF);
     output.write((val >>> 8) & 0xFF);
@@ -80,7 +80,7 @@
   }
 
   /** Read a varint from the input, one byte at a time. */
-  public static int readVarInt32(final InputStream input) throws IOException {
+  public static int readVarInt32(InputStream input) throws IOException {
     int result = 0;
     int offset = 0;
     for (; offset < 32; offset += 7) {
@@ -94,7 +94,7 @@
   }
 
   /** Write a varint; value is treated as an unsigned value. */
-  public static void writeVarInt32(final OutputStream output, int value) throws IOException {
+  public static void writeVarInt32(OutputStream output, int value) throws IOException {
     while (true) {
       if ((value & ~0x7F) == 0) {
         output.write(value);
@@ -106,7 +106,7 @@
   }
 
   /** Read a fixed length byte array whose length is specified as a varint. */
-  public static byte[] readBytes(final InputStream input) throws IOException {
+  public static byte[] readBytes(InputStream input) throws IOException {
     final int len = readVarInt32(input);
     if (len == 0) {
       return NO_BYTES;
@@ -117,20 +117,19 @@
   }
 
   /** Write a byte array prefixed by its length in a varint. */
-  public static void writeBytes(final OutputStream output, final byte[] data) throws IOException {
+  public static void writeBytes(OutputStream output, byte[] data) throws IOException {
     writeBytes(output, data, 0, data.length);
   }
 
   /** Write a byte array prefixed by its length in a varint. */
-  public static void writeBytes(
-      final OutputStream output, final byte[] data, final int offset, final int len)
+  public static void writeBytes(final OutputStream output, byte[] data, int offset, int len)
       throws IOException {
     writeVarInt32(output, len);
     output.write(data, offset, len);
   }
 
   /** Read a UTF-8 string, prefixed by its byte length in a varint. */
-  public static String readString(final InputStream input) throws IOException {
+  public static String readString(InputStream input) throws IOException {
     final byte[] bin = readBytes(input);
     if (bin.length == 0) {
       return null;
@@ -139,7 +138,7 @@
   }
 
   /** Write a UTF-8 string, prefixed by its byte length in a varint. */
-  public static void writeString(final OutputStream output, final String s) throws IOException {
+  public static void writeString(OutputStream output, String s) throws IOException {
     if (s == null) {
       writeVarInt32(output, 0);
     } else {
@@ -148,8 +147,7 @@
   }
 
   /** Read an enum whose code is stored as a varint. */
-  public static <T extends CodedEnum> T readEnum(final InputStream input, final T[] all)
-      throws IOException {
+  public static <T extends CodedEnum> T readEnum(InputStream input, T[] all) throws IOException {
     final int val = readVarInt32(input);
     for (T t : all) {
       if (t.getCode() == val) {
@@ -160,8 +158,7 @@
   }
 
   /** Write an enum whose code is stored as a varint. */
-  public static <T extends CodedEnum> void writeEnum(final OutputStream output, final T e)
-      throws IOException {
+  public static <T extends CodedEnum> void writeEnum(OutputStream output, T e) throws IOException {
     writeVarInt32(output, e.getCode());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java
index c96e808..10ad33b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ioutil/ColumnFormatter.java
@@ -33,7 +33,7 @@
    *     output. As only non-printable characters in the column text are ever escaped, the column
    *     separator must be a non-printable character if the output needs to be unambiguously parsed.
    */
-  public ColumnFormatter(final PrintWriter out, final char columnSeparator) {
+  public ColumnFormatter(PrintWriter out, char columnSeparator) {
     this.out = out;
     this.columnSeparator = columnSeparator;
     this.firstColumn = true;
@@ -45,7 +45,7 @@
    *
    * @param content the string to add.
    */
-  public void addColumn(final String content) {
+  public void addColumn(String content) {
     if (!firstColumn) {
       out.print(columnSeparator);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
index f3b08fb..e91f3f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/Address.java
@@ -17,7 +17,7 @@
 import com.google.gerrit.server.mail.send.EmailHeader;
 
 public class Address {
-  public static Address parse(final String in) {
+  public static Address parse(String in) {
     final int lt = in.indexOf('<');
     final int gt = in.indexOf('>');
     final int at = in.indexOf("@");
@@ -42,6 +42,14 @@
     throw new IllegalArgumentException("Invalid email address: " + in);
   }
 
+  public static Address tryParse(String in) {
+    try {
+      return parse(in);
+    } catch (IllegalArgumentException e) {
+      return null;
+    }
+  }
+
   final String name;
   final String email;
 
@@ -102,7 +110,7 @@
     return true;
   }
 
-  private static String quotedPhrase(final String name) {
+  private static String quotedPhrase(String name) {
     if (EmailHeader.needsQuotedPrintable(name)) {
       return EmailHeader.quotedPrintable(name);
     }
@@ -115,7 +123,7 @@
     return name;
   }
 
-  private static String wrapInQuotes(final String name) {
+  private static String wrapInQuotes(String name) {
     final StringBuilder r = new StringBuilder(2 + name.length());
     r.append('"');
     for (int i = 0; i < name.length(); i++) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
index b6d7fa8..0487cc0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailUtil.java
@@ -20,10 +20,10 @@
 import com.google.gerrit.common.FooterConstants;
 import com.google.gerrit.common.errors.NoSuchAccountException;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
 import java.time.format.DateTimeFormatter;
 import java.util.Collections;
 import java.util.HashSet;
@@ -38,24 +38,18 @@
       DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss ZZZ");
 
   public static MailRecipients getRecipientsFromFooters(
-      ReviewDb db,
-      AccountResolver accountResolver,
-      boolean draftPatchSet,
-      List<FooterLine> footerLines)
-      throws OrmException {
+      AccountResolver accountResolver, List<FooterLine> footerLines)
+      throws OrmException, IOException {
     MailRecipients recipients = new MailRecipients();
-    if (!draftPatchSet) {
-      for (FooterLine footerLine : footerLines) {
-        try {
-          if (isReviewer(footerLine)) {
-            recipients.reviewers.add(
-                toAccountId(db, accountResolver, footerLine.getValue().trim()));
-          } else if (footerLine.matches(FooterKey.CC)) {
-            recipients.cc.add(toAccountId(db, accountResolver, footerLine.getValue().trim()));
-          }
-        } catch (NoSuchAccountException e) {
-          continue;
+    for (FooterLine footerLine : footerLines) {
+      try {
+        if (isReviewer(footerLine)) {
+          recipients.reviewers.add(toAccountId(accountResolver, footerLine.getValue().trim()));
+        } else if (footerLine.matches(FooterKey.CC)) {
+          recipients.cc.add(toAccountId(accountResolver, footerLine.getValue().trim()));
         }
+      } catch (NoSuchAccountException e) {
+        continue;
       }
     }
     return recipients;
@@ -68,17 +62,16 @@
     return recipients;
   }
 
-  private static Account.Id toAccountId(
-      ReviewDb db, AccountResolver accountResolver, String nameOrEmail)
-      throws OrmException, NoSuchAccountException {
-    Account a = accountResolver.findByNameOrEmail(db, nameOrEmail);
+  private static Account.Id toAccountId(AccountResolver accountResolver, String nameOrEmail)
+      throws OrmException, NoSuchAccountException, IOException {
+    Account a = accountResolver.findByNameOrEmail(nameOrEmail);
     if (a == null) {
       throw new NoSuchAccountException("\"" + nameOrEmail + "\" is not registered");
     }
     return a.getId();
   }
 
-  private static boolean isReviewer(final FooterLine candidateFooterLine) {
+  private static boolean isReviewer(FooterLine candidateFooterLine) {
     return candidateFooterLine.matches(FooterKey.SIGNED_OFF_BY)
         || candidateFooterLine.matches(FooterKey.ACKED_BY)
         || candidateFooterLine.matches(FooterConstants.REVIEWED_BY)
@@ -94,17 +87,17 @@
       this.cc = new HashSet<>();
     }
 
-    public MailRecipients(final Set<Account.Id> reviewers, final Set<Account.Id> cc) {
+    public MailRecipients(Set<Account.Id> reviewers, Set<Account.Id> cc) {
       this.reviewers = new HashSet<>(reviewers);
       this.cc = new HashSet<>(cc);
     }
 
-    public void add(final MailRecipients recipients) {
+    public void add(MailRecipients recipients) {
       reviewers.addAll(recipients.reviewers);
       cc.addAll(recipients.cc);
     }
 
-    public void remove(final Account.Id toRemove) {
+    public void remove(Account.Id toRemove) {
       reviewers.remove(toRemove);
       cc.remove(toRemove);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java
index 2ecaeb1..14cb09a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/HtmlParser.java
@@ -26,23 +26,31 @@
 import org.jsoup.nodes.Document;
 import org.jsoup.nodes.Element;
 
-/** HTMLParser provides parsing functionality for html email. */
+/** Provides functionality for parsing the HTML part of a {@link MailMessage}. */
 public class HtmlParser {
-  private static ImmutableList<String> MAIL_PROVIDER_EXTRAS =
+
+  private static final ImmutableList<String> MAIL_PROVIDER_EXTRAS =
       ImmutableList.of(
           "gmail_extra", // "On 01/01/2017 User<user@gmail.com> wrote:"
           "gmail_quote" // Used for quoting original content
           );
 
+  private HtmlParser() {}
+
   /**
    * Parses comments from html email.
    *
-   * @param email MailMessage as received from the email service.
-   * @param comments A specific set of comments as sent out in the original notification email.
-   *     Comments are expected to be in the same order as they were sent out to in the email
-   * @param changeUrl Canonical change URL that points to the change on this Gerrit instance.
+   * <p>This parser goes though all html elements in the email and checks for matching patterns. It
+   * keeps track of the last file and comments it encountered to know in which context a parsed
+   * comment belongs. It uses the href attributes of <a> tags to identify comments sent out by
+   * Gerrit as these are generally more reliable then the text captions.
+   *
+   * @param email the message as received from the email service
+   * @param comments a specific set of comments as sent out in the original notification email.
+   *     Comments are expected to be in the same order as they were sent out to in the email.
+   * @param changeUrl canonical change URL that points to the change on this Gerrit instance.
    *     Example: https://go-review.googlesource.com/#/c/91570
-   * @return List of MailComments parsed from the html part of the email.
+   * @return list of MailComments parsed from the html part of the email
    */
   public static List<MailComment> parse(
       MailMessage email, Collection<Comment> comments, String changeUrl) {
@@ -106,17 +114,20 @@
             content = ParserUtil.trimQuotation(content);
             // TODO(hiesel) Add more sanitizer
             if (!Strings.isNullOrEmpty(content)) {
-              parsedComments.add(
-                  new MailComment(content, null, null, MailComment.CommentType.CHANGE_MESSAGE));
+              ParserUtil.appendOrAddNewComment(
+                  new MailComment(content, null, null, MailComment.CommentType.CHANGE_MESSAGE),
+                  parsedComments);
             }
           } else if (lastEncounteredComment == null) {
-            parsedComments.add(
+            ParserUtil.appendOrAddNewComment(
                 new MailComment(
-                    content, lastEncounteredFileName, null, MailComment.CommentType.FILE_COMMENT));
+                    content, lastEncounteredFileName, null, MailComment.CommentType.FILE_COMMENT),
+                parsedComments);
           } else {
-            parsedComments.add(
+            ParserUtil.appendOrAddNewComment(
                 new MailComment(
-                    content, null, lastEncounteredComment, MailComment.CommentType.INLINE_COMMENT));
+                    content, null, lastEncounteredComment, MailComment.CommentType.INLINE_COMMENT),
+                parsedComments);
           }
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
index f350e63..6bb6211 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ImapMailReceiver.java
@@ -38,13 +38,15 @@
   }
 
   /**
-   * handleEmails will open a connection to the mail server, remove emails where deletion is
-   * pending, read new email and close the connection.
+   * Opens a connection to the mail server, removes emails where deletion is pending, reads new
+   * email and closes the connection.
    *
-   * @param async Determines if processing messages should happen asynchronous.
+   * @param async determines if processing messages should happen asynchronously
+   * @throws MailTransferException in case of a known transport failure
+   * @throws IOException in case of a low-level transport failure
    */
   @Override
-  public synchronized void handleEmails(boolean async) {
+  public synchronized void handleEmails(boolean async) throws MailTransferException, IOException {
     IMAPClient imap;
     if (mailSettings.encryption != Encryption.NONE) {
       imap = new IMAPSClient(mailSettings.encryption.name(), true);
@@ -56,88 +58,78 @@
     }
     // Set a 30s timeout for each operation
     imap.setDefaultTimeout(30 * 1000);
+    imap.connect(mailSettings.host);
     try {
-      imap.connect(mailSettings.host);
+      if (!imap.login(mailSettings.username, mailSettings.password)) {
+        throw new MailTransferException("Could not login to IMAP server");
+      }
       try {
-        if (!imap.login(mailSettings.username, mailSettings.password)) {
-          log.error("Could not login to IMAP server");
+        if (!imap.select(INBOX_FOLDER)) {
+          throw new MailTransferException("Could not select IMAP folder " + INBOX_FOLDER);
+        }
+        // Fetch just the internal dates first to know how many messages we
+        // should fetch.
+        if (!imap.fetch("1:*", "(INTERNALDATE)")) {
+          // false indicates that there are no messages to fetch
+          log.info("Fetched 0 messages via IMAP");
           return;
         }
-        try {
-          if (!imap.select(INBOX_FOLDER)) {
-            log.error("Could not select IMAP folder " + INBOX_FOLDER);
-            return;
-          }
-          // Fetch just the internal dates first to know how many messages we
-          // should fetch.
-          if (!imap.fetch("1:*", "(INTERNALDATE)")) {
-            // false indicates that there are no messages to fetch
-            log.info("Fetched 0 messages via IMAP");
-            return;
-          }
-          // Format of reply is one line per email and one line to indicate
-          // that the fetch was successful.
-          // Example:
-          // * 1 FETCH (INTERNALDATE "Mon, 24 Oct 2016 16:53:22 +0200 (CEST)")
-          // * 2 FETCH (INTERNALDATE "Mon, 24 Oct 2016 16:53:22 +0200 (CEST)")
-          // AAAC OK FETCH completed.
-          int numMessages = imap.getReplyStrings().length - 1;
-          log.info("Fetched " + numMessages + " messages via IMAP");
-          if (numMessages == 0) {
-            return;
-          }
-          // Fetch the full version of all emails
-          List<MailMessage> mailMessages = new ArrayList<>(numMessages);
-          for (int i = 1; i <= numMessages; i++) {
-            if (imap.fetch(i + ":" + i, "(BODY.PEEK[])")) {
-              // Obtain full reply
-              String[] rawMessage = imap.getReplyStrings();
-              if (rawMessage.length < 2) {
-                continue;
-              }
-              // First and last line are IMAP status codes. We have already
-              // checked, that the fetch returned true (OK), so we safely ignore
-              // those two lines.
-              StringBuilder b = new StringBuilder(2 * (rawMessage.length - 2));
-              for (int j = 1; j < rawMessage.length - 1; j++) {
-                if (j > 1) {
-                  b.append("\n");
-                }
-                b.append(rawMessage[j]);
-              }
-              try {
-                MailMessage mailMessage = RawMailParser.parse(b.toString());
-                if (pendingDeletion.contains(mailMessage.id())) {
-                  // Mark message as deleted
-                  if (imap.store(i + ":" + i, "+FLAGS", "(\\Deleted)")) {
-                    pendingDeletion.remove(mailMessage.id());
-                  } else {
-                    log.error("Could not mark mail message as deleted: " + mailMessage.id());
-                  }
-                } else {
-                  mailMessages.add(mailMessage);
-                }
-              } catch (MailParsingException e) {
-                log.error("Exception while parsing email after IMAP fetch", e);
-              }
-            } else {
-              log.error("IMAP fetch failed. Will retry in next fetch cycle.");
+        // Format of reply is one line per email and one line to indicate
+        // that the fetch was successful.
+        // Example:
+        // * 1 FETCH (INTERNALDATE "Mon, 24 Oct 2016 16:53:22 +0200 (CEST)")
+        // * 2 FETCH (INTERNALDATE "Mon, 24 Oct 2016 16:53:22 +0200 (CEST)")
+        // AAAC OK FETCH completed.
+        int numMessages = imap.getReplyStrings().length - 1;
+        log.info("Fetched " + numMessages + " messages via IMAP");
+        // Fetch the full version of all emails
+        List<MailMessage> mailMessages = new ArrayList<>(numMessages);
+        for (int i = 1; i <= numMessages; i++) {
+          if (imap.fetch(i + ":" + i, "(BODY.PEEK[])")) {
+            // Obtain full reply
+            String[] rawMessage = imap.getReplyStrings();
+            if (rawMessage.length < 2) {
+              continue;
             }
+            // First and last line are IMAP status codes. We have already
+            // checked, that the fetch returned true (OK), so we safely ignore
+            // those two lines.
+            StringBuilder b = new StringBuilder(2 * (rawMessage.length - 2));
+            for (int j = 1; j < rawMessage.length - 1; j++) {
+              if (j > 1) {
+                b.append("\n");
+              }
+              b.append(rawMessage[j]);
+            }
+            try {
+              MailMessage mailMessage = RawMailParser.parse(b.toString());
+              if (pendingDeletion.contains(mailMessage.id())) {
+                // Mark message as deleted
+                if (imap.store(i + ":" + i, "+FLAGS", "(\\Deleted)")) {
+                  pendingDeletion.remove(mailMessage.id());
+                } else {
+                  log.error("Could not mark mail message as deleted: " + mailMessage.id());
+                }
+              } else {
+                mailMessages.add(mailMessage);
+              }
+            } catch (MailParsingException e) {
+              log.error("Exception while parsing email after IMAP fetch", e);
+            }
+          } else {
+            log.error("IMAP fetch failed. Will retry in next fetch cycle.");
           }
-          // Permanently delete emails marked for deletion
-          if (!imap.expunge()) {
-            log.error("Could not expunge IMAP emails");
-          }
-          dispatchMailProcessor(mailMessages, async);
-        } finally {
-          imap.logout();
         }
+        // Permanently delete emails marked for deletion
+        if (!imap.expunge()) {
+          log.error("Could not expunge IMAP emails");
+        }
+        dispatchMailProcessor(mailMessages, async);
       } finally {
-        imap.disconnect();
+        imap.logout();
       }
-    } catch (IOException e) {
-      log.error("Error while talking to IMAP server", e);
-      return;
+    } finally {
+      imap.disconnect();
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java
index 8afbe81..f7804b33 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailComment.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.receive;
 
 import com.google.gerrit.reviewdb.client.Comment;
+import java.util.Objects;
 
 /** A comment parsed from inbound email */
 public class MailComment {
@@ -37,4 +38,14 @@
     this.inReplyTo = inReplyTo;
     this.type = type;
   }
+
+  /**
+   * Checks if the provided comment concerns the same exact spot in the change. This is basically an
+   * equals method except that the message is not checked.
+   */
+  public boolean isSameCommentPath(MailComment c) {
+    return Objects.equals(fileName, c.fileName)
+        && Objects.equals(inReplyTo, c.inReplyTo)
+        && Objects.equals(type, c.type);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java
index dcac25c..68b3c23 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailMessage.java
@@ -21,12 +21,12 @@
 import org.joda.time.DateTime;
 
 /**
- * MailMessage is a simplified representation of an RFC 2045-2047 mime email message used for
- * representing received emails inside Gerrit. It is populated by the MailParser after MailReceiver
- * has received a message. Transformations done by the parser include stitching mime parts together,
- * transforming all content to UTF-16 and removing attachments.
+ * A simplified representation of an RFC 2045-2047 mime email message used for representing received
+ * emails inside Gerrit. It is populated by the MailParser after MailReceiver has received a
+ * message. Transformations done by the parser include stitching mime parts together, transforming
+ * all content to UTF-16 and removing attachments.
  *
- * <p>A valid MailMessage contains at least the following fields: id, from, to, subject and
+ * <p>A valid {@link MailMessage} contains at least the following fields: id, from, to, subject and
  * dateReceived.
  */
 @AutoValue
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailParsingException.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailParsingException.java
index edadef8..b91bb18 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailParsingException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailParsingException.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.mail.receive;
 
-/** MailParsingException indicates that an email could not be parsed. */
+/** An {@link Exception} indicating that an email could not be parsed. */
 public class MailParsingException extends Exception {
   private static final long serialVersionUID = 1L;
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
index 07cbc20..808aaf0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailProcessor.java
@@ -36,20 +36,22 @@
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountByEmailCache;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.change.EmailReviewComments;
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.mail.MailFilter;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
 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.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
 import com.google.gerrit.server.update.Context;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -57,6 +59,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.HashMap;
@@ -67,12 +70,13 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+/** A service that can attach the comments from a {@link MailMessage} to a change. */
 @Singleton
 public class MailProcessor {
   private static final Logger log = LoggerFactory.getLogger(MailProcessor.class);
 
-  private final AccountByEmailCache accountByEmailCache;
-  private final BatchUpdate.Factory buf;
+  private final Emails emails;
+  private final RetryHelper retryHelper;
   private final ChangeMessagesUtil changeMessagesUtil;
   private final CommentsUtil commentsUtil;
   private final OneOffRequestContext oneOffRequestContext;
@@ -88,8 +92,8 @@
 
   @Inject
   public MailProcessor(
-      AccountByEmailCache accountByEmailCache,
-      BatchUpdate.Factory buf,
+      Emails emails,
+      RetryHelper retryHelper,
       ChangeMessagesUtil changeMessagesUtil,
       CommentsUtil commentsUtil,
       OneOffRequestContext oneOffRequestContext,
@@ -102,8 +106,8 @@
       CommentAdded commentAdded,
       AccountCache accountCache,
       @CanonicalWebUrl Provider<String> canonicalUrl) {
-    this.accountByEmailCache = accountByEmailCache;
-    this.buf = buf;
+    this.emails = emails;
+    this.retryHelper = retryHelper;
     this.changeMessagesUtil = changeMessagesUtil;
     this.commentsUtil = commentsUtil;
     this.oneOffRequestContext = oneOffRequestContext;
@@ -119,12 +123,20 @@
   }
 
   /**
-   * Parse comments from MailMessage and persist them on the change.
+   * Parses comments from a {@link MailMessage} and persists them on the change.
    *
-   * @param message MailMessage to process.
-   * @throws OrmException
+   * @param message {@link MailMessage} to process
    */
-  public void process(MailMessage message) throws OrmException {
+  public void process(MailMessage message) throws RestApiException, UpdateException {
+    retryHelper.execute(
+        buf -> {
+          processImpl(buf, message);
+          return null;
+        });
+  }
+
+  private void processImpl(BatchUpdate.Factory buf, MailMessage message)
+      throws OrmException, UpdateException, RestApiException, IOException {
     for (DynamicMap.Entry<MailFilter> filter : mailFilters) {
       if (!filter.getProvider().get().shouldProcessMessage(message)) {
         log.warn(
@@ -145,22 +157,28 @@
       return;
     }
 
-    Set<Account.Id> accounts = accountByEmailCache.get(metadata.author);
-    if (accounts.size() != 1) {
+    Set<Account.Id> accountIds = emails.getAccountFor(metadata.author);
+    if (accountIds.size() != 1) {
       log.error(
           "Address {} could not be matched to a unique account. It was matched to {}."
               + " Will delete message.",
           metadata.author,
-          accounts);
+          accountIds);
       return;
     }
-    Account.Id account = accounts.iterator().next();
+    Account.Id account = accountIds.iterator().next();
     if (!accountCache.get(account).getAccount().isActive()) {
       log.warn("Mail: Account {} is inactive. Will delete message.", account);
       return;
     }
 
-    try (ManualRequestContext ctx = oneOffRequestContext.openAs(account)) {
+    persistComments(buf, message, metadata, account);
+  }
+
+  private void persistComments(
+      BatchUpdate.Factory buf, MailMessage message, MailMetadata metadata, Account.Id sender)
+      throws OrmException, UpdateException, RestApiException {
+    try (ManualRequestContext ctx = oneOffRequestContext.openAs(sender)) {
       List<ChangeData> changeDataList =
           queryProvider.get().byLegacyChangeId(new Change.Id(metadata.changeNumber));
       if (changeDataList.size() != 1) {
@@ -204,11 +222,7 @@
       Op o = new Op(new PatchSet.Id(cd.getId(), metadata.patchSet), parsedComments, message.id());
       BatchUpdate batchUpdate = buf.create(cd.db(), project, ctx.getUser(), TimeUtil.nowTs());
       batchUpdate.addOp(cd.getId(), o);
-      try {
-        batchUpdate.execute();
-      } catch (UpdateException | RestApiException e) {
-        throw new OrmException(e);
-      }
+      batchUpdate.execute();
     }
   }
 
@@ -219,7 +233,7 @@
     private ChangeMessage changeMessage;
     private List<Comment> comments;
     private PatchSet patchSet;
-    private ChangeControl changeControl;
+    private ChangeNotes notes;
 
     private Op(PatchSet.Id psId, List<MailComment> parsedComments, String messageId) {
       this.psId = psId;
@@ -229,69 +243,23 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx)
-        throws OrmException, UnprocessableEntityException {
-      changeControl = ctx.getControl();
+        throws OrmException, UnprocessableEntityException, PatchListNotAvailableException {
       patchSet = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
+      notes = ctx.getNotes();
       if (patchSet == null) {
         throw new OrmException("patch set not found: " + psId);
       }
 
-      String changeMsg = "Patch Set " + psId.get() + ":";
-      if (parsedComments.get(0).type == MailComment.CommentType.CHANGE_MESSAGE) {
-        // Add a blank line after Patch Set to follow the default format
-        if (parsedComments.size() > 1) {
-          changeMsg += "\n\n" + numComments(parsedComments.size() - 1);
-        }
-        changeMsg += "\n\n" + parsedComments.get(0).message;
-      } else {
-        changeMsg += "\n\n" + numComments(parsedComments.size());
-      }
-
-      changeMessage = ChangeMessagesUtil.newMessage(ctx, changeMsg, tag);
+      changeMessage = generateChangeMessage(ctx);
       changeMessagesUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
+
       comments = new ArrayList<>();
       for (MailComment c : parsedComments) {
         if (c.type == MailComment.CommentType.CHANGE_MESSAGE) {
           continue;
         }
-
-        String fileName;
-        // The patch set that this comment is based on is different if this
-        // comment was sent in reply to a comment on a previous patch set.
-        PatchSet psForComment;
-        Side side;
-        if (c.inReplyTo != null) {
-          fileName = c.inReplyTo.key.filename;
-          psForComment =
-              psUtil.get(
-                  ctx.getDb(),
-                  ctx.getNotes(),
-                  new PatchSet.Id(ctx.getChange().getId(), c.inReplyTo.key.patchSetId));
-          side = Side.fromShort(c.inReplyTo.side);
-        } else {
-          fileName = c.fileName;
-          psForComment = patchSet;
-          side = Side.REVISION;
-        }
-
-        Comment comment =
-            commentsUtil.newComment(
-                ctx,
-                fileName,
-                psForComment.getId(),
-                (short) side.ordinal(),
-                c.message,
-                false,
-                null);
-        comment.tag = tag;
-        if (c.inReplyTo != null) {
-          comment.parentUuid = c.inReplyTo.key.uuid;
-          comment.lineNbr = c.inReplyTo.lineNbr;
-          comment.range = c.inReplyTo.range;
-          comment.unresolved = c.inReplyTo.unresolved;
-        }
-        CommentsUtil.setCommentRevId(comment, patchListCache, ctx.getChange(), psForComment);
-        comments.add(comment);
+        comments.add(
+            persistentCommentFromMailComment(ctx, c, targetPatchSetForComment(ctx, c, patchSet)));
       }
       commentsUtil.putComments(
           ctx.getDb(),
@@ -313,7 +281,7 @@
           .create(
               NotifyHandling.ALL,
               ArrayListMultimap.create(),
-              changeControl.getNotes(),
+              notes,
               patchSet,
               ctx.getUser().asIdentifiedUser(),
               changeMessage,
@@ -324,12 +292,19 @@
       // Get previous approvals from this user
       Map<String, Short> approvals = new HashMap<>();
       approvalsUtil
-          .byPatchSetUser(ctx.getDb(), changeControl, psId, ctx.getAccountId())
+          .byPatchSetUser(
+              ctx.getDb(),
+              notes,
+              ctx.getUser(),
+              psId,
+              ctx.getAccountId(),
+              ctx.getRevWalk(),
+              ctx.getRepoView().getConfig())
           .forEach(a -> approvals.put(a.getLabel(), a.getValue()));
       // Fire Gerrit event. Note that approvals can't be granted via email, so old and new approvals
       // are always the same here.
       commentAdded.fire(
-          changeControl.getChange(),
+          notes.getChange(),
           patchSet,
           ctx.getAccount(),
           changeMessage.getMessage(),
@@ -337,6 +312,67 @@
           approvals,
           ctx.getWhen());
     }
+
+    private ChangeMessage generateChangeMessage(ChangeContext ctx) {
+      String changeMsg = "Patch Set " + psId.get() + ":";
+      if (parsedComments.get(0).type == MailComment.CommentType.CHANGE_MESSAGE) {
+        // Add a blank line after Patch Set to follow the default format
+        if (parsedComments.size() > 1) {
+          changeMsg += "\n\n" + numComments(parsedComments.size() - 1);
+        }
+        changeMsg += "\n\n" + parsedComments.get(0).message;
+      } else {
+        changeMsg += "\n\n" + numComments(parsedComments.size());
+      }
+      return ChangeMessagesUtil.newMessage(ctx, changeMsg, tag);
+    }
+
+    private PatchSet targetPatchSetForComment(
+        ChangeContext ctx, MailComment mailComment, PatchSet current) throws OrmException {
+      if (mailComment.inReplyTo != null) {
+        return psUtil.get(
+            ctx.getDb(),
+            ctx.getNotes(),
+            new PatchSet.Id(ctx.getChange().getId(), mailComment.inReplyTo.key.patchSetId));
+      }
+      return current;
+    }
+
+    private Comment persistentCommentFromMailComment(
+        ChangeContext ctx, MailComment mailComment, PatchSet patchSetForComment)
+        throws OrmException, UnprocessableEntityException, PatchListNotAvailableException {
+      String fileName;
+      // The patch set that this comment is based on is different if this
+      // comment was sent in reply to a comment on a previous patch set.
+      Side side;
+      if (mailComment.inReplyTo != null) {
+        fileName = mailComment.inReplyTo.key.filename;
+        side = Side.fromShort(mailComment.inReplyTo.side);
+      } else {
+        fileName = mailComment.fileName;
+        side = Side.REVISION;
+      }
+
+      Comment comment =
+          commentsUtil.newComment(
+              ctx,
+              fileName,
+              patchSetForComment.getId(),
+              (short) side.ordinal(),
+              mailComment.message,
+              false,
+              null);
+
+      comment.tag = tag;
+      if (mailComment.inReplyTo != null) {
+        comment.parentUuid = mailComment.inReplyTo.key.uuid;
+        comment.lineNbr = mailComment.inReplyTo.lineNbr;
+        comment.range = mailComment.inReplyTo.range;
+        comment.unresolved = mailComment.inReplyTo.unresolved;
+      }
+      CommentsUtil.setCommentRevId(comment, patchListCache, ctx.getChange(), patchSetForComment);
+      return comment;
+    }
   }
 
   private static boolean useHtmlParser(MailMessage m) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
index 5068985..6deb240 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailReceiver.java
@@ -16,11 +16,13 @@
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.mail.EmailSettings;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.List;
@@ -86,7 +88,11 @@
         new TimerTask() {
           @Override
           public void run() {
-            MailReceiver.this.handleEmails(true);
+            try {
+              MailReceiver.this.handleEmails(true);
+            } catch (MailTransferException | IOException e) {
+              log.error("Error while fetching emails", e);
+            }
           }
         },
         0L,
@@ -115,10 +121,12 @@
    * handleEmails will open a connection to the mail server, remove emails where deletion is
    * pending, read new email and close the connection.
    *
-   * @param async Determines if processing messages should happen asynchronous.
+   * @param async determines if processing messages should happen asynchronously
+   * @throws MailTransferException in case of a known transport failure
+   * @throws IOException in case of a low-level transport failure
    */
   @VisibleForTesting
-  public abstract void handleEmails(boolean async);
+  public abstract void handleEmails(boolean async) throws MailTransferException, IOException;
 
   protected void dispatchMailProcessor(List<MailMessage> messages, boolean async) {
     for (MailMessage m : messages) {
@@ -132,7 +140,7 @@
                       try {
                         mailProcessor.process(m);
                         requestDeletion(m.id());
-                      } catch (OrmException e) {
+                      } catch (RestApiException | UpdateException e) {
                         log.error("Mail: Can't process message " + m.id() + " . Won't delete.", e);
                       }
                     });
@@ -141,7 +149,7 @@
         try {
           mailProcessor.process(m);
           requestDeletion(m.id());
-        } catch (OrmException e) {
+        } catch (RestApiException | UpdateException e) {
           log.error("Mail: Can't process messages. Won't delete.", e);
         }
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailTransferException.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailTransferException.java
new file mode 100644
index 0000000..fdaba1d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/MailTransferException.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.server.mail.receive;
+
+/** An exception indicating a known transport-level exception. */
+public class MailTransferException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public MailTransferException(String message) {
+    super(message);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java
index f8f64e2..5e3c0ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/ParserUtil.java
@@ -14,7 +14,9 @@
 
 package com.google.gerrit.server.mail.receive;
 
+import com.google.common.collect.Iterables;
 import com.google.gerrit.reviewdb.client.Comment;
+import java.util.List;
 import java.util.StringJoiner;
 import java.util.regex.Pattern;
 
@@ -24,6 +26,8 @@
           "[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+"
               + "(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})");
 
+  private ParserUtil() {}
+
   /**
    * Trims the quotation that email clients add Example: On Sun, Nov 20, 2016 at 10:33 PM,
    * <gerrit@gerritcodereview.com> wrote:
@@ -77,6 +81,28 @@
     return changeUrl + "/" + comment.key.patchSetId + "/" + comment.key.filename;
   }
 
+  /**
+   * When parsing mail content, we need to append comments prematurely since we are parsing
+   * block-by-block and never know what comes next. This can result in a comment being parsed as two
+   * comments when it spans multiple blocks. This method takes care of merging those blocks or
+   * adding a new comment to the list of appropriate.
+   */
+  public static void appendOrAddNewComment(MailComment comment, List<MailComment> comments) {
+    if (comments.isEmpty()) {
+      comments.add(comment);
+      return;
+    }
+    MailComment lastComment = Iterables.getLast(comments);
+
+    if (comment.isSameCommentPath(lastComment)) {
+      // Merge the two comments
+      lastComment.message += "\n\n" + comment.message;
+      return;
+    }
+
+    comments.add(comment);
+  }
+
   private static boolean containsQuotationPattern(String s) {
     // Identifying the quotation line is hard, as it can be in any language.
     // We identify this line by it's characteristics: It usually contains a
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
index d70d651..bbb7e66 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/Pop3MailReceiver.java
@@ -30,6 +30,7 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+/** An implementation of {@link MailReceiver} for POP3. */
 @Singleton
 public class Pop3MailReceiver extends MailReceiver {
   private static final Logger log = LoggerFactory.getLogger(Pop3MailReceiver.class);
@@ -40,13 +41,15 @@
   }
 
   /**
-   * handleEmails will open a connection to the mail server, remove emails where deletion is
-   * pending, read new email and close the connection.
+   * Opens a connection to the mail server, removes emails where deletion is pending, reads new
+   * email and closes the connection.
    *
-   * @param async Determines if processing messages should happen asynchronous.
+   * @param async determines if processing messages should happen asynchronously
+   * @throws MailTransferException in case of a known transport failure
+   * @throws IOException in case of a low-level transport failure
    */
   @Override
-  public synchronized void handleEmails(boolean async) {
+  public synchronized void handleEmails(boolean async) throws MailTransferException, IOException {
     POP3Client pop3;
     if (mailSettings.encryption != Encryption.NONE) {
       pop3 = new POP3SClient(mailSettings.encryption.name(), true);
@@ -56,67 +59,56 @@
     if (mailSettings.port > 0) {
       pop3.setDefaultPort(mailSettings.port);
     }
+    pop3.connect(mailSettings.host);
     try {
-      pop3.connect(mailSettings.host);
-    } catch (IOException e) {
-      log.error("Could not connect to POP3 email server", e);
-      return;
-    }
-    try {
-      try {
-        if (!pop3.login(mailSettings.username, mailSettings.password)) {
-          log.error("Could not login to POP3 email server. Check username and password");
-          return;
-        }
-        try {
-          POP3MessageInfo[] messages = pop3.listMessages();
-          if (messages == null) {
-            log.error("Could not retrieve message list via POP3");
-            return;
-          }
-          log.info("Received " + messages.length + " messages via POP3");
-          // Fetch messages
-          List<MailMessage> mailMessages = new ArrayList<>();
-          for (POP3MessageInfo msginfo : messages) {
-            if (msginfo == null) {
-              // Message was deleted
-              continue;
-            }
-            try (BufferedReader reader = (BufferedReader) pop3.retrieveMessage(msginfo.number)) {
-              if (reader == null) {
-                log.error(
-                    "Could not retrieve POP3 message header for message {}", msginfo.identifier);
-                return;
-              }
-              int[] message = fetchMessage(reader);
-              MailMessage mailMessage = RawMailParser.parse(message);
-              // Delete messages where deletion is pending. This requires
-              // knowing the integer message ID of the email. We therefore parse
-              // the message first and extract the Message-ID specified in RFC
-              // 822 and delete the message if deletion is pending.
-              if (pendingDeletion.contains(mailMessage.id())) {
-                if (pop3.deleteMessage(msginfo.number)) {
-                  pendingDeletion.remove(mailMessage.id());
-                } else {
-                  log.error("Could not delete message " + msginfo.number);
-                }
-              } else {
-                // Process message further
-                mailMessages.add(mailMessage);
-              }
-            } catch (MailParsingException e) {
-              log.error("Could not parse message " + msginfo.number);
-            }
-          }
-          dispatchMailProcessor(mailMessages, async);
-        } finally {
-          pop3.logout();
-        }
-      } finally {
-        pop3.disconnect();
+      if (!pop3.login(mailSettings.username, mailSettings.password)) {
+        throw new MailTransferException(
+            "Could not login to POP3 email server. Check username and password");
       }
-    } catch (IOException e) {
-      log.error("Error while issuing POP3 command", e);
+      try {
+        POP3MessageInfo[] messages = pop3.listMessages();
+        if (messages == null) {
+          throw new MailTransferException("Could not retrieve message list via POP3");
+        }
+        log.info("Received " + messages.length + " messages via POP3");
+        // Fetch messages
+        List<MailMessage> mailMessages = new ArrayList<>();
+        for (POP3MessageInfo msginfo : messages) {
+          if (msginfo == null) {
+            // Message was deleted
+            continue;
+          }
+          try (BufferedReader reader = (BufferedReader) pop3.retrieveMessage(msginfo.number)) {
+            if (reader == null) {
+              throw new MailTransferException(
+                  "Could not retrieve POP3 message header for message " + msginfo.identifier);
+            }
+            int[] message = fetchMessage(reader);
+            MailMessage mailMessage = RawMailParser.parse(message);
+            // Delete messages where deletion is pending. This requires
+            // knowing the integer message ID of the email. We therefore parse
+            // the message first and extract the Message-ID specified in RFC
+            // 822 and delete the message if deletion is pending.
+            if (pendingDeletion.contains(mailMessage.id())) {
+              if (pop3.deleteMessage(msginfo.number)) {
+                pendingDeletion.remove(mailMessage.id());
+              } else {
+                log.error("Could not delete message " + msginfo.number);
+              }
+            } else {
+              // Process message further
+              mailMessages.add(mailMessage);
+            }
+          } catch (MailParsingException e) {
+            log.error("Could not parse message " + msginfo.number);
+          }
+        }
+        dispatchMailProcessor(mailMessages, async);
+      } finally {
+        pop3.logout();
+      }
+    } finally {
+      pop3.disconnect();
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java
index 3ba9de7..d2f91ed 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/RawMailParser.java
@@ -35,20 +35,19 @@
 import org.apache.james.mime4j.message.DefaultMessageBuilder;
 import org.joda.time.DateTime;
 
-/**
- * RawMailParser parses raw email content received through POP3 or IMAP into an internal {@link
- * MailMessage}.
- */
+/** Parses raw email content received through POP3 or IMAP into an internal {@link MailMessage}. */
 public class RawMailParser {
   private static final ImmutableSet<String> MAIN_HEADERS =
       ImmutableSet.of("to", "from", "cc", "date", "message-id", "subject", "content-type");
 
+  private RawMailParser() {}
+
   /**
    * Parses a MailMessage from a string.
    *
-   * @param raw String as received over the wire
-   * @return Parsed MailMessage
-   * @throws MailParsingException
+   * @param raw {@link String} payload as received over the wire
+   * @return parsed {@link MailMessage}
+   * @throws MailParsingException in case parsing fails
    */
   public static MailMessage parse(String raw) throws MailParsingException {
     MailMessage.Builder messageBuilder = MailMessage.builder();
@@ -123,8 +122,8 @@
    * quoted-printable encoding.
    *
    * @param chars Array as received over the wire
-   * @return Parsed MailMessage
-   * @throws MailParsingException
+   * @return Parsed {@link MailMessage}
+   * @throws MailParsingException in case parsing fails
    */
   public static MailMessage parse(int[] chars) throws MailParsingException {
     StringBuilder b = new StringBuilder(chars.length);
@@ -140,10 +139,10 @@
   /**
    * Traverses a mime tree and parses out text and html parts. All other parts will be dropped.
    *
-   * @param part MimePart to parse
-   * @param textBuilder StringBuilder to append all plaintext parts
-   * @param htmlBuilder StringBuilder to append all html parts
-   * @throws IOException
+   * @param part {@code MimePart} to parse
+   * @param textBuilder {@link StringBuilder} to append all plaintext parts
+   * @param htmlBuilder {@link StringBuilder} to append all html parts
+   * @throws IOException in case of a failure while transforming the input to a {@link String}
    */
   private static void handleMimePart(
       Entity part, StringBuilder textBuilder, StringBuilder htmlBuilder) throws IOException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java
index fa33cc6..80443ba 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/receive/TextParser.java
@@ -22,17 +22,20 @@
 import java.util.Collection;
 import java.util.List;
 
-/** TextParser provides parsing functionality for plaintext email. */
+/** Provides parsing functionality for plaintext email. */
 public class TextParser {
+  private TextParser() {}
+
   /**
    * Parses comments from plaintext email.
    *
-   * @param email MailMessage as received from the email service.
-   * @param comments Comments previously persisted on the change that caused the original
-   *     notification email to be sent out. Ordering must be the same as in the outbound email
-   * @param changeUrl Canonical change url that points to the change on this Gerrit instance.
+   * @param email @param email the message as received from the email service
+   * @param comments list of {@link Comment}s previously persisted on the change that caused the
+   *     original notification email to be sent out. Ordering must be the same as in the outbound
+   *     email
+   * @param changeUrl canonical change url that points to the change on this Gerrit instance.
    *     Example: https://go-review.googlesource.com/#/c/91570
-   * @return List of MailComments parsed from the plaintext part of the email.
+   * @return list of MailComments parsed from the plaintext part of the email
    */
   public static List<MailComment> parse(
       MailMessage email, Collection<Comment> comments, String changeUrl) {
@@ -78,7 +81,7 @@
             currentComment.message = ParserUtil.trimQuotation(currentComment.message);
           }
           if (!Strings.isNullOrEmpty(currentComment.message)) {
-            parsedComments.add(currentComment);
+            ParserUtil.appendOrAddNewComment(currentComment, parsedComments);
           }
           currentComment = null;
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
index 0c09639..30938f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/AddKeySender.java
@@ -17,9 +17,13 @@
 import com.google.common.base.Joiner;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.assistedinject.Assisted;
 import com.google.inject.assistedinject.AssistedInject;
 import java.util.List;
@@ -31,6 +35,7 @@
     AddKeySender create(IdentifiedUser user, List<String> gpgKey);
   }
 
+  private final PermissionBackend permissionBackend;
   private final IdentifiedUser callingUser;
   private final IdentifiedUser user;
   private final AccountSshKey sshKey;
@@ -39,10 +44,12 @@
   @AssistedInject
   public AddKeySender(
       EmailArguments ea,
+      PermissionBackend permissionBackend,
       IdentifiedUser callingUser,
       @Assisted IdentifiedUser user,
       @Assisted AccountSshKey sshKey) {
     super(ea, "addkey");
+    this.permissionBackend = permissionBackend;
     this.callingUser = callingUser;
     this.user = user;
     this.sshKey = sshKey;
@@ -52,10 +59,12 @@
   @AssistedInject
   public AddKeySender(
       EmailArguments ea,
+      PermissionBackend permissionBackend,
       IdentifiedUser callingUser,
       @Assisted IdentifiedUser user,
       @Assisted List<String> gpgKeys) {
     super(ea, "addkey");
+    this.permissionBackend = permissionBackend;
     this.callingUser = callingUser;
     this.user = user;
     this.sshKey = null;
@@ -71,12 +80,25 @@
 
   @Override
   protected boolean shouldSendMessage() {
-    /*
-     * Don't send an email if no keys are added, or an admin is adding a key to
-     * a user.
-     */
-    return (sshKey != null || gpgKeys.size() > 0)
-        && (user.equals(callingUser) || !callingUser.getCapabilities().canAdministrateServer());
+    if (sshKey == null && (gpgKeys == null || gpgKeys.isEmpty())) {
+      // Don't email if no keys were added.
+      return false;
+    }
+
+    if (user.equals(callingUser)) {
+      // Send email if the user self-added a key; this notification is necessary to alert
+      // the user if their account was compromised and a key was unexpectedly added.
+      return true;
+    }
+
+    try {
+      // Don't email if an administrator added a key on behalf of the user.
+      permissionBackend.user(callingUser).check(GlobalPermission.ADMINISTRATE_SERVER);
+      return false;
+    } catch (AuthException | PermissionBackendException e) {
+      // Send email if a non-administrator modified the keys, e.g. by MODIFY_ACCOUNT.
+      return true;
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
index 4ee88fb..a7826cb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ChangeEmail.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.mail.send;
 
+import com.google.common.base.Splitter;
 import com.google.common.collect.ListMultimap;
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
@@ -35,10 +37,16 @@
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListEntry;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.patch.PatchSetInfoNotAvailableException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
+import com.google.template.soy.data.SoyListData;
+import com.google.template.soy.data.SoyMapData;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.text.MessageFormat;
@@ -87,25 +95,29 @@
   }
 
   @Override
-  public void setFrom(final Account.Id id) {
+  public void setFrom(Account.Id id) {
     super.setFrom(id);
 
     /** Is the from user in an email squelching group? */
-    final IdentifiedUser user = args.identifiedUserFactory.create(id);
-    emailOnlyAuthors = !user.getCapabilities().canEmailReviewers();
+    try {
+      IdentifiedUser user = args.identifiedUserFactory.create(id);
+      args.permissionBackend.user(user).check(GlobalPermission.EMAIL_REVIEWERS);
+    } catch (AuthException | PermissionBackendException e) {
+      emailOnlyAuthors = true;
+    }
   }
 
-  public void setPatchSet(final PatchSet ps) {
+  public void setPatchSet(PatchSet ps) {
     patchSet = ps;
   }
 
-  public void setPatchSet(final PatchSet ps, final PatchSetInfo psi) {
+  public void setPatchSet(PatchSet ps, PatchSetInfo psi) {
     patchSet = ps;
     patchSetInfo = psi;
   }
 
   @Deprecated
-  public void setChangeMessage(final ChangeMessage cm) {
+  public void setChangeMessage(ChangeMessage cm) {
     setChangeMessage(cm.getMessage(), cm.getWrittenOn());
   }
 
@@ -166,7 +178,7 @@
     authors = getAuthors();
 
     try {
-      stars = args.starredChangesUtil.byChangeFromIndex(change.getId());
+      stars = changeData.stars();
     } catch (OrmException e) {
       throw new EmailException("Failed to load stars for change " + change.getChangeId(), e);
     }
@@ -180,6 +192,18 @@
     setHeader("X-Gerrit-Change-Number", "" + change.getChangeId());
     setChangeUrlHeader();
     setCommitIdHeader();
+
+    if (notify.ordinal() >= NotifyHandling.OWNER_REVIEWERS.ordinal()) {
+      try {
+        addByEmail(
+            RecipientType.CC, changeData.reviewersByEmail().byState(ReviewerStateInternal.CC));
+        addByEmail(
+            RecipientType.CC,
+            changeData.reviewersByEmail().byState(ReviewerStateInternal.REVIEWER));
+      } catch (OrmException e) {
+        throw new EmailException("Failed to add unregistered CCs " + change.getChangeId(), e);
+      }
+    }
   }
 
   private void setChangeUrlHeader() {
@@ -304,8 +328,8 @@
   }
 
   /** TO or CC all vested parties (change owner, patch set uploader, author). */
-  protected void rcptToAuthors(final RecipientType rt) {
-    for (final Account.Id id : authors) {
+  protected void rcptToAuthors(RecipientType rt) {
+    for (Account.Id id : authors) {
       add(rt, id);
     }
   }
@@ -376,19 +400,19 @@
   }
 
   @Override
-  protected void add(final RecipientType rt, final Account.Id to) {
+  protected void add(RecipientType rt, Account.Id to) {
     if (!emailOnlyAuthors || authors.contains(to)) {
       super.add(rt, to);
     }
   }
 
   @Override
-  protected boolean isVisibleTo(final Account.Id to) throws OrmException {
-    return projectState == null
-        || projectState
-            .controlFor(args.identifiedUserFactory.create(to))
-            .controlFor(args.db.get(), change)
-            .isVisible(args.db.get());
+  protected boolean isVisibleTo(Account.Id to) throws OrmException, PermissionBackendException {
+    return args.permissionBackend
+        .user(args.identifiedUserFactory.create(to))
+        .change(changeData)
+        .database(args.db.get())
+        .test(ChangePermission.READ);
   }
 
   /** Find all users who are authors of any part of this change. */
@@ -440,6 +464,7 @@
     soyContext.put("coverLetter", getCoverLetter());
     soyContext.put("fromName", getNameFor(fromId));
     soyContext.put("fromEmail", getNameEmailFor(fromId));
+    soyContext.put("diffLines", getDiffTemplateData());
 
     soyContextEmailData.put("unifiedDiff", getUnifiedDiff());
     soyContextEmailData.put("changeDetail", getChangeDetail());
@@ -476,6 +501,9 @@
     footers.add("Gerrit-Change-Number: " + Integer.toString(change.getChangeId()));
     footers.add("Gerrit-PatchSet: " + patchSet.getPatchSetId());
     footers.add("Gerrit-Owner: " + getNameEmailFor(change.getOwner()));
+    if (change.getAssignee() != null) {
+      footers.add("Gerrit-Assignee: " + getNameEmailFor(change.getAssignee()));
+    }
     for (String reviewer : getEmailsByState(ReviewerStateInternal.REVIEWER)) {
       footers.add("Gerrit-Reviewer: " + reviewer);
     }
@@ -512,6 +540,9 @@
         // Currently these always have a null oldId in the PatchList.
         return "[Octopus merge; cannot be formatted as a diff.]\n";
       }
+    } catch (PatchListObjectTooLargeException e) {
+      log.warn("Cannot format patch " + e.getMessage());
+      return "";
     } catch (PatchListNotAvailableException e) {
       log.error("Cannot format patch", e);
       return "";
@@ -539,4 +570,37 @@
       }
     }
   }
+
+  /**
+   * Generate a Soy list of maps representing each line of the unified diff. The line maps will have
+   * a 'type' key which maps to one of 'common', 'add' or 'remove' and a 'text' key which maps to
+   * the line's content.
+   */
+  private SoyListData getDiffTemplateData() {
+    SoyListData result = new SoyListData();
+    Splitter lineSplitter = Splitter.on(System.getProperty("line.separator"));
+    for (String diffLine : lineSplitter.split(getUnifiedDiff())) {
+      SoyMapData lineData = new SoyMapData();
+      lineData.put("text", diffLine);
+
+      // Skip empty lines and lines that look like diff headers.
+      if (diffLine.isEmpty() || diffLine.startsWith("---") || diffLine.startsWith("+++")) {
+        lineData.put("type", "common");
+      } else {
+        switch (diffLine.charAt(0)) {
+          case '+':
+            lineData.put("type", "add");
+            break;
+          case '-':
+            lineData.put("type", "remove");
+            break;
+          default:
+            lineData.put("type", "common");
+            break;
+        }
+      }
+      result.add(lineData);
+    }
+    return result;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
index d3c456c..4adca1b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CommentSender.java
@@ -35,6 +35,7 @@
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.PatchList;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import com.google.gerrit.server.patch.PatchListObjectTooLargeException;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.OrmException;
@@ -155,7 +156,7 @@
     }
     if (notify.compareTo(NotifyHandling.ALL) >= 0) {
       bccStarredBy();
-      includeWatchers(NotifyType.ALL_COMMENTS, !patchSet.isDraft());
+      includeWatchers(NotifyType.ALL_COMMENTS, !change.isWorkInProgress() && !change.isPrivate());
     }
     removeUsersThatIgnoredTheChange();
 
@@ -232,6 +233,8 @@
     if (repo != null) {
       try {
         patchList = getPatchList();
+      } catch (PatchListObjectTooLargeException e) {
+        log.warn("Failed to get patch list: " + e.getMessage());
       } catch (PatchListNotAvailableException e) {
         log.error("Failed to get patch list", e);
       }
@@ -257,7 +260,7 @@
                 "Cannot load {} from {} in {}",
                 c.key.filename,
                 patchList.getNewId().name(),
-                projectState.getProject().getName(),
+                projectState.getName(),
                 e);
             currentGroup.fileData = null;
           }
@@ -401,7 +404,7 @@
 
     Comment.Key key = new Comment.Key(child.parentUuid, child.key.filename, child.key.patchSetId);
     try {
-      return commentsUtil.get(args.db.get(), changeData.notes(), key);
+      return commentsUtil.getPublished(args.db.get(), changeData.notes(), key);
     } catch (OrmException e) {
       log.warn("Could not find the parent of this comment: {}", child.toString());
       return Optional.empty();
@@ -432,21 +435,43 @@
   }
 
   /**
-   * @return a shortened version of the given comment's message. Will be shortened to 75 characters
-   *     or the first line, whichever is shorter.
+   * @return a shortened version of the given comment's message. Will be shortened to 100 characters
+   *     or the first line, or following the last period within the first 100 characters, whichever
+   *     is shorter. If the message is shortened, an ellipsis is appended.
    */
-  private String getShortenedCommentMessage(Comment comment) {
-    String msg = comment.message.trim();
-    if (msg.length() > 75) {
-      msg = msg.substring(0, 75);
+  protected static String getShortenedCommentMessage(String message) {
+    int threshold = 100;
+    String fullMessage = message.trim();
+    String msg = fullMessage;
+
+    if (msg.length() > threshold) {
+      msg = msg.substring(0, threshold);
     }
+
     int lf = msg.indexOf('\n');
+    int period = msg.lastIndexOf('.');
+
     if (lf > 0) {
+      // Truncate if a line feed appears within the threshold.
       msg = msg.substring(0, lf);
+
+    } else if (period > 0) {
+      // Otherwise truncate if there is a period within the threshold.
+      msg = msg.substring(0, period + 1);
     }
+
+    // Append an ellipsis if the message has been truncated.
+    if (!msg.equals(fullMessage)) {
+      msg += " […]";
+    }
+
     return msg;
   }
 
+  protected static String getShortenedCommentMessage(Comment comment) {
+    return getShortenedCommentMessage(comment.message);
+  }
+
   /**
    * @return grouped inline comment data mapped to data structures that are suitable for passing
    *     into Soy.
@@ -561,7 +586,7 @@
 
   private Repository getRepository() {
     try {
-      return args.server.openRepository(projectState.getProject().getNameKey());
+      return args.server.openRepository(projectState.getNameKey());
     } catch (IOException e) {
       return null;
     }
@@ -587,6 +612,7 @@
 
     footers.add("Gerrit-Comment-Date: " + getCommentTimestamp());
     footers.add("Gerrit-HasComments: " + (hasComments ? "Yes" : "No"));
+    footers.add("Gerrit-HasLabels: " + (labels.isEmpty() ? "No" : "Yes"));
   }
 
   private String getLine(PatchFile fileInfo, short side, int lineNbr) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
index 3e9e62c..6d15d6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/CreateChangeSender.java
@@ -47,10 +47,10 @@
   protected void init() throws EmailException {
     super.init();
 
-    boolean isDraft = change.getStatus() == Change.Status.DRAFT;
     try {
       // Try to mark interested owners with TO and CC or BCC line.
-      Watchers matching = getWatchers(NotifyType.NEW_CHANGES, !isDraft);
+      Watchers matching =
+          getWatchers(NotifyType.NEW_CHANGES, !change.isWorkInProgress() && !change.isPrivate());
       for (Account.Id user :
           Iterables.concat(matching.to.accounts, matching.cc.accounts, matching.bcc.accounts)) {
         if (isOwnerOfProjectOrBranch(user)) {
@@ -69,7 +69,7 @@
       log.warn("Cannot notify watchers for new change", err);
     }
 
-    includeWatchers(NotifyType.NEW_PATCHSETS, !isDraft);
+    includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
   }
 
   private boolean isOwnerOfProjectOrBranch(Account.Id user) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
index a563846..0fea7ce 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/DeleteReviewerSender.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.WatchConfig.NotifyType;
+import com.google.gerrit.server.mail.Address;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
@@ -32,6 +33,7 @@
 /** Let users know that a reviewer and possibly her review have been removed. */
 public class DeleteReviewerSender extends ReplyToChangeSender {
   private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
 
   public interface Factory extends ReplyToChangeSender.Factory<DeleteReviewerSender> {
     @Override
@@ -49,6 +51,10 @@
     reviewers.addAll(cc);
   }
 
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -58,6 +64,7 @@
     ccExistingReviewers();
     includeWatchers(NotifyType.ALL_COMMENTS);
     add(RecipientType.TO, reviewers);
+    addByEmail(RecipientType.TO, reviewersByEmail);
     removeUsersThatIgnoredTheChange();
   }
 
@@ -70,13 +77,16 @@
   }
 
   public List<String> getReviewerNames() {
-    if (reviewers.isEmpty()) {
+    if (reviewers.isEmpty() && reviewersByEmail.isEmpty()) {
       return null;
     }
     List<String> names = new ArrayList<>();
     for (Account.Id id : reviewers) {
       names.add(getNameFor(id));
     }
+    for (Address a : reviewersByEmail) {
+      names.add(a.toString());
+    }
     return names;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
index 9306c7a..869d7d1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailArguments.java
@@ -22,9 +22,7 @@
 import com.google.gerrit.server.GerritPersonIdentProvider;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.IdentifiedUser.GenericFactory;
-import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupIncludeCache;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -32,10 +30,12 @@
 import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.group.Groups;
 import com.google.gerrit.server.mail.EmailSettings;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.patch.PatchSetInfoFactory;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -52,8 +52,10 @@
 public class EmailArguments {
   final GitRepositoryManager server;
   final ProjectCache projectCache;
+  final PermissionBackend permissionBackend;
   final GroupBackend groupBackend;
   final GroupIncludeCache groupIncludes;
+  final Groups groups;
   final AccountCache accountCache;
   final PatchListCache patchListCache;
   final ApprovalsUtil approvalsUtil;
@@ -61,7 +63,6 @@
   final EmailSender emailSender;
   final PatchSetInfoFactory patchSetInfoFactory;
   final IdentifiedUser.GenericFactory identifiedUserFactory;
-  final CapabilityControl.Factory capabilityControlFactory;
   final ChangeNotes.Factory changeNotesFactory;
   final AnonymousUser anonymousUser;
   final String anonymousCowardName;
@@ -78,13 +79,14 @@
   final SoyTofu soyTofu;
   final EmailSettings settings;
   final DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners;
-  final StarredChangesUtil starredChangesUtil;
   final Provider<InternalAccountQuery> accountQueryProvider;
+  final OutgoingEmailValidator validator;
 
   @Inject
   EmailArguments(
       GitRepositoryManager server,
       ProjectCache projectCache,
+      PermissionBackend permissionBackend,
       GroupBackend groupBackend,
       GroupIncludeCache groupIncludes,
       AccountCache accountCache,
@@ -94,11 +96,11 @@
       EmailSender emailSender,
       PatchSetInfoFactory patchSetInfoFactory,
       GenericFactory identifiedUserFactory,
-      CapabilityControl.Factory capabilityControlFactory,
       ChangeNotes.Factory changeNotesFactory,
       AnonymousUser anonymousUser,
       @AnonymousCowardName String anonymousCowardName,
       GerritPersonIdentProvider gerritPersonIdentProvider,
+      Groups groups,
       @CanonicalWebUrl @Nullable Provider<String> urlProvider,
       AllProjectsName allProjectsName,
       ChangeQueryBuilder queryBuilder,
@@ -110,10 +112,11 @@
       @SshAdvertisedAddresses List<String> sshAddresses,
       SitePaths site,
       DynamicSet<OutgoingEmailValidationListener> outgoingEmailValidationListeners,
-      StarredChangesUtil starredChangesUtil,
-      Provider<InternalAccountQuery> accountQueryProvider) {
+      Provider<InternalAccountQuery> accountQueryProvider,
+      OutgoingEmailValidator validator) {
     this.server = server;
     this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
     this.groupBackend = groupBackend;
     this.groupIncludes = groupIncludes;
     this.accountCache = accountCache;
@@ -123,11 +126,11 @@
     this.emailSender = emailSender;
     this.patchSetInfoFactory = patchSetInfoFactory;
     this.identifiedUserFactory = identifiedUserFactory;
-    this.capabilityControlFactory = capabilityControlFactory;
     this.changeNotesFactory = changeNotesFactory;
     this.anonymousUser = anonymousUser;
     this.anonymousCowardName = anonymousCowardName;
     this.gerritPersonIdent = gerritPersonIdentProvider.get();
+    this.groups = groups;
     this.urlProvider = urlProvider;
     this.allProjectsName = allProjectsName;
     this.queryBuilder = queryBuilder;
@@ -139,7 +142,7 @@
     this.sshAddresses = sshAddresses;
     this.site = site;
     this.outgoingEmailValidationListeners = outgoingEmailValidationListeners;
-    this.starredChangesUtil = starredChangesUtil;
     this.accountQueryProvider = accountQueryProvider;
+    this.validator = validator;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java
index e2b5894..0bfe428 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/EmailHeader.java
@@ -83,7 +83,7 @@
     return false;
   }
 
-  static boolean needsQuotedPrintableWithinPhrase(final int cp) {
+  static boolean needsQuotedPrintableWithinPhrase(int cp) {
     switch (cp) {
       case '!':
       case '*':
@@ -202,7 +202,7 @@
       int len = 8;
       boolean firstAddress = true;
       boolean needComma = false;
-      for (final Address addr : list) {
+      for (Address addr : list) {
         java.lang.String s = addr.toHeaderString();
         if (firstAddress) {
           firstAddress = false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
index db52626..c2c6834 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProvider.java
@@ -41,11 +41,10 @@
 
   @Inject
   FromAddressGeneratorProvider(
-      @GerritServerConfig final Config cfg,
-      @AnonymousCowardName final String anonymousCowardName,
-      @GerritPersonIdent final PersonIdent myIdent,
-      final AccountCache accountCache) {
-
+      @GerritServerConfig Config cfg,
+      @AnonymousCowardName String anonymousCowardName,
+      @GerritPersonIdent PersonIdent myIdent,
+      AccountCache accountCache) {
     final String from = cfg.getString("sendemail", null, "from");
     final Address srvAddr = toAddress(myIdent);
 
@@ -73,7 +72,7 @@
     }
   }
 
-  private static Address toAddress(final PersonIdent myIdent) {
+  private static Address toAddress(PersonIdent myIdent) {
     return new Address(myIdent.getName(), myIdent.getEmailAddress());
   }
 
@@ -119,7 +118,7 @@
     }
 
     @Override
-    public Address from(final Account.Id fromId) {
+    public Address from(Account.Id fromId) {
       String senderName;
       if (fromId != null) {
         Account a = accountCache.get(fromId).getAccount();
@@ -172,7 +171,7 @@
     }
 
     @Override
-    public Address from(final Account.Id fromId) {
+    public Address from(Account.Id fromId) {
       return srvAddr;
     }
   }
@@ -203,7 +202,7 @@
     }
 
     @Override
-    public Address from(final Account.Id fromId) {
+    public Address from(Account.Id fromId) {
       final String senderName;
 
       if (fromId != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
index 47115af..425ac65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/MergedSender.java
@@ -41,7 +41,7 @@
   public MergedSender(EmailArguments ea, @Assisted Project.NameKey project, @Assisted Change.Id id)
       throws OrmException {
     super(ea, "merged", newChangeData(ea, project, id));
-    labelTypes = changeData.changeControl().getLabelTypes();
+    labelTypes = changeData.getLabelTypes();
   }
 
   @Override
@@ -70,7 +70,12 @@
       Table<Account.Id, String, PatchSetApproval> neg = HashBasedTable.create();
       for (PatchSetApproval ca :
           args.approvalsUtil.byPatchSet(
-              args.db.get(), changeData.changeControl(), patchSet.getId())) {
+              args.db.get(),
+              changeData.notes(),
+              args.identifiedUserFactory.create(changeData.change().getOwner()),
+              patchSet.getId(),
+              null,
+              null)) {
         LabelType lt = labelTypes.byLabel(ca.getLabelId());
         if (lt == null) {
           continue;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
index 45fdeb7..9f94fa3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/NewChangeSender.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.common.errors.EmailException;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
@@ -28,20 +29,30 @@
 /** Sends an email alerting a user to a new change for them to review. */
 public abstract class NewChangeSender extends ChangeEmail {
   private final Set<Account.Id> reviewers = new HashSet<>();
+  private final Set<Address> reviewersByEmail = new HashSet<>();
   private final Set<Account.Id> extraCC = new HashSet<>();
+  private final Set<Address> extraCCByEmail = new HashSet<>();
 
   protected NewChangeSender(EmailArguments ea, ChangeData cd) throws OrmException {
     super(ea, "newchange", cd);
   }
 
-  public void addReviewers(final Collection<Account.Id> cc) {
+  public void addReviewers(Collection<Account.Id> cc) {
     reviewers.addAll(cc);
   }
 
-  public void addExtraCC(final Collection<Account.Id> cc) {
+  public void addReviewersByEmail(Collection<Address> cc) {
+    reviewersByEmail.addAll(cc);
+  }
+
+  public void addExtraCC(Collection<Account.Id> cc) {
     extraCC.addAll(cc);
   }
 
+  public void addExtraCCByEmail(Collection<Address> cc) {
+    extraCCByEmail.addAll(cc);
+  }
+
   @Override
   protected void init() throws EmailException {
     super.init();
@@ -55,9 +66,11 @@
       case ALL:
       default:
         add(RecipientType.CC, extraCC);
+        extraCCByEmail.stream().forEach(cc -> add(RecipientType.CC, cc));
         // $FALL-THROUGH$
       case OWNER_REVIEWERS:
-        add(RecipientType.TO, reviewers);
+        add(RecipientType.TO, reviewers, true);
+        addByEmail(RecipientType.TO, reviewersByEmail, true);
         break;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
index 465f131..7420611 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmail.java
@@ -32,6 +32,7 @@
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.mail.send.EmailHeader.AddressList;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.validators.OutgoingEmailValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.gwtorm.server.OrmException;
@@ -92,7 +93,7 @@
     headers = new LinkedHashMap<>();
   }
 
-  public void setFrom(final Account.Id id) {
+  public void setFrom(Account.Id id) {
     fromId = id;
   }
 
@@ -310,26 +311,26 @@
   }
 
   /** Set a header in the outgoing message using a template. */
-  protected void setVHeader(final String name, final String value) throws EmailException {
+  protected void setVHeader(String name, String value) throws EmailException {
     setHeader(name, velocify(value));
   }
 
   /** Set a header in the outgoing message. */
-  protected void setHeader(final String name, final String value) {
+  protected void setHeader(String name, String value) {
     headers.put(name, new EmailHeader.String(value));
   }
 
   /** Remove a header from the outgoing message. */
-  protected void removeHeader(final String name) {
+  protected void removeHeader(String name) {
     headers.remove(name);
   }
 
-  protected void setHeader(final String name, final Date date) {
+  protected void setHeader(String name, Date date) {
     headers.put(name, new EmailHeader.Date(date));
   }
 
   /** Append text to the outgoing email body. */
-  protected void appendText(final String text) {
+  protected void appendText(String text) {
     if (text != null) {
       textBody.append(text);
     }
@@ -427,26 +428,51 @@
   }
 
   /** Schedule this message for delivery to the listed accounts. */
-  protected void add(final RecipientType rt, final Collection<Account.Id> list) {
+  protected void add(RecipientType rt, Collection<Account.Id> list) {
+    add(rt, list, false);
+  }
+
+  /** Schedule this message for delivery to the listed accounts. */
+  protected void add(RecipientType rt, Collection<Account.Id> list, boolean override) {
     for (final Account.Id id : list) {
-      add(rt, id);
+      add(rt, id, override);
     }
   }
 
-  protected void add(final RecipientType rt, final UserIdentity who) {
+  /** Schedule this message for delivery to the listed address. */
+  protected void addByEmail(RecipientType rt, Collection<Address> list) {
+    addByEmail(rt, list, false);
+  }
+
+  /** Schedule this message for delivery to the listed address. */
+  protected void addByEmail(RecipientType rt, Collection<Address> list, boolean override) {
+    for (final Address id : list) {
+      add(rt, id, override);
+    }
+  }
+
+  protected void add(RecipientType rt, UserIdentity who) {
+    add(rt, who, false);
+  }
+
+  protected void add(RecipientType rt, UserIdentity who, boolean override) {
     if (who != null && who.getAccount() != null) {
-      add(rt, who.getAccount());
+      add(rt, who.getAccount(), override);
     }
   }
 
   /** Schedule delivery of this message to the given account. */
-  protected void add(final RecipientType rt, final Account.Id to) {
+  protected void add(RecipientType rt, Account.Id to) {
+    add(rt, to, false);
+  }
+
+  protected void add(RecipientType rt, Account.Id to, boolean override) {
     try {
       if (!rcptTo.contains(to) && isVisibleTo(to)) {
         rcptTo.add(to);
-        add(rt, toAddress(to));
+        add(rt, toAddress(to), override);
       }
-    } catch (OrmException e) {
+    } catch (OrmException | PermissionBackendException e) {
       log.error("Error reading database for account: " + to, e);
     }
   }
@@ -454,20 +480,32 @@
   /**
    * @param to account.
    * @throws OrmException
+   * @throws PermissionBackendException
    * @return whether this email is visible to the given account.
    */
-  protected boolean isVisibleTo(final Account.Id to) throws OrmException {
+  protected boolean isVisibleTo(Account.Id to) throws OrmException, PermissionBackendException {
     return true;
   }
 
   /** Schedule delivery of this message to the given account. */
-  protected void add(final RecipientType rt, final Address addr) {
+  protected void add(RecipientType rt, Address addr) {
+    add(rt, addr, false);
+  }
+
+  protected void add(RecipientType rt, Address addr, boolean override) {
     if (addr != null && addr.getEmail() != null && addr.getEmail().length() > 0) {
-      if (!OutgoingEmailValidator.isValid(addr.getEmail())) {
+      if (!args.validator.isValid(addr.getEmail())) {
         log.warn("Not emailing " + addr.getEmail() + " (invalid email address)");
       } else if (!args.emailSender.canEmail(addr.getEmail())) {
         log.warn("Not emailing " + addr.getEmail() + " (prohibited by allowrcpt)");
-      } else if (smtpRcptTo.add(addr)) {
+      } else {
+        if (!smtpRcptTo.add(addr)) {
+          if (!override) {
+            return;
+          }
+          ((EmailHeader.AddressList) headers.get(HDR_TO)).remove(addr.getEmail());
+          ((EmailHeader.AddressList) headers.get(HDR_CC)).remove(addr.getEmail());
+        }
         switch (rt) {
           case TO:
             ((EmailHeader.AddressList) headers.get(HDR_TO)).add(addr);
@@ -482,7 +520,7 @@
     }
   }
 
-  private Address toAddress(final Account.Id id) {
+  private Address toAddress(Account.Id id) {
     final Account a = args.accountCache.get(id).getAccount();
     final String e = a.getPreferredEmail();
     if (!a.isActive() || e == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
index 2d9db1d..1a4d39b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/OutgoingEmailValidator.java
@@ -16,15 +16,34 @@
 
 import static org.apache.commons.validator.routines.DomainValidator.ArrayType.GENERIC_PLUS;
 
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
 import org.apache.commons.validator.routines.DomainValidator;
 import org.apache.commons.validator.routines.EmailValidator;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
+@Singleton
 public class OutgoingEmailValidator {
-  static {
-    DomainValidator.updateTLDOverride(GENERIC_PLUS, new String[] {"local"});
+  private static final Logger log = LoggerFactory.getLogger(OutgoingEmailValidator.class);
+
+  @Inject
+  OutgoingEmailValidator(@GerritServerConfig Config config) {
+    String[] allowTLD = config.getStringList("sendemail", null, "allowTLD");
+    if (allowTLD.length != 0) {
+      try {
+        DomainValidator.updateTLDOverride(GENERIC_PLUS, allowTLD);
+      } catch (IllegalStateException e) {
+        // Should only happen in tests, where the OutgoingEmailValidator
+        // is instantiated repeatedly.
+        log.error("Failed to update TLD override: " + e.getMessage());
+      }
+    }
   }
 
-  public static boolean isValid(String addr) {
+  public boolean isValid(String addr) {
     return EmailValidator.getInstance(true, true).isValid(addr);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
index b459d25..e1b6e36 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ProjectWatch.java
@@ -16,11 +16,12 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupDescriptions;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.errors.NoSuchGroupException;
+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.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupMember;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
@@ -31,8 +32,6 @@
 import com.google.gerrit.server.git.NotifyConfig;
 import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ProjectState;
-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.ChangeQueryBuilder;
 import com.google.gerrit.server.query.change.SingleGroupUser;
@@ -104,7 +103,7 @@
           } catch (QueryParseException e) {
             log.warn(
                 "Project {} has invalid notify {} filter \"{}\": {}",
-                state.getProject().getName(),
+                state.getName(),
                 nc.getName(),
                 nc.getFilter(),
                 e.getMessage());
@@ -141,7 +140,7 @@
 
   private void add(Watchers matching, NotifyConfig nc) throws OrmException, QueryParseException {
     for (GroupReference ref : nc.getGroups()) {
-      CurrentUser user = new SingleGroupUser(args.capabilityControlFactory, ref.getUUID());
+      CurrentUser user = new SingleGroupUser(ref.getUUID());
       if (filterMatch(user, nc.getFilter())) {
         deliverToMembers(matching.list(nc.getHeader()), ref.getUUID());
       }
@@ -166,20 +165,25 @@
     while (!q.isEmpty()) {
       AccountGroup.UUID uuid = q.remove(q.size() - 1);
       GroupDescription.Basic group = args.groupBackend.get(uuid);
+      if (group == null) {
+        continue;
+      }
       if (!Strings.isNullOrEmpty(group.getEmailAddress())) {
         // If the group has an email address, do not expand membership.
         matching.emails.add(new Address(group.getEmailAddress()));
         continue;
       }
 
-      AccountGroup ig = GroupDescriptions.toAccountGroup(group);
-      if (ig == null) {
+      if (!(group instanceof GroupDescription.Internal)) {
         // Non-internal groups cannot be expanded by the server.
         continue;
       }
 
-      for (AccountGroupMember m : db.accountGroupMembers().byGroup(ig.getId())) {
-        matching.accounts.add(m.getAccountId());
+      GroupDescription.Internal ig = (GroupDescription.Internal) group;
+      try {
+        args.groups.getMembers(db, ig.getGroupUUID()).forEach(matching.accounts::add);
+      } catch (NoSuchGroupException e) {
+        continue;
       }
       for (AccountGroup.UUID m : args.groupIncludes.subgroupsOf(uuid)) {
         if (seen.add(m)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
index c90000f..c9e5791 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/ReplacePatchSetSender.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.mail.send;
 
 import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.api.changes.RecipientType;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -45,11 +46,11 @@
     super(ea, "newpatchset", newChangeData(ea, project, id));
   }
 
-  public void addReviewers(final Collection<Account.Id> cc) {
+  public void addReviewers(Collection<Account.Id> cc) {
     reviewers.addAll(cc);
   }
 
-  public void addExtraCC(final Collection<Account.Id> cc) {
+  public void addExtraCC(Collection<Account.Id> cc) {
     extraCC.addAll(cc);
   }
 
@@ -62,11 +63,13 @@
       //
       reviewers.remove(fromId);
     }
-    add(RecipientType.TO, reviewers);
-    add(RecipientType.CC, extraCC);
+    if (notify == NotifyHandling.ALL || notify == NotifyHandling.OWNER_REVIEWERS) {
+      add(RecipientType.TO, reviewers);
+      add(RecipientType.CC, extraCC);
+    }
     rcptToAuthors(RecipientType.CC);
     bccStarredBy();
-    includeWatchers(NotifyType.NEW_PATCHSETS, !patchSet.isDraft());
+    includeWatchers(NotifyType.NEW_PATCHSETS, !change.isWorkInProgress() && !change.isPrivate());
     removeUsersThatIgnoredTheChange();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
index 583cfe6..b08e594 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/send/SmtpEmailSender.java
@@ -76,7 +76,7 @@
   private int expiryDays;
 
   @Inject
-  SmtpEmailSender(@GerritServerConfig final Config cfg) {
+  SmtpEmailSender(@GerritServerConfig Config cfg) {
     enabled = cfg.getBoolean("sendemail", null, "enable", true);
     connectTimeout =
         Ints.checkedCast(
@@ -200,30 +200,31 @@
           }
         }
 
-        Writer messageDataWriter = client.sendMessageData();
-        if (messageDataWriter == null) {
-          /* Include rejected recipient error messages here to not lose that
-           * information. That piece of the puzzle is vital if zero recipients
-           * are accepted and the server consequently rejects the DATA command.
-           */
-          throw new EmailException(
-              rejected
-                  + "Server "
-                  + smtpHost
-                  + " rejected DATA command: "
-                  + client.getReplyString());
-        }
+        try (Writer messageDataWriter = client.sendMessageData()) {
+          if (messageDataWriter == null) {
+            /* Include rejected recipient error messages here to not lose that
+             * information. That piece of the puzzle is vital if zero recipients
+             * are accepted and the server consequently rejects the DATA command.
+             */
+            throw new EmailException(
+                rejected
+                    + "Server "
+                    + smtpHost
+                    + " rejected DATA command: "
+                    + client.getReplyString());
+          }
 
-        render(messageDataWriter, callerHeaders, textBody, htmlBody);
+          render(messageDataWriter, callerHeaders, textBody, htmlBody);
 
-        if (!client.completePendingCommand()) {
-          throw new EmailException(
-              "Server " + smtpHost + " rejected message body: " + client.getReplyString());
-        }
+          if (!client.completePendingCommand()) {
+            throw new EmailException(
+                "Server " + smtpHost + " rejected message body: " + client.getReplyString());
+          }
 
-        client.logout();
-        if (rejected.length() > 0) {
-          throw new EmailException(rejected.toString());
+          client.logout();
+          if (rejected.length() > 0) {
+            throw new EmailException(rejected.toString());
+          }
         }
       } finally {
         client.disconnect();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
index e9e3c71..15ee1bd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/FileTypeRegistry.java
@@ -16,6 +16,7 @@
 
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
+import java.io.InputStream;
 
 public interface FileTypeRegistry {
   /**
@@ -33,6 +34,20 @@
   MimeType getMimeType(String path, byte[] content);
 
   /**
+   * Get the most specific MIME type available for a file.
+   *
+   * @param path name of the file. The base name (component after the last '/') may be used to help
+   *     determine the MIME type, such as by examining the extension (portion after the last '.' if
+   *     present).
+   * @param is InputStream corresponding to the complete file content. The content may be used to
+   *     guess the MIME type by examining the beginning for common file headers.
+   * @return the MIME type for this content. If the MIME type is not recognized or cannot be
+   *     determined, {@link MimeUtil2#UNKNOWN_MIME_TYPE} which is an alias for {@code
+   *     application/octet-stream}.
+   */
+  MimeType getMimeType(String path, InputStream is);
+
+  /**
    * Is this content type safe to transmit to a browser directly?
    *
    * @param type the MIME type of the file content.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java b/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
index 859363c..7cb34e2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mime/MimeUtilFileTypeRegistry.java
@@ -20,6 +20,7 @@
 import eu.medsea.mimeutil.MimeException;
 import eu.medsea.mimeutil.MimeType;
 import eu.medsea.mimeutil.MimeUtil2;
+import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -78,7 +79,7 @@
 
   @Override
   @SuppressWarnings("unchecked")
-  public MimeType getMimeType(final String path, final byte[] content) {
+  public MimeType getMimeType(String path, byte[] content) {
     Set<MimeType> mimeTypes = new HashSet<>();
     if (content != null && content.length > 0) {
       try {
@@ -87,6 +88,23 @@
         log.warn("Unable to determine MIME type from content", e);
       }
     }
+    return getMimeType(mimeTypes, path);
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  public MimeType getMimeType(String path, InputStream is) {
+    Set<MimeType> mimeTypes = new HashSet<>();
+    try {
+      mimeTypes.addAll(mimeUtil.getMimeTypes(is));
+    } catch (MimeException e) {
+      log.warn("Unable to determine MIME type from content", e);
+    }
+    return getMimeType(mimeTypes, path);
+  }
+
+  @SuppressWarnings("unchecked")
+  private MimeType getMimeType(Set<MimeType> mimeTypes, String path) {
     try {
       mimeTypes.addAll(mimeUtil.getMimeTypes(path));
     } catch (MimeException e) {
@@ -110,7 +128,7 @@
   }
 
   @Override
-  public boolean isSafeInline(final MimeType type) {
+  public boolean isSafeInline(MimeType type) {
     if (MimeUtil2.UNKNOWN_MIME_TYPE.equals(type)) {
       // Most browsers perform content type sniffing when they get told
       // a generic content type. This is bad, so assume we cannot send
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
index 7b19b39..ef2c9b3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeNotes.java
@@ -123,7 +123,10 @@
     this.args = checkNotNull(args);
     this.changeId = checkNotNull(changeId);
     this.primaryStorage = primaryStorage;
-    this.autoRebuild = primaryStorage == PrimaryStorage.REVIEW_DB && autoRebuild;
+    this.autoRebuild =
+        primaryStorage == PrimaryStorage.REVIEW_DB
+            && !args.migration.disableChangeReviewDb()
+            && autoRebuild;
   }
 
   public Change.Id getChangeId() {
@@ -143,7 +146,7 @@
     if (!read && primaryStorage == PrimaryStorage.NOTE_DB) {
       throw new OrmException("NoteDb is required to read change " + changeId);
     }
-    boolean readOrWrite = read || args.migration.writeChanges();
+    boolean readOrWrite = read || args.migration.rawWriteChangesSetting();
     if (!readOrWrite) {
       // Don't even open the repo if we neither write to nor read from NoteDb. It's possible that
       // there is some garbage in the noteDbState field and/or the repo, but at this point NoteDb is
@@ -151,7 +154,7 @@
       loadDefaults();
       return self();
     }
-    if (args.migration.failOnLoad()) {
+    if (args.migration.failOnLoadForTest()) {
       throw new OrmException("Reading from NoteDb is disabled");
     }
     try (Timer1.Context timer = args.metrics.readLatency.start(CHANGES);
@@ -203,7 +206,7 @@
   public ObjectId loadRevision() throws OrmException {
     if (loaded) {
       return getRevision();
-    } else if (!args.migration.enabled()) {
+    } else if (!args.migration.readChanges()) {
       return null;
     }
     try (Repository repo = args.repoManager.openRepository(getProjectName())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 472eda1..f3f3a13 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.sql.Timestamp;
@@ -57,11 +56,13 @@
 
   protected PatchSet.Id psId;
   private ObjectId result;
+  protected boolean rootOnly;
 
   protected AbstractChangeUpdate(
       Config cfg,
       NotesMigration migration,
-      ChangeControl ctl,
+      ChangeNotes notes,
+      CurrentUser user,
       PersonIdent serverIdent,
       String anonymousCowardName,
       ChangeNoteUtil noteUtil,
@@ -70,12 +71,12 @@
     this.noteUtil = noteUtil;
     this.serverIdent = new PersonIdent(serverIdent, when);
     this.anonymousCowardName = anonymousCowardName;
-    this.notes = ctl.getNotes();
+    this.notes = notes;
     this.change = notes.getChange();
-    this.accountId = accountId(ctl.getUser());
-    Account.Id realAccountId = accountId(ctl.getUser().getRealUser());
+    this.accountId = accountId(user);
+    Account.Id realAccountId = accountId(user.getRealUser());
     this.realAccountId = realAccountId != null ? realAccountId : accountId;
-    this.authorIdent = ident(noteUtil, serverIdent, anonymousCowardName, ctl.getUser(), when);
+    this.authorIdent = ident(noteUtil, serverIdent, anonymousCowardName, user, when);
     this.when = when;
     this.readOnlySkewMs = NoteDbChangeState.getReadOnlySkew(cfg);
   }
@@ -190,6 +191,11 @@
   /** Whether no updates have been done. */
   public abstract boolean isEmpty();
 
+  /** Wether this update can only be a root commit. */
+  public boolean isRootOnly() {
+    return rootOnly;
+  }
+
   /**
    * @return the NameKey for the project where the update will be stored, which is not necessarily
    *     the same as the change's project.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
index acfbbac..a2e0997 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -68,6 +68,7 @@
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.TreeMap;
 import java.util.TreeSet;
@@ -233,11 +234,12 @@
     // last time this file was updated.
     checkColumns(Change.Id.class, 1);
 
-    checkColumns(Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 101);
+    checkColumns(
+        Change.class, 1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 20, 21, 22, 23, 101);
     checkColumns(ChangeMessage.Key.class, 1, 2);
     checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
     checkColumns(PatchSet.Id.class, 1, 2);
-    checkColumns(PatchSet.class, 1, 2, 3, 4, 5, 6, 8, 9);
+    checkColumns(PatchSet.class, 1, 2, 3, 4, 6, 8, 9);
     checkColumns(PatchSetApproval.Key.class, 1, 2, 3);
     checkColumns(PatchSetApproval.class, 1, 2, 3, 6, 7, 8);
     checkColumns(PatchLineComment.Key.class, 1, 2);
@@ -386,6 +388,8 @@
     boolean excludeCreatedOn = false;
     boolean excludeCurrentPatchSetId = false;
     boolean excludeTopic = false;
+    Timestamp aCreated = a.getCreatedOn();
+    Timestamp bCreated = b.getCreatedOn();
     Timestamp aUpdated = a.getLastUpdatedOn();
     Timestamp bUpdated = b.getLastUpdatedOn();
 
@@ -396,8 +400,10 @@
     String aSubj = Strings.nullToEmpty(a.getSubject());
     String bSubj = Strings.nullToEmpty(b.getSubject());
 
-    // Allow created timestamp in NoteDb to be either the created timestamp of
-    // the change, or the timestamp of the first remaining patch set.
+    // Allow created timestamp in NoteDb to be any of:
+    //  - The created timestamp of the change.
+    //  - The timestamp of the first remaining patch set.
+    //  - The last updated timestamp, if it is less than the created timestamp.
     //
     // Ignore subject if the NoteDb subject starts with the ReviewDb subject.
     // The NoteDb subject is read directly from the commit, whereas the ReviewDb
@@ -433,8 +439,14 @@
     //
     // Use max timestamp of all ReviewDb entities when comparing with NoteDb.
     if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
+      boolean createdOnMatchesFirstPs =
+          !timestampsDiffer(bundleA, bundleA.getFirstPatchSetTime(), bundleB, bCreated);
+      boolean createdOnMatchesLastUpdatedOn =
+          !timestampsDiffer(bundleA, aUpdated, bundleB, bCreated);
+      boolean createdAfterUpdated = aCreated.compareTo(aUpdated) > 0;
       excludeCreatedOn =
-          !timestampsDiffer(bundleA, bundleA.getFirstPatchSetTime(), bundleB, b.getCreatedOn());
+          createdOnMatchesFirstPs || (createdAfterUpdated && createdOnMatchesLastUpdatedOn);
+
       aSubj = cleanReviewDbSubject(aSubj);
       bSubj = cleanNoteDbSubject(bSubj);
       excludeCurrentPatchSetId = !bundleA.validPatchSetPredicate().apply(a.currentPatchSetId());
@@ -445,8 +457,14 @@
           Objects.equals(aTopic, b.getTopic()) || ("".equals(aTopic) && b.getTopic() == null);
       aUpdated = bundleA.getLatestTimestamp();
     } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
+      boolean createdOnMatchesFirstPs =
+          !timestampsDiffer(bundleA, aCreated, bundleB, bundleB.getFirstPatchSetTime());
+      boolean createdOnMatchesLastUpdatedOn =
+          !timestampsDiffer(bundleA, aCreated, bundleB, bUpdated);
+      boolean createdAfterUpdated = bCreated.compareTo(bUpdated) > 0;
       excludeCreatedOn =
-          !timestampsDiffer(bundleA, a.getCreatedOn(), bundleB, bundleB.getFirstPatchSetTime());
+          createdOnMatchesFirstPs || (createdAfterUpdated && createdOnMatchesLastUpdatedOn);
+
       aSubj = cleanNoteDbSubject(aSubj);
       bSubj = cleanReviewDbSubject(bSubj);
       excludeCurrentPatchSetId = !bundleB.validPatchSetPredicate().apply(b.currentPatchSetId());
@@ -650,6 +668,8 @@
       List<String> diffs, ChangeBundle bundleA, ChangeBundle bundleB) {
     Map<PatchSet.Id, PatchSet> as = bundleA.patchSets;
     Map<PatchSet.Id, PatchSet> bs = bundleB.patchSets;
+    Optional<PatchSet.Id> minA = as.keySet().stream().min(intKeyOrdering());
+    Optional<PatchSet.Id> minB = bs.keySet().stream().min(intKeyOrdering());
     Set<PatchSet.Id> ids = diffKeySets(diffs, as, bs);
 
     // Old versions of Gerrit had a bug that created patch sets during
@@ -662,11 +682,14 @@
     // ignore the createdOn timestamps if both:
     //   * ReviewDb timestamps are non-monotonic.
     //   * NoteDb timestamps are monotonic.
-    boolean excludeCreatedOn = false;
+    //
+    // Allow the timestamp of the first patch set to match the creation time of
+    // the change.
+    boolean excludeAllCreatedOn = false;
     if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
-      excludeCreatedOn = !createdOnIsMonotonic(as, ids) && createdOnIsMonotonic(bs, ids);
+      excludeAllCreatedOn = !createdOnIsMonotonic(as, ids) && createdOnIsMonotonic(bs, ids);
     } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
-      excludeCreatedOn = createdOnIsMonotonic(as, ids) && !createdOnIsMonotonic(bs, ids);
+      excludeAllCreatedOn = createdOnIsMonotonic(as, ids) && !createdOnIsMonotonic(bs, ids);
     }
 
     for (PatchSet.Id id : ids) {
@@ -675,11 +698,16 @@
       String desc = describe(id);
       String pushCertField = "pushCertificate";
 
+      boolean excludeCreatedOn = excludeAllCreatedOn;
       boolean excludeDesc = false;
       if (bundleA.source == REVIEW_DB && bundleB.source == NOTE_DB) {
         excludeDesc = Objects.equals(trimOrNull(a.getDescription()), b.getDescription());
+        excludeCreatedOn |=
+            Optional.of(id).equals(minB) && b.getCreatedOn().equals(bundleB.change.getCreatedOn());
       } else if (bundleA.source == NOTE_DB && bundleB.source == REVIEW_DB) {
         excludeDesc = Objects.equals(a.getDescription(), trimOrNull(b.getDescription()));
+        excludeCreatedOn |=
+            Optional.of(id).equals(minA) && a.getCreatedOn().equals(bundleA.change.getCreatedOn());
       }
 
       List<String> exclude = Lists.newArrayList(pushCertField);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
index 9e7a1fe1..3207c3b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundleReader.java
@@ -14,10 +14,12 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gwtorm.server.OrmException;
 
 public interface ChangeBundleReader {
+  @Nullable
   ChangeBundle fromReviewDb(ReviewDb db, Change.Id id) throws OrmException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index c848987..0628913 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -73,6 +73,7 @@
   public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
   public static final FooterKey FOOTER_PATCH_SET_DESCRIPTION =
       new FooterKey("Patch-set-description");
+  public static final FooterKey FOOTER_PRIVATE = new FooterKey("Private");
   public static final FooterKey FOOTER_READ_ONLY_UNTIL = new FooterKey("Read-only-until");
   public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
   public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
@@ -81,6 +82,8 @@
   public static final FooterKey FOOTER_SUBMITTED_WITH = new FooterKey("Submitted-with");
   public static final FooterKey FOOTER_TOPIC = new FooterKey("Topic");
   public static final FooterKey FOOTER_TAG = new FooterKey("Tag");
+  public static final FooterKey FOOTER_WORK_IN_PROGRESS = new FooterKey("Work-in-progress");
+  public static final FooterKey FOOTER_REVERT_OF = new FooterKey("Revert-of");
 
   private static final String AUTHOR = "Author";
   private static final String BASE_PATCH_SET = "Base-for-patch-set";
@@ -128,7 +131,7 @@
     this.serverIdent = serverIdent;
     this.anonymousCowardName = anonymousCowardName;
     this.serverId = serverId;
-    this.writeJson = config.getBoolean("notedb", "writeJson", false);
+    this.writeJson = config.getBoolean("notedb", "writeJson", true);
   }
 
   @VisibleForTesting
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 9582fb3..e652a35 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -21,17 +21,21 @@
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 import static java.util.Comparator.comparing;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSortedMap;
 import com.google.common.collect.ImmutableSortedSet;
-import com.google.common.collect.Iterables;
+import com.google.common.collect.Iterators;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
+import com.google.common.collect.Sets;
+import com.google.common.collect.Sets.SetView;
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.metrics.Timer1;
@@ -48,6 +52,7 @@
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.git.RefCache;
@@ -66,12 +71,14 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Predicate;
+import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
@@ -94,6 +101,7 @@
     return new ConfigInvalidException("Change " + changeId + ": " + String.format(fmt, args));
   }
 
+  @Nullable
   public static Change readOneReviewDbChange(ReviewDb db, Change.Id id) throws OrmException {
     return ReviewDbUtil.unwrapDb(db).changes().get(id);
   }
@@ -220,7 +228,7 @@
     public List<ChangeNotes> create(ReviewDb db, Collection<Change.Id> changeIds)
         throws OrmException {
       List<ChangeNotes> notes = new ArrayList<>();
-      if (args.migration.enabled()) {
+      if (args.migration.readChanges()) {
         for (Change.Id changeId : changeIds) {
           try {
             notes.add(createChecked(changeId));
@@ -244,11 +252,17 @@
         Predicate<ChangeNotes> predicate)
         throws OrmException {
       List<ChangeNotes> notes = new ArrayList<>();
-      if (args.migration.enabled()) {
+      if (args.migration.readChanges()) {
         for (Change.Id cid : changeIds) {
-          ChangeNotes cn = create(db, project, cid);
-          if (cn.getChange() != null && predicate.test(cn)) {
-            notes.add(cn);
+          try {
+            ChangeNotes cn = create(db, project, cid);
+            if (cn.getChange() != null && predicate.test(cn)) {
+              notes.add(cn);
+            }
+          } catch (NoSuchChangeException e) {
+            // Match ReviewDb behavior, returning not found; maybe the caller learned about it from
+            // a dangling patch set ref or something.
+            continue;
           }
         }
         return notes;
@@ -272,12 +286,11 @@
       if (args.migration.readChanges()) {
         for (Project.NameKey project : projectCache.all()) {
           try (Repository repo = args.repoManager.openRepository(project)) {
-            List<ChangeNotes> changes = scanNoteDb(repo, db, project);
-            for (ChangeNotes cn : changes) {
-              if (predicate.test(cn)) {
-                m.put(project, cn);
-              }
-            }
+            scanNoteDb(repo, db, project)
+                .filter(r -> !r.error().isPresent())
+                .map(ChangeNotesResult::notes)
+                .filter(predicate)
+                .forEach(n -> m.put(n.getProjectName(), n));
           }
         }
       } else {
@@ -291,67 +304,150 @@
       return ImmutableListMultimap.copyOf(m);
     }
 
-    public List<ChangeNotes> scan(Repository repo, ReviewDb db, Project.NameKey project)
-        throws OrmException, IOException {
-      if (!args.migration.readChanges()) {
-        return scanDb(repo, db);
-      }
-
-      return scanNoteDb(repo, db, project);
+    public Stream<ChangeNotesResult> scan(Repository repo, ReviewDb db, Project.NameKey project)
+        throws IOException {
+      return args.migration.readChanges() ? scanNoteDb(repo, db, project) : scanReviewDb(repo, db);
     }
 
-    private List<ChangeNotes> scanDb(Repository repo, ReviewDb db)
-        throws OrmException, IOException {
-      Set<Change.Id> ids = scan(repo);
-      List<ChangeNotes> notes = new ArrayList<>(ids.size());
-      // A batch size of N may overload get(Iterable), so use something smaller,
-      // but still >1.
-      for (List<Change.Id> batch : Iterables.partition(ids, 30)) {
-        for (Change change : ReviewDbUtil.unwrapDb(db).changes().get(batch)) {
-          notes.add(createFromChangeOnlyWhenNoteDbDisabled(change));
-        }
-      }
-      return notes;
+    private Stream<ChangeNotesResult> scanReviewDb(Repository repo, ReviewDb db)
+        throws IOException {
+      // Scan IDs that might exist in ReviewDb, assuming that each change has at least one patch set
+      // ref. Not all changes might exist: some patch set refs might have been written where the
+      // corresponding ReviewDb write failed. These will be silently filtered out by the batch get
+      // call below, which is intended.
+      Set<Change.Id> ids = scanChangeIds(repo).fromPatchSetRefs();
+
+      // A batch size of N may overload get(Iterable), so use something smaller, but still >1.
+      return Streams.stream(Iterators.partition(ids.iterator(), 30))
+          .flatMap(
+              batch -> {
+                try {
+                  return Streams.stream(ReviewDbUtil.unwrapDb(db).changes().get(batch))
+                      .map(this::toResult)
+                      .filter(Objects::nonNull);
+                } catch (OrmException e) {
+                  // Return this error for each Id in the input batch.
+                  return batch.stream().map(id -> ChangeNotesResult.error(id, e));
+                }
+              });
     }
 
-    private List<ChangeNotes> scanNoteDb(Repository repo, ReviewDb db, Project.NameKey project)
-        throws OrmException, IOException {
-      Set<Change.Id> ids = scan(repo);
-      List<ChangeNotes> changeNotes = new ArrayList<>(ids.size());
+    private Stream<ChangeNotesResult> scanNoteDb(
+        Repository repo, ReviewDb db, Project.NameKey project) throws IOException {
+      ScanResult sr = scanChangeIds(repo);
       PrimaryStorage defaultStorage = args.migration.changePrimaryStorage();
-      for (Change.Id id : ids) {
-        Change change = readOneReviewDbChange(db, id);
-        if (change == null) {
-          if (defaultStorage == PrimaryStorage.REVIEW_DB) {
-            log.warn("skipping change {} found in project {} but not in ReviewDb", id, project);
-            continue;
-          }
-          // TODO(dborowitz): See discussion in BatchUpdate#newChangeContext.
-          change = newNoteDbOnlyChange(project, id);
-        } else if (!change.getProject().equals(project)) {
-          log.error(
-              "skipping change {} found in project {} because ReviewDb change has project {}",
-              id,
-              project,
-              change.getProject());
-          continue;
-        }
-        log.debug("adding change {} found in project {}", id, project);
-        changeNotes.add(new ChangeNotes(args, change).load());
-      }
-      return changeNotes;
+
+      return sr.all()
+          .stream()
+          .map(id -> scanOneNoteDbChange(db, project, sr, defaultStorage, id))
+          .filter(Objects::nonNull);
     }
 
-    public static Set<Change.Id> scan(Repository repo) throws IOException {
-      Map<String, Ref> refs = repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES);
-      Set<Change.Id> ids = new HashSet<>(refs.size());
-      for (Ref r : refs.values()) {
+    private ChangeNotesResult scanOneNoteDbChange(
+        ReviewDb db,
+        Project.NameKey project,
+        ScanResult sr,
+        PrimaryStorage defaultStorage,
+        Change.Id id) {
+      Change change;
+      try {
+        change = readOneReviewDbChange(db, id);
+      } catch (OrmException e) {
+        return ChangeNotesResult.error(id, e);
+      }
+
+      if (change == null) {
+        if (!sr.fromMetaRefs().contains(id)) {
+          // Stray patch set refs can happen due to normal error conditions, e.g. failed
+          // push processing, so aren't worth even a warning.
+          return null;
+        }
+        if (defaultStorage == PrimaryStorage.REVIEW_DB) {
+          // If changes should exist in ReviewDb, it's worth warning about a meta ref with
+          // no corresponding ReviewDb data.
+          log.warn("skipping change {} found in project {} but not in ReviewDb", id, project);
+          return null;
+        }
+        // TODO(dborowitz): See discussion in NoteDbBatchUpdate#newChangeContext.
+        change = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
+      } else if (!change.getProject().equals(project)) {
+        log.error(
+            "skipping change {} found in project {} because ReviewDb change has" + " project {}",
+            id,
+            project,
+            change.getProject());
+        return null;
+      }
+      log.debug("adding change {} found in project {}", id, project);
+      return toResult(change);
+    }
+
+    @Nullable
+    private ChangeNotesResult toResult(Change rawChangeFromReviewDbOrNoteDb) {
+      ChangeNotes n = new ChangeNotes(args, rawChangeFromReviewDbOrNoteDb);
+      try {
+        n.load();
+      } catch (OrmException e) {
+        return ChangeNotesResult.error(n.getChangeId(), e);
+      }
+      return ChangeNotesResult.notes(n);
+    }
+
+    /** Result of {@link #scan(Repository, ReviewDb, Project.NameKey)}. */
+    @AutoValue
+    public abstract static class ChangeNotesResult {
+      static ChangeNotesResult error(Change.Id id, OrmException e) {
+        return new AutoValue_ChangeNotes_Factory_ChangeNotesResult(id, Optional.of(e), null);
+      }
+
+      static ChangeNotesResult notes(ChangeNotes notes) {
+        return new AutoValue_ChangeNotes_Factory_ChangeNotesResult(
+            notes.getChangeId(), Optional.empty(), notes);
+      }
+
+      /** Change ID that was scanned. */
+      public abstract Change.Id id();
+
+      /** Error encountered while loading this change, if any. */
+      public abstract Optional<OrmException> error();
+
+      /**
+       * Notes loaded for this change.
+       *
+       * @return notes.
+       * @throws IllegalStateException if there was an error loading the change; callers must check
+       *     that {@link #error()} is absent before attempting to look up the notes.
+       */
+      public ChangeNotes notes() {
+        checkState(maybeNotes() != null, "no ChangeNotes loaded; check error().isPresent() first");
+        return maybeNotes();
+      }
+
+      @Nullable
+      abstract ChangeNotes maybeNotes();
+    }
+
+    @AutoValue
+    abstract static class ScanResult {
+      abstract ImmutableSet<Change.Id> fromPatchSetRefs();
+
+      abstract ImmutableSet<Change.Id> fromMetaRefs();
+
+      SetView<Change.Id> all() {
+        return Sets.union(fromPatchSetRefs(), fromMetaRefs());
+      }
+    }
+
+    private static ScanResult scanChangeIds(Repository repo) throws IOException {
+      ImmutableSet.Builder<Change.Id> fromPs = ImmutableSet.builder();
+      ImmutableSet.Builder<Change.Id> fromMeta = ImmutableSet.builder();
+      for (Ref r : repo.getRefDatabase().getRefs(RefNames.REFS_CHANGES).values()) {
         Change.Id id = Change.Id.fromRef(r.getName());
         if (id != null) {
-          ids.add(id);
+          (r.getName().endsWith(RefNames.META_SUFFIX) ? fromMeta : fromPs).add(id);
         }
       }
-      return ids;
+      return new AutoValue_ChangeNotes_Factory_ScanResult(fromPs.build(), fromMeta.build());
     }
   }
 
@@ -424,6 +520,21 @@
     return state.reviewers();
   }
 
+  /** @return reviewers that do not currently have a Gerrit account and were added by email. */
+  public ReviewerByEmailSet getReviewersByEmail() {
+    return state.reviewersByEmail();
+  }
+
+  /** @return reviewers that were modified during this change's current WIP phase. */
+  public ReviewerSet getPendingReviewers() {
+    return state.pendingReviewers();
+  }
+
+  /** @return reviewers by email that were modified during this change's current WIP phase. */
+  public ReviewerByEmailSet getPendingReviewersByEmail() {
+    return state.pendingReviewersByEmail();
+  }
+
   public ImmutableList<ReviewerStatusUpdate> getReviewerUpdates() {
     return state.reviewerUpdates();
   }
@@ -559,6 +670,28 @@
     return state.readOnlyUntil();
   }
 
+  public boolean isPrivate() {
+    if (state.isPrivate() == null) {
+      return false;
+    }
+    return state.isPrivate();
+  }
+
+  public boolean isWorkInProgress() {
+    if (state.isWorkInProgress() == null) {
+      return false;
+    }
+    return state.isWorkInProgress();
+  }
+
+  public Change.Id getRevertOf() {
+    return state.revertOf();
+  }
+
+  public boolean hasReviewStarted() {
+    return state.hasReviewStarted();
+  }
+
   @Override
   protected void onLoad(LoadHandle handle)
       throws NoSuchChangeException, IOException, ConfigInvalidException {
@@ -599,16 +732,27 @@
   protected LoadHandle openHandle(Repository repo) throws NoSuchChangeException, IOException {
     if (autoRebuild) {
       NoteDbChangeState state = NoteDbChangeState.parse(change);
+      if (args.migration.disableChangeReviewDb()) {
+        checkState(
+            state != null,
+            "shouldn't have null NoteDbChangeState when ReviewDb disabled: %s",
+            change);
+      }
       ObjectId id = readRef(repo);
       if (id == null) {
+        // Meta ref doesn't exist in NoteDb.
+
         if (state == null) {
+          // Either ReviewDb change is being newly created, or it exists in ReviewDb but has not yet
+          // been rebuilt for the first time, e.g. because we just turned on write-only mode. In
+          // both cases, we don't want to auto-rebuild, just proceed with an empty ChangeNotes.
           return super.openHandle(repo, id);
-        } else if (shouldExist) {
-          // TODO(dborowitz): This means we have a state recorded in noteDbState but the ref doesn't
-          // exist for whatever reason. Doesn't this mean we should trigger an auto-rebuild, rather
-          // than throwing?
+        } else if (shouldExist && state.getPrimaryStorage() == PrimaryStorage.NOTE_DB) {
           throw new NoSuchChangeException(getChangeId());
         }
+
+        // ReviewDb claims NoteDb state exists, but meta ref isn't present: fall through and
+        // auto-rebuild if necessary.
       }
       RefCache refs = this.refs != null ? this.refs : new RepoRefCache(repo);
       if (!NoteDbChangeState.isChangeUpToDate(state, refs, getChangeId())) {
@@ -649,6 +793,7 @@
           rebuildResult = checkNotNull(r);
           checkNotNull(r.newState());
           checkNotNull(r.staged());
+          checkNotNull(r.staged().changeObjects());
           return LoadHandle.create(
               ChangeNotesCommit.newStagedRevWalk(repo, r.staged().changeObjects()),
               r.newState().getChangeMetaId());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 8c7e762..676dbb8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -17,10 +17,13 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
+import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.ReviewerByEmailSet;
+import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.notedb.AbstractChangeNotes.Args;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
@@ -111,6 +114,14 @@
           + P
           + list(state.patchSets(), patchSet())
           + P
+          + reviewerSet(state.reviewers(), 2) // REVIEWER or CC
+          + P
+          + reviewerSet(state.reviewersByEmail(), 2) // REVIEWER or CC
+          + P
+          + reviewerSet(state.pendingReviewers(), 3) // includes REMOVED
+          + P
+          + reviewerSet(state.pendingReviewersByEmail(), 3) // includes REMOVED
+          + P
           + list(state.allPastReviewers(), approval())
           + P
           + list(state.reviewerUpdates(), 4 * O + K + K + P)
@@ -122,7 +133,11 @@
           + P
           + map(state.changeMessagesByPatchSet().asMap(), patchSetId())
           + P
-          + map(state.publishedComments().asMap(), comment());
+          + map(state.publishedComments().asMap(), comment())
+          + T // readOnlyUntil
+          + 1 // isPrivate
+          + 1 // workInProgress
+          + 1; // hasReviewStarted
     }
 
     private static int ptr(Object o, int size) {
@@ -176,6 +191,27 @@
       return O + O + n * (P + elemSize);
     }
 
+    private static int hashBasedTable(
+        Table<?, ?, ?> table, int numRows, int rowKey, int columnKey, int elemSize) {
+      return O
+          + hashtable(numRows, rowKey + hashtable(0, 0))
+          + hashtable(table.size(), columnKey + elemSize);
+    }
+
+    private static int reviewerSet(ReviewerSet reviewers, int numRows) {
+      final int rowKey = 1; // ReviewerStateInternal
+      final int columnKey = K; // Account.Id
+      final int cellValue = T; // Timestamp
+      return hashBasedTable(reviewers.asTable(), numRows, rowKey, columnKey, cellValue);
+    }
+
+    private static int reviewerSet(ReviewerByEmailSet reviewers, int numRows) {
+      final int rowKey = 1; // ReviewerStateInternal
+      final int columnKey = P + 2 * str(20); // name and email, just a guess
+      final int cellValue = T; // Timestamp
+      return hashBasedTable(reviewers.asTable(), numRows, rowKey, columnKey, cellValue);
+    }
+
     private static int patchSet() {
       return O
           + P
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index cd51e0d..d6472bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -24,14 +24,17 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
 import static java.util.stream.Collectors.joining;
 
@@ -40,6 +43,7 @@
 import com.google.common.base.Splitter;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableTable;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Lists;
@@ -62,8 +66,10 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.LabelVote;
 import java.io.IOException;
@@ -127,6 +133,7 @@
   // Private final but mutable members initialized in the constructor and filled
   // in during the parsing process.
   private final Table<Account.Id, ReviewerStateInternal, Timestamp> reviewers;
+  private final Table<Address, ReviewerStateInternal, Timestamp> reviewersByEmail;
   private final List<Account.Id> allPastReviewers;
   private final List<ReviewerStatusUpdate> reviewerUpdates;
   private final List<SubmitRecord> submitRecords;
@@ -157,6 +164,13 @@
   private String tag;
   private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
   private Timestamp readOnlyUntil;
+  private Boolean isPrivate;
+  private Boolean workInProgress;
+  private Boolean previousWorkInProgressFooter;
+  private Boolean hasReviewStarted;
+  private ReviewerSet pendingReviewers;
+  private ReviewerByEmailSet pendingReviewersByEmail;
+  private Change.Id revertOf;
 
   ChangeNotesParser(
       Change.Id changeId,
@@ -172,6 +186,9 @@
     approvals = new LinkedHashMap<>();
     bufferedApprovals = new ArrayList<>();
     reviewers = HashBasedTable.create();
+    reviewersByEmail = HashBasedTable.create();
+    pendingReviewers = ReviewerSet.empty();
+    pendingReviewersByEmail = ReviewerByEmailSet.empty();
     allPastReviewers = new ArrayList<>();
     reviewerUpdates = new ArrayList<>();
     submitRecords = Lists.newArrayListWithExpectedSize(1);
@@ -196,9 +213,17 @@
       while ((commit = walk.next()) != null) {
         parse(commit);
       }
+      if (hasReviewStarted == null) {
+        if (previousWorkInProgressFooter == null) {
+          hasReviewStarted = true;
+        } else {
+          hasReviewStarted = !previousWorkInProgressFooter;
+        }
+      }
       parseNotes();
       allPastReviewers.addAll(reviewers.rowKeySet());
       pruneReviewers();
+      pruneReviewersByEmail();
 
       updatePatchSetStates();
       checkMandatoryFooters();
@@ -232,13 +257,20 @@
         patchSets,
         buildApprovals(),
         ReviewerSet.fromTable(Tables.transpose(reviewers)),
+        ReviewerByEmailSet.fromTable(Tables.transpose(reviewersByEmail)),
+        pendingReviewers,
+        pendingReviewersByEmail,
         allPastReviewers,
         buildReviewerUpdates(),
         submitRecords,
         buildAllMessages(),
         buildMessagesByPatchSet(),
         comments,
-        readOnlyUntil);
+        readOnlyUntil,
+        isPrivate,
+        workInProgress,
+        hasReviewStarted,
+        revertOf);
   }
 
   private PatchSet.Id buildCurrentPatchSetId() {
@@ -371,6 +403,9 @@
       for (String line : commit.getFooterLineValues(state.getFooterKey())) {
         parseReviewer(ts, state, line);
       }
+      for (String line : commit.getFooterLineValues(state.getByEmailFooterKey())) {
+        parseReviewerByEmail(ts, state, line);
+      }
       // Don't update timestamp when a reviewer was added, matching RevewDb
       // behavior.
     }
@@ -379,6 +414,17 @@
       parseReadOnlyUntil(commit);
     }
 
+    if (isPrivate == null) {
+      parseIsPrivate(commit);
+    }
+
+    if (revertOf == null) {
+      revertOf = parseRevertOf(commit);
+    }
+
+    previousWorkInProgressFooter = null;
+    parseWorkInProgress(commit);
+
     if (lastUpdatedOn == null || ts.after(lastUpdatedOn)) {
       lastUpdatedOn = ts;
     }
@@ -910,6 +956,19 @@
     }
   }
 
+  private void parseReviewerByEmail(Timestamp ts, ReviewerStateInternal state, String line)
+      throws ConfigInvalidException {
+    Address adr;
+    try {
+      adr = Address.parse(line);
+    } catch (IllegalArgumentException e) {
+      throw invalidFooter(state.getByEmailFooterKey(), line);
+    }
+    if (!reviewersByEmail.containsRow(adr)) {
+      reviewersByEmail.put(adr, state, ts);
+    }
+  }
+
   private void parseReadOnlyUntil(ChangeNotesCommit commit) throws ConfigInvalidException {
     String raw = parseOneFooter(commit, FOOTER_READ_ONLY_UNTIL);
     if (raw == null) {
@@ -924,6 +983,64 @@
     }
   }
 
+  private void parseIsPrivate(ChangeNotesCommit commit) throws ConfigInvalidException {
+    String raw = parseOneFooter(commit, FOOTER_PRIVATE);
+    if (raw == null) {
+      return;
+    } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
+      isPrivate = true;
+      return;
+    } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
+      isPrivate = false;
+      return;
+    }
+    throw invalidFooter(FOOTER_PRIVATE, raw);
+  }
+
+  private void parseWorkInProgress(ChangeNotesCommit commit) throws ConfigInvalidException {
+    String raw = parseOneFooter(commit, FOOTER_WORK_IN_PROGRESS);
+    if (raw == null) {
+      // No change to WIP state in this revision.
+      previousWorkInProgressFooter = null;
+      return;
+    } else if (Boolean.TRUE.toString().equalsIgnoreCase(raw)) {
+      // This revision moves the change into WIP.
+      previousWorkInProgressFooter = true;
+      if (workInProgress == null) {
+        // Because this is the first time workInProgress is being set, we know
+        // that this change's current state is WIP. All the reviewer updates
+        // we've seen so far are pending, so take a snapshot of the reviewers
+        // and reviewersByEmail tables.
+        pendingReviewers =
+            ReviewerSet.fromTable(Tables.transpose(ImmutableTable.copyOf(reviewers)));
+        pendingReviewersByEmail =
+            ReviewerByEmailSet.fromTable(Tables.transpose(ImmutableTable.copyOf(reviewersByEmail)));
+        workInProgress = true;
+      }
+      return;
+    } else if (Boolean.FALSE.toString().equalsIgnoreCase(raw)) {
+      previousWorkInProgressFooter = false;
+      hasReviewStarted = true;
+      if (workInProgress == null) {
+        workInProgress = false;
+      }
+      return;
+    }
+    throw invalidFooter(FOOTER_WORK_IN_PROGRESS, raw);
+  }
+
+  private Change.Id parseRevertOf(ChangeNotesCommit commit) throws ConfigInvalidException {
+    String footer = parseOneFooter(commit, FOOTER_REVERT_OF);
+    if (footer == null) {
+      return null;
+    }
+    Integer revertOf = Ints.tryParse(footer);
+    if (revertOf == null) {
+      throw invalidFooter(FOOTER_REVERT_OF, footer);
+    }
+    return new Change.Id(revertOf);
+  }
+
   private void pruneReviewers() {
     Iterator<Table.Cell<Account.Id, ReviewerStateInternal, Timestamp>> rit =
         reviewers.cellSet().iterator();
@@ -935,6 +1052,17 @@
     }
   }
 
+  private void pruneReviewersByEmail() {
+    Iterator<Table.Cell<Address, ReviewerStateInternal, Timestamp>> rit =
+        reviewersByEmail.cellSet().iterator();
+    while (rit.hasNext()) {
+      Table.Cell<Address, ReviewerStateInternal, Timestamp> e = rit.next();
+      if (e.getColumnKey() == ReviewerStateInternal.REMOVED) {
+        rit.remove();
+      }
+    }
+  }
+
   private void updatePatchSetStates() {
     Set<PatchSet.Id> missing = new TreeSet<>(ReviewDbUtil.intKeyOrdering());
     for (Iterator<PatchSet> it = patchSets.values().iterator(); it.hasNext(); ) {
@@ -953,13 +1081,6 @@
         case DELETED:
           patchSets.remove(e.getKey());
           break;
-
-        case DRAFT:
-          PatchSet ps = patchSets.get(e.getKey());
-          if (ps != null) {
-            ps.setDraft(true);
-          }
-          break;
       }
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
index 7b25bbd..1dd944d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesState.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
@@ -65,12 +66,19 @@
         ImmutableList.of(),
         ImmutableList.of(),
         ReviewerSet.empty(),
+        ReviewerByEmailSet.empty(),
+        ReviewerSet.empty(),
+        ReviewerByEmailSet.empty(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableList.of(),
         ImmutableListMultimap.of(),
         ImmutableListMultimap.of(),
+        null,
+        null,
+        null,
+        true,
         null);
   }
 
@@ -94,13 +102,20 @@
       Map<PatchSet.Id, PatchSet> patchSets,
       ListMultimap<PatchSet.Id, PatchSetApproval> approvals,
       ReviewerSet reviewers,
+      ReviewerByEmailSet reviewersByEmail,
+      ReviewerSet pendingReviewers,
+      ReviewerByEmailSet pendingReviewersByEmail,
       List<Account.Id> allPastReviewers,
       List<ReviewerStatusUpdate> reviewerUpdates,
       List<SubmitRecord> submitRecords,
       List<ChangeMessage> allChangeMessages,
       ListMultimap<PatchSet.Id, ChangeMessage> changeMessagesByPatchSet,
       ListMultimap<RevId, Comment> publishedComments,
-      @Nullable Timestamp readOnlyUntil) {
+      @Nullable Timestamp readOnlyUntil,
+      @Nullable Boolean isPrivate,
+      @Nullable Boolean workInProgress,
+      boolean hasReviewStarted,
+      @Nullable Change.Id revertOf) {
     if (hashtags == null) {
       hashtags = ImmutableSet.of();
     }
@@ -119,19 +134,30 @@
             originalSubject,
             submissionId,
             assignee,
-            status),
+            status,
+            isPrivate,
+            workInProgress,
+            hasReviewStarted,
+            revertOf),
         ImmutableSet.copyOf(pastAssignees),
         ImmutableSet.copyOf(hashtags),
         ImmutableList.copyOf(patchSets.entrySet()),
         ImmutableList.copyOf(approvals.entries()),
         reviewers,
+        reviewersByEmail,
+        pendingReviewers,
+        pendingReviewersByEmail,
         ImmutableList.copyOf(allPastReviewers),
         ImmutableList.copyOf(reviewerUpdates),
         ImmutableList.copyOf(submitRecords),
         ImmutableList.copyOf(allChangeMessages),
         ImmutableListMultimap.copyOf(changeMessagesByPatchSet),
         ImmutableListMultimap.copyOf(publishedComments),
-        readOnlyUntil);
+        readOnlyUntil,
+        isPrivate,
+        workInProgress,
+        hasReviewStarted,
+        revertOf);
   }
 
   /**
@@ -174,6 +200,18 @@
     // TODO(dborowitz): Use a sensible default other than null
     @Nullable
     abstract Change.Status status();
+
+    @Nullable
+    abstract Boolean isPrivate();
+
+    @Nullable
+    abstract Boolean isWorkInProgress();
+
+    @Nullable
+    abstract Boolean hasReviewStarted();
+
+    @Nullable
+    abstract Change.Id revertOf();
   }
 
   // Only null if NoteDb is disabled.
@@ -197,6 +235,12 @@
 
   abstract ReviewerSet reviewers();
 
+  abstract ReviewerByEmailSet reviewersByEmail();
+
+  abstract ReviewerSet pendingReviewers();
+
+  abstract ReviewerByEmailSet pendingReviewersByEmail();
+
   abstract ImmutableList<Account.Id> allPastReviewers();
 
   abstract ImmutableList<ReviewerStatusUpdate> reviewerUpdates();
@@ -212,6 +256,18 @@
   @Nullable
   abstract Timestamp readOnlyUntil();
 
+  @Nullable
+  abstract Boolean isPrivate();
+
+  @Nullable
+  abstract Boolean isWorkInProgress();
+
+  @Nullable
+  abstract Boolean hasReviewStarted();
+
+  @Nullable
+  abstract Change.Id revertOf();
+
   Change newChange(Project.NameKey project) {
     ChangeColumns c = checkNotNull(columns(), "columns are required");
     Change change =
@@ -269,6 +325,10 @@
     change.setLastUpdatedOn(c.lastUpdatedOn());
     change.setSubmissionId(c.submissionId());
     change.setAssignee(c.assignee());
+    change.setPrivate(c.isPrivate() == null ? false : c.isPrivate());
+    change.setWorkInProgress(c.isWorkInProgress() == null ? false : c.isWorkInProgress());
+    change.setReviewStarted(c.hasReviewStarted() == null ? false : c.hasReviewStarted());
+    change.setRevertOf(c.revertOf());
 
     if (!patchSets().isEmpty()) {
       change.setCurrentPatchSet(c.currentPatchSetId(), c.subject(), c.originalSubject());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 7af0cb4..7e0daa6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -29,14 +29,17 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET_DESCRIPTION;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PRIVATE;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_READ_ONLY_UNTIL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REVERT_OF;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMITTED_WITH;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TAG;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_TOPIC;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_WORK_IN_PROGRESS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.sanitizeFooter;
 import static java.util.Comparator.comparing;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -56,11 +59,12 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.server.util.RequestId;
@@ -104,9 +108,9 @@
  */
 public class ChangeUpdate extends AbstractChangeUpdate {
   public interface Factory {
-    ChangeUpdate create(ChangeControl ctl);
+    ChangeUpdate create(ChangeNotes notes, CurrentUser user);
 
-    ChangeUpdate create(ChangeControl ctl, Date when);
+    ChangeUpdate create(ChangeNotes notes, CurrentUser user, Date when);
 
     ChangeUpdate create(
         Change change,
@@ -117,16 +121,19 @@
         Comparator<String> labelNameComparator);
 
     @VisibleForTesting
-    ChangeUpdate create(ChangeControl ctl, Date when, Comparator<String> labelNameComparator);
+    ChangeUpdate create(
+        ChangeNotes notes, CurrentUser user, Date when, Comparator<String> labelNameComparator);
   }
 
   private final AccountCache accountCache;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
   private final ChangeDraftUpdate.Factory draftUpdateFactory;
   private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
-  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final DeleteCommentRewriter.Factory deleteCommentRewriterFactory;
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
   private final Map<Account.Id, ReviewerStateInternal> reviewers = new LinkedHashMap<>();
+  private final Map<Address, ReviewerStateInternal> reviewersByEmail = new LinkedHashMap<>();
   private final List<Comment> comments = new ArrayList<>();
 
   private String commitSubject;
@@ -149,9 +156,13 @@
   private String psDescription;
   private boolean currentPatchSet;
   private Timestamp readOnlyUntil;
+  private Boolean isPrivate;
+  private Boolean workInProgress;
+  private Integer revertOf;
 
   private ChangeDraftUpdate draftUpdate;
   private RobotCommentUpdate robotCommentUpdate;
+  private DeleteCommentRewriter deleteCommentRewriter;
 
   @AssistedInject
   private ChangeUpdate(
@@ -163,8 +174,10 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ProjectCache projectCache,
-      @Assisted ChangeControl ctl,
+      @Assisted ChangeNotes notes,
+      @Assisted CurrentUser user,
       ChangeNoteUtil noteUtil) {
     this(
         cfg,
@@ -175,8 +188,10 @@
         updateManagerFactory,
         draftUpdateFactory,
         robotCommentUpdateFactory,
+        deleteCommentRewriterFactory,
         projectCache,
-        ctl,
+        notes,
+        user,
         serverIdent.getWhen(),
         noteUtil);
   }
@@ -191,8 +206,10 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ProjectCache projectCache,
-      @Assisted ChangeControl ctl,
+      @Assisted ChangeNotes notes,
+      @Assisted CurrentUser user,
       @Assisted Date when,
       ChangeNoteUtil noteUtil) {
     this(
@@ -204,16 +221,14 @@
         updateManagerFactory,
         draftUpdateFactory,
         robotCommentUpdateFactory,
-        ctl,
+        deleteCommentRewriterFactory,
+        notes,
+        user,
         when,
-        projectCache.get(getProjectName(ctl)).getLabelTypes().nameComparator(),
+        projectCache.get(notes.getProjectName()).getLabelTypes().nameComparator(),
         noteUtil);
   }
 
-  private static Project.NameKey getProjectName(ChangeControl ctl) {
-    return ctl.getProject().getNameKey();
-  }
-
   private static Table<String, Account.Id, Optional<Short>> approvals(
       Comparator<String> nameComparator) {
     return TreeBasedTable.create(nameComparator, comparing(IntKey::get));
@@ -229,15 +244,18 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
-      @Assisted ChangeControl ctl,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
+      @Assisted ChangeNotes notes,
+      @Assisted CurrentUser user,
       @Assisted Date when,
       @Assisted Comparator<String> labelNameComparator,
       ChangeNoteUtil noteUtil) {
-    super(cfg, migration, ctl, serverIdent, anonymousCowardName, noteUtil, when);
+    super(cfg, migration, notes, user, serverIdent, anonymousCowardName, noteUtil, when);
     this.accountCache = accountCache;
+    this.updateManagerFactory = updateManagerFactory;
     this.draftUpdateFactory = draftUpdateFactory;
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
-    this.updateManagerFactory = updateManagerFactory;
+    this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
     this.approvals = approvals(labelNameComparator);
   }
 
@@ -251,6 +269,7 @@
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
+      DeleteCommentRewriter.Factory deleteCommentRewriterFactory,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
       @Assisted("effective") @Nullable Account.Id accountId,
@@ -274,6 +293,7 @@
     this.draftUpdateFactory = draftUpdateFactory;
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
+    this.deleteCommentRewriterFactory = deleteCommentRewriterFactory;
     this.approvals = approvals(labelNameComparator);
   }
 
@@ -388,6 +408,11 @@
     createDraftUpdateIfNull().deleteComment(c);
   }
 
+  public void deleteCommentByRewritingHistory(String uuid, String newMessage) {
+    deleteCommentRewriter =
+        deleteCommentRewriterFactory.create(getChange().getId(), uuid, newMessage);
+  }
+
   @VisibleForTesting
   ChangeDraftUpdate createDraftUpdateIfNull() {
     if (draftUpdate == null) {
@@ -469,6 +494,15 @@
     reviewers.put(reviewer, ReviewerStateInternal.REMOVED);
   }
 
+  public void putReviewerByEmail(Address reviewer, ReviewerStateInternal type) {
+    checkArgument(type != ReviewerStateInternal.REMOVED, "invalid ReviewerType");
+    reviewersByEmail.put(reviewer, type);
+  }
+
+  public void removeReviewerByEmail(Address reviewer) {
+    reviewersByEmail.put(reviewer, ReviewerStateInternal.REMOVED);
+  }
+
   public void setPatchSetState(PatchSetState psState) {
     this.psState = psState;
   }
@@ -482,6 +516,13 @@
     this.groups = groups;
   }
 
+  public void setRevertOf(int revertOf) {
+    int ownId = getChange().getId().get();
+    checkArgument(ownId != revertOf, "A change cannot revert itself");
+    this.revertOf = revertOf;
+    rootOnly = true;
+  }
+
   /** @return the tree id for the updated tree */
   private ObjectId storeRevisionNotes(RevWalk rw, ObjectInserter inserter, ObjectId curr)
       throws ConfigInvalidException, OrmException, IOException {
@@ -581,6 +622,8 @@
   @Override
   protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins, ObjectId curr)
       throws OrmException, IOException {
+    checkState(deleteCommentRewriter == null, "cannot update and rewrite ref in one BatchUpdate");
+
     CommitBuilder cb = new CommitBuilder();
 
     int ps = psId != null ? psId.get() : getChange().currentPatchSetId().get();
@@ -658,6 +701,10 @@
       addIdent(msg, e.getKey()).append('\n');
     }
 
+    for (Map.Entry<Address, ReviewerStateInternal> e : reviewersByEmail.entrySet()) {
+      addFooter(msg, e.getValue().getByEmailFooterKey(), e.getKey().toString());
+    }
+
     for (Table.Cell<String, Account.Id, Optional<Short>> c : approvals.cellSet()) {
       addFooter(msg, FOOTER_LABEL);
       // Label names/values are safe to append without sanitizing.
@@ -711,6 +758,18 @@
       addFooter(msg, FOOTER_READ_ONLY_UNTIL, ChangeNoteUtil.formatTime(serverIdent, readOnlyUntil));
     }
 
+    if (isPrivate != null) {
+      addFooter(msg, FOOTER_PRIVATE, isPrivate);
+    }
+
+    if (workInProgress != null) {
+      addFooter(msg, FOOTER_WORK_IN_PROGRESS, workInProgress);
+    }
+
+    if (revertOf != null) {
+      addFooter(msg, FOOTER_REVERT_OF, revertOf);
+    }
+
     cb.setMessage(msg.toString());
     try {
       ObjectId treeId = storeRevisionNotes(rw, ins, curr);
@@ -743,6 +802,7 @@
         && changeMessage == null
         && comments.isEmpty()
         && reviewers.isEmpty()
+        && reviewersByEmail.isEmpty()
         && changeId == null
         && branch == null
         && status == null
@@ -757,7 +817,10 @@
         && tag == null
         && psDescription == null
         && !currentPatchSet
-        && readOnlyUntil == null;
+        && readOnlyUntil == null
+        && isPrivate == null
+        && workInProgress == null
+        && revertOf == null;
   }
 
   ChangeDraftUpdate getDraftUpdate() {
@@ -768,6 +831,10 @@
     return robotCommentUpdate;
   }
 
+  public DeleteCommentRewriter getDeleteCommentRewriter() {
+    return deleteCommentRewriter;
+  }
+
   public void setAllowWriteToNewRef(boolean allow) {
     isAllowWriteToNewtRef = allow;
   }
@@ -777,6 +844,14 @@
     return isAllowWriteToNewtRef;
   }
 
+  public void setPrivate(boolean isPrivate) {
+    this.isPrivate = isPrivate;
+  }
+
+  public void setWorkInProgress(boolean workInProgress) {
+    this.workInProgress = workInProgress;
+  }
+
   void setReadOnlyUntil(Timestamp readOnlyUntil) {
     this.readOnlyUntil = readOnlyUntil;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
deleted file mode 100644
index c0b0525..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ConfigNotesMigration.java
+++ /dev/null
@@ -1,132 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.notedb;
-
-import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.inject.AbstractModule;
-import com.google.inject.Inject;
-import com.google.inject.Singleton;
-import java.util.Set;
-import org.eclipse.jgit.lib.Config;
-
-/**
- * Implement NoteDb migration stages using {@code gerrit.config}.
- *
- * <p>This class controls the state of the migration according to options in {@code gerrit.config}.
- * In general, any changes to these options should only be made by adventurous administrators, who
- * know what they're doing, on non-production data, for the purposes of testing the NoteDb
- * implementation. Changing options quite likely requires re-running {@code RebuildNoteDb}. For
- * these reasons, the options remain undocumented.
- */
-@Singleton
-public class ConfigNotesMigration extends NotesMigration {
-  public static class Module extends AbstractModule {
-    @Override
-    public void configure() {
-      bind(NotesMigration.class).to(ConfigNotesMigration.class);
-    }
-  }
-
-  private static final String NOTE_DB = "noteDb";
-
-  // All of these names must be reflected in the allowed set in checkConfig.
-  private static final String DISABLE_REVIEW_DB = "disableReviewDb";
-  private static final String PRIMARY_STORAGE = "primaryStorage";
-  private static final String READ = "read";
-  private static final String SEQUENCE = "sequence";
-  private static final String WRITE = "write";
-
-  private static void checkConfig(Config cfg) {
-    Set<String> keys = ImmutableSet.of(CHANGES.key());
-    Set<String> allowed =
-        ImmutableSet.of(
-            DISABLE_REVIEW_DB.toLowerCase(),
-            PRIMARY_STORAGE.toLowerCase(),
-            READ.toLowerCase(),
-            WRITE.toLowerCase(),
-            SEQUENCE.toLowerCase());
-    for (String t : cfg.getSubsections(NOTE_DB)) {
-      checkArgument(keys.contains(t.toLowerCase()), "invalid NoteDb table: %s", t);
-      for (String key : cfg.getNames(NOTE_DB, t)) {
-        checkArgument(allowed.contains(key.toLowerCase()), "invalid NoteDb key: %s.%s", t, key);
-      }
-    }
-  }
-
-  public static Config allEnabledConfig() {
-    Config cfg = new Config();
-    cfg.setBoolean(NOTE_DB, CHANGES.key(), WRITE, true);
-    cfg.setBoolean(NOTE_DB, CHANGES.key(), READ, true);
-    return cfg;
-  }
-
-  private final boolean writeChanges;
-  private final boolean readChanges;
-  private final boolean readChangeSequence;
-  private final PrimaryStorage changePrimaryStorage;
-  private final boolean disableChangeReviewDb;
-
-  @Inject
-  ConfigNotesMigration(@GerritServerConfig Config cfg) {
-    checkConfig(cfg);
-
-    writeChanges = cfg.getBoolean(NOTE_DB, CHANGES.key(), WRITE, false);
-    readChanges = cfg.getBoolean(NOTE_DB, CHANGES.key(), READ, false);
-
-    // Reading change sequence numbers from NoteDb is not the default even if
-    // reading changes themselves is. Once this is enabled, it's not easy to
-    // undo: ReviewDb might hand out numbers that have already been assigned by
-    // NoteDb. This decision for the default may be reevaluated later.
-    readChangeSequence = cfg.getBoolean(NOTE_DB, CHANGES.key(), SEQUENCE, false);
-
-    changePrimaryStorage =
-        cfg.getEnum(NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB);
-    disableChangeReviewDb = cfg.getBoolean(NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false);
-
-    checkArgument(
-        !(disableChangeReviewDb && changePrimaryStorage != PrimaryStorage.NOTE_DB),
-        "cannot disable ReviewDb for changes if default change primary storage is ReviewDb");
-  }
-
-  @Override
-  protected boolean writeChanges() {
-    return writeChanges;
-  }
-
-  @Override
-  public boolean readChanges() {
-    return readChanges;
-  }
-
-  @Override
-  public boolean readChangeSequence() {
-    return readChangeSequence;
-  }
-
-  @Override
-  public PrimaryStorage changePrimaryStorage() {
-    return changePrimaryStorage;
-  }
-
-  @Override
-  public boolean disableChangeReviewDb() {
-    return disableChangeReviewDb;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
new file mode 100644
index 0000000..db7b86a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DeleteCommentRewriter.java
@@ -0,0 +1,239 @@
+// 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.notedb;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.reviewdb.client.PatchLineComment.Status.PUBLISHED;
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toMap;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Deletes a published comment from NoteDb by rewriting the commit history. Instead of deleting the
+ * whole comment, it just replaces the comment's message with a new message.
+ */
+public class DeleteCommentRewriter implements NoteDbRewriter {
+
+  public interface Factory {
+    /**
+     * Creates a DeleteCommentRewriter instance.
+     *
+     * @param id the id of the change which contains the target comment.
+     * @param uuid the uuid of the target comment.
+     * @param newMessage the message used to replace the old message of the target comment.
+     * @return the DeleteCommentRewriter instance
+     */
+    DeleteCommentRewriter create(
+        Change.Id id, @Assisted("uuid") String uuid, @Assisted("newMessage") String newMessage);
+  }
+
+  private final ChangeNoteUtil noteUtil;
+  private final Change.Id changeId;
+  private final String uuid;
+  private final String newMessage;
+
+  @Inject
+  DeleteCommentRewriter(
+      ChangeNoteUtil noteUtil,
+      @Assisted Change.Id changeId,
+      @Assisted("uuid") String uuid,
+      @Assisted("newMessage") String newMessage) {
+    this.noteUtil = noteUtil;
+    this.changeId = changeId;
+    this.uuid = uuid;
+    this.newMessage = newMessage;
+  }
+
+  @Override
+  public String getRefName() {
+    return RefNames.changeMetaRef(changeId);
+  }
+
+  @Override
+  public ObjectId rewriteCommitHistory(RevWalk revWalk, ObjectInserter inserter, ObjectId currTip)
+      throws IOException, ConfigInvalidException, OrmException {
+    checkArgument(!currTip.equals(ObjectId.zeroId()));
+
+    // Walk from the first commit of the branch.
+    revWalk.reset();
+    revWalk.markStart(revWalk.parseCommit(currTip));
+    revWalk.sort(RevSort.REVERSE);
+
+    ObjectReader reader = revWalk.getObjectReader();
+    RevCommit newTipCommit = revWalk.next(); // The first commit will not be rewritten.
+    Map<String, Comment> parentComments =
+        getPublishedComments(noteUtil, changeId, reader, NoteMap.read(reader, newTipCommit));
+
+    boolean rewrite = false;
+    RevCommit originalCommit;
+    while ((originalCommit = revWalk.next()) != null) {
+      NoteMap noteMap = NoteMap.read(reader, originalCommit);
+      Map<String, Comment> currComments = getPublishedComments(noteUtil, changeId, reader, noteMap);
+
+      if (!rewrite && currComments.containsKey(uuid)) {
+        rewrite = true;
+      }
+
+      if (!rewrite) {
+        parentComments = currComments;
+        newTipCommit = originalCommit;
+        continue;
+      }
+
+      List<Comment> putInComments = getPutInComments(parentComments, currComments);
+      List<Comment> deletedComments = getDeletedComments(parentComments, currComments);
+      newTipCommit =
+          revWalk.parseCommit(
+              rewriteCommit(
+                  originalCommit, newTipCommit, inserter, reader, putInComments, deletedComments));
+      parentComments = currComments;
+    }
+
+    return newTipCommit;
+  }
+
+  /**
+   * Gets all the comments which are presented at a commit. Note they include the comments put in by
+   * the previous commits.
+   */
+  @VisibleForTesting
+  public static Map<String, Comment> getPublishedComments(
+      ChangeNoteUtil noteUtil, Change.Id changeId, ObjectReader reader, NoteMap noteMap)
+      throws IOException, ConfigInvalidException {
+    return RevisionNoteMap.parse(noteUtil, changeId, reader, noteMap, PUBLISHED)
+        .revisionNotes
+        .values()
+        .stream()
+        .flatMap(n -> n.getComments().stream())
+        .collect(toMap(c -> c.key.uuid, c -> c));
+  }
+
+  /**
+   * Gets the comments put in by the current commit. The message of the target comment will be
+   * replaced by the new message.
+   *
+   * @param parMap the comment map of the parent commit.
+   * @param curMap the comment map of the current commit.
+   * @return The comments put in by the current commit.
+   */
+  private List<Comment> getPutInComments(Map<String, Comment> parMap, Map<String, Comment> curMap) {
+    List<Comment> comments = new ArrayList<>();
+    for (String key : curMap.keySet()) {
+      if (!parMap.containsKey(key)) {
+        Comment comment = curMap.get(key);
+        if (key.equals(uuid)) {
+          comment.message = newMessage;
+        }
+        comments.add(comment);
+      }
+    }
+    return comments;
+  }
+
+  /**
+   * Gets the comments deleted by the current commit.
+   *
+   * @param parMap the comment map of the parent commit.
+   * @param curMap the comment map of the current commit.
+   * @return The comments deleted by the current commit.
+   */
+  private List<Comment> getDeletedComments(
+      Map<String, Comment> parMap, Map<String, Comment> curMap) {
+    return parMap
+        .entrySet()
+        .stream()
+        .filter(c -> !curMap.containsKey(c.getKey()))
+        .map(c -> c.getValue())
+        .collect(toList());
+  }
+
+  /**
+   * Rewrites one commit.
+   *
+   * @param originalCommit the original commit to be rewritten.
+   * @param parentCommit the parent of the new commit.
+   * @param inserter the {@code ObjectInserter} for the rewrite process.
+   * @param reader the {@code ObjectReader} for the rewrite process.
+   * @param putInComments the comments put in by this commit.
+   * @param deletedComments the comments deleted by this commit.
+   * @return the {@code objectId} of the new commit.
+   * @throws IOException
+   * @throws ConfigInvalidException
+   */
+  private ObjectId rewriteCommit(
+      RevCommit originalCommit,
+      RevCommit parentCommit,
+      ObjectInserter inserter,
+      ObjectReader reader,
+      List<Comment> putInComments,
+      List<Comment> deletedComments)
+      throws IOException, ConfigInvalidException {
+    RevisionNoteMap<ChangeRevisionNote> revNotesMap =
+        RevisionNoteMap.parse(
+            noteUtil, changeId, reader, NoteMap.read(reader, parentCommit), PUBLISHED);
+    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(revNotesMap);
+
+    for (Comment c : putInComments) {
+      cache.get(new RevId(c.revId)).putComment(c);
+    }
+
+    for (Comment c : deletedComments) {
+      cache.get(new RevId(c.revId)).deleteComment(c.key);
+    }
+
+    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    for (Map.Entry<RevId, RevisionNoteBuilder> entry : builders.entrySet()) {
+      ObjectId objectId = ObjectId.fromString(entry.getKey().get());
+      byte[] data = entry.getValue().build(noteUtil, noteUtil.getWriteJson());
+      if (data.length == 0) {
+        revNotesMap.noteMap.remove(objectId);
+      } else {
+        revNotesMap.noteMap.set(objectId, inserter.insert(OBJ_BLOB, data));
+      }
+    }
+
+    CommitBuilder cb = new CommitBuilder();
+    cb.setParentId(parentCommit);
+    cb.setTreeId(revNotesMap.noteMap.writeTree(inserter));
+    cb.setMessage(originalCommit.getFullMessage());
+    cb.setCommitter(originalCommit.getCommitterIdent());
+    cb.setAuthor(originalCommit.getAuthorIdent());
+    cb.setEncoding(originalCommit.getEncoding());
+
+    return inserter.insert(cb);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
index ee28d29..347ba48 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/GwtormChangeBundleReader.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -30,20 +31,22 @@
   GwtormChangeBundleReader() {}
 
   @Override
+  @Nullable
   public ChangeBundle fromReviewDb(ReviewDb db, Change.Id id) throws OrmException {
-    db.changes().beginTransaction(id);
-    try {
-      List<PatchSetApproval> approvals = db.patchSetApprovals().byChange(id).toList();
-      return new ChangeBundle(
-          db.changes().get(id),
-          db.changeMessages().byChange(id),
-          db.patchSets().byChange(id),
-          approvals,
-          db.patchComments().byChange(id),
-          ReviewerSet.fromApprovals(approvals),
-          Source.REVIEW_DB);
-    } finally {
-      db.rollback();
+    Change reviewDbChange = db.changes().get(id);
+    if (reviewDbChange == null) {
+      return null;
     }
+
+    // TODO(dborowitz): Figure out how to do this more consistently, e.g. hand-written inner joins.
+    List<PatchSetApproval> approvals = db.patchSetApprovals().byChange(id).toList();
+    return new ChangeBundle(
+        reviewDbChange,
+        db.changeMessages().byChange(id),
+        db.patchSets().byChange(id),
+        approvals,
+        db.patchComments().byChange(id),
+        ReviewerSet.fromApprovals(approvals),
+        Source.REVIEW_DB);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/MutableNotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/MutableNotesMigration.java
new file mode 100644
index 0000000..7f4912b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/MutableNotesMigration.java
@@ -0,0 +1,104 @@
+// 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.notedb;
+
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.function.Function;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * {@link NotesMigration} with additional methods for altering the migration state at runtime.
+ *
+ * <p>Almost all callers care only about inspecting the migration state, and for safety should not
+ * have access to mutation methods, which must be used with extreme care. Those callers should
+ * inject {@link NotesMigration}.
+ *
+ * <p>Some callers, namely the NoteDb migration pipeline and tests, do need to alter the migration
+ * state at runtime, and those callers are expected to take the necessary precautions such as
+ * keeping the in-memory and on-disk config state in sync. Those callers use this class.
+ *
+ * <p>Mutations to the {@link MutableNotesMigration} are guaranteed to be instantly visible to all
+ * callers that use the non-mutable {@link NotesMigration}. The current implementation accomplishes
+ * this by always binding {@link NotesMigration} to {@link MutableNotesMigration} in Guice, so there
+ * is just one {@link NotesMigration} instance process-wide.
+ */
+@Singleton
+public class MutableNotesMigration extends NotesMigration {
+  public static MutableNotesMigration newDisabled() {
+    return new MutableNotesMigration(new Config());
+  }
+
+  public static MutableNotesMigration fromConfig(Config cfg) {
+    return new MutableNotesMigration(cfg);
+  }
+
+  @Inject
+  MutableNotesMigration(@GerritServerConfig Config cfg) {
+    super(Snapshot.create(cfg));
+  }
+
+  public MutableNotesMigration setReadChanges(boolean readChanges) {
+    return set(b -> b.setReadChanges(readChanges));
+  }
+
+  public MutableNotesMigration setWriteChanges(boolean writeChanges) {
+    return set(b -> b.setWriteChanges(writeChanges));
+  }
+
+  public MutableNotesMigration setReadChangeSequence(boolean readChangeSequence) {
+    return set(b -> b.setReadChangeSequence(readChangeSequence));
+  }
+
+  public MutableNotesMigration setChangePrimaryStorage(PrimaryStorage changePrimaryStorage) {
+    return set(b -> b.setChangePrimaryStorage(changePrimaryStorage));
+  }
+
+  public MutableNotesMigration setDisableChangeReviewDb(boolean disableChangeReviewDb) {
+    return set(b -> b.setDisableChangeReviewDb(disableChangeReviewDb));
+  }
+
+  public MutableNotesMigration setFailOnLoadForTest(boolean failOnLoadForTest) {
+    return set(b -> b.setFailOnLoadForTest(failOnLoadForTest));
+  }
+
+  /**
+   * Set the in-memory values returned by this instance to match the given state.
+   *
+   * <p>This method is only intended for use by {@link
+   * com.google.gerrit.server.notedb.rebuild.NoteDbMigrator}.
+   *
+   * <p>This <em>only</em> modifies the in-memory state; if this instance was initialized from a
+   * file-based config, the underlying storage is not updated. Callers are responsible for managing
+   * the underlying storage on their own.
+   */
+  public MutableNotesMigration setFrom(NotesMigrationState state) {
+    snapshot.set(state.snapshot());
+    return this;
+  }
+
+  /** @see #setFrom(NotesMigrationState) */
+  public MutableNotesMigration setFrom(NotesMigration other) {
+    snapshot.set(other.snapshot.get());
+    return this;
+  }
+
+  private MutableNotesMigration set(Function<Snapshot.Builder, Snapshot.Builder> f) {
+    snapshot.updateAndGet(s -> f.apply(s.toBuilder()).build());
+    return this;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
index fef7fdf..12967b8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbChangeState.java
@@ -78,11 +78,11 @@
       this.code = code;
     }
 
-    public static PrimaryStorage of(Change c) {
+    public static PrimaryStorage of(@Nullable Change c) {
       return of(NoteDbChangeState.parse(c));
     }
 
-    public static PrimaryStorage of(NoteDbChangeState s) {
+    public static PrimaryStorage of(@Nullable NoteDbChangeState s) {
       return s != null ? s.getPrimaryStorage() : REVIEW_DB;
     }
   }
@@ -150,12 +150,12 @@
     }
   }
 
-  public static NoteDbChangeState parse(Change c) {
+  public static NoteDbChangeState parse(@Nullable Change c) {
     return c != null ? parse(c.getId(), c.getNoteDbState()) : null;
   }
 
   @VisibleForTesting
-  public static NoteDbChangeState parse(Change.Id id, String str) {
+  public static NoteDbChangeState parse(Change.Id id, @Nullable String str) {
     if (Strings.isNullOrEmpty(str)) {
       // Return null rather than Optional as this is what goes in the field in
       // ReviewDb.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
index d249689..c76c39b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -17,6 +17,7 @@
 import com.google.common.cache.Cache;
 import com.google.common.cache.CacheBuilder;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Id;
 import com.google.gerrit.reviewdb.client.Project;
@@ -24,6 +25,7 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.Result;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilderImpl;
+import com.google.gerrit.server.notedb.rebuild.NotesMigrationStateListener;
 import com.google.inject.TypeLiteral;
 import com.google.inject.name.Names;
 import org.eclipse.jgit.lib.Config;
@@ -47,12 +49,15 @@
 
   @Override
   public void configure() {
-    factory(ChangeUpdate.Factory.class);
     factory(ChangeDraftUpdate.Factory.class);
+    factory(ChangeUpdate.Factory.class);
+    factory(DeleteCommentRewriter.Factory.class);
     factory(DraftCommentNotes.Factory.class);
-    factory(RobotCommentUpdate.Factory.class);
-    factory(RobotCommentNotes.Factory.class);
     factory(NoteDbUpdateManager.Factory.class);
+    factory(RobotCommentNotes.Factory.class);
+    factory(RobotCommentUpdate.Factory.class);
+    DynamicSet.setOf(binder(), NotesMigrationStateListener.class);
+
     if (!useTestBindings) {
       install(ChangeNotesCache.module());
       if (cfg.getBoolean("noteDb", null, "testRebuilderWrapper", false)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbRewriter.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbRewriter.java
new file mode 100644
index 0000000..3c7b0a3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbRewriter.java
@@ -0,0 +1,39 @@
+// 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.notedb;
+
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public interface NoteDbRewriter {
+
+  /** Gets the name of the target ref which will be rewritten. */
+  String getRefName();
+
+  /**
+   * Rewrites the commit history.
+   *
+   * @param revWalk a {@code RevWalk} instance.
+   * @param inserter a {@code ObjectInserter} instance.
+   * @param currTip the {@code ObjectId} of the ref's tip commit.
+   * @return the {@code ObjectId} of the ref's new tip commit.
+   */
+  ObjectId rewriteCommitHistory(RevWalk revWalk, ObjectInserter inserter, ObjectId currTip)
+      throws IOException, ConfigInvalidException, OrmException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
index 255998c..be24e28 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbTable.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.notedb;
 
-enum NoteDbTable {
+public enum NoteDbTable {
   ACCOUNTS,
   CHANGES;
 
-  String key() {
+  public String key() {
     return name().toLowerCase();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 59d7cbb..b9f5fe6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -24,10 +24,12 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.collect.HashBasedTable;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
 import com.google.common.collect.Table;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.metrics.Timer1;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
@@ -38,14 +40,15 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.InsertedObject;
-import com.google.gerrit.server.git.LockFailureException;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.update.ChainedReceiveCommands;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.server.update.RetryingRestModifyView;
 import com.google.gwtorm.server.OrmConcurrencyException;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
@@ -53,8 +56,8 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.BatchRefUpdate;
-import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.ObjectReader;
@@ -62,6 +65,7 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
 /**
@@ -76,6 +80,12 @@
 public class NoteDbUpdateManager implements AutoCloseable {
   public static final String CHANGES_READ_ONLY = "NoteDb changes are read-only";
 
+  private static final ImmutableList<String> PACKAGE_PREFIXES =
+      ImmutableList.of("com.google.gerrit.server.", "com.google.gerrit.");
+  private static final ImmutableSet<String> SERVLET_NAMES =
+      ImmutableSet.of(
+          "com.google.gerrit.httpd.restapi.RestApiServlet", RetryingRestModifyView.class.getName());
+
   public interface Factory {
     NoteDbUpdateManager create(Project.NameKey projectName);
   }
@@ -88,13 +98,13 @@
       ImmutableList<InsertedObject> changeObjects = ImmutableList.of();
       if (changeRepo != null) {
         changeCommands = changeRepo.getCommandsSnapshot();
-        changeObjects = changeRepo.tempIns.getInsertedObjects();
+        changeObjects = changeRepo.getInsertedObjects();
       }
       ImmutableList<ReceiveCommand> allUsersCommands = ImmutableList.of();
       ImmutableList<InsertedObject> allUsersObjects = ImmutableList.of();
       if (allUsersRepo != null) {
         allUsersCommands = allUsersRepo.getCommandsSnapshot();
-        allUsersObjects = allUsersRepo.tempIns.getInsertedObjects();
+        allUsersObjects = allUsersRepo.getInsertedObjects();
       }
       return new AutoValue_NoteDbUpdateManager_StagedResult(
           id, delta,
@@ -109,10 +119,32 @@
 
     public abstract ImmutableList<ReceiveCommand> changeCommands();
 
+    /**
+     * Objects inserted into the change repo for this change.
+     *
+     * <p>Includes all objects inserted for any change in this repo that may have been processed by
+     * the corresponding {@link NoteDbUpdateManager} instance, not just those objects that were
+     * inserted to handle this specific change's updates.
+     *
+     * @return inserted objects, or null if the corresponding {@link NoteDbUpdateManager} was
+     *     configured not to {@link NoteDbUpdateManager#setSaveObjects(boolean) save objects}.
+     */
+    @Nullable
     public abstract ImmutableList<InsertedObject> changeObjects();
 
     public abstract ImmutableList<ReceiveCommand> allUsersCommands();
 
+    /**
+     * Objects inserted into the All-Users repo for this change.
+     *
+     * <p>Includes all objects inserted into All-Users for any change that may have been processed
+     * by the corresponding {@link NoteDbUpdateManager} instance, not just those objects that were
+     * inserted to handle this specific change's updates.
+     *
+     * @return inserted objects, or null if the corresponding {@link NoteDbUpdateManager} was
+     *     configured not to {@link NoteDbUpdateManager#setSaveObjects(boolean) save objects}.
+     */
+    @Nullable
     public abstract ImmutableList<InsertedObject> allUsersObjects();
   }
 
@@ -134,17 +166,20 @@
     public final RevWalk rw;
     public final ChainedReceiveCommands cmds;
 
-    private final InMemoryInserter tempIns;
+    private final InMemoryInserter inMemIns;
+    private final ObjectInserter tempIns;
     @Nullable private final ObjectInserter finalIns;
 
     private final boolean close;
+    private final boolean saveObjects;
 
     private OpenRepo(
         Repository repo,
         RevWalk rw,
         @Nullable ObjectInserter ins,
         ChainedReceiveCommands cmds,
-        boolean close) {
+        boolean close,
+        boolean saveObjects) {
       ObjectReader reader = rw.getObjectReader();
       checkArgument(
           ins == null || reader.getCreatedFromInserter() == ins,
@@ -152,11 +187,21 @@
           ins,
           reader.getCreatedFromInserter());
       this.repo = checkNotNull(repo);
-      this.tempIns = new InMemoryInserter(rw.getObjectReader());
+
+      if (saveObjects) {
+        this.inMemIns = new InMemoryInserter(rw.getObjectReader());
+        this.tempIns = inMemIns;
+      } else {
+        checkArgument(ins != null);
+        this.inMemIns = null;
+        this.tempIns = ins;
+      }
+
       this.rw = new RevWalk(tempIns.newReader());
       this.finalIns = ins;
       this.cmds = checkNotNull(cmds);
       this.close = close;
+      this.saveObjects = saveObjects;
     }
 
     public Optional<ObjectId> getObjectId(String refName) throws IOException {
@@ -167,13 +212,25 @@
       return ImmutableList.copyOf(cmds.getCommands().values());
     }
 
+    @Nullable
+    ImmutableList<InsertedObject> getInsertedObjects() {
+      return saveObjects ? inMemIns.getInsertedObjects() : null;
+    }
+
     void flush() throws IOException {
+      flushToFinalInserter();
+      finalIns.flush();
+    }
+
+    void flushToFinalInserter() throws IOException {
+      if (!saveObjects) {
+        return;
+      }
       checkState(finalIns != null);
-      for (InsertedObject obj : tempIns.getInsertedObjects()) {
+      for (InsertedObject obj : inMemIns.getInsertedObjects()) {
         finalIns.insert(obj.type(), obj.data().toByteArray());
       }
-      finalIns.flush();
-      tempIns.clear();
+      inMemIns.clear();
     }
 
     @Override
@@ -198,16 +255,20 @@
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
   private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
   private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
+  private final ListMultimap<String, NoteDbRewriter> rewriters;
   private final Set<Change.Id> toDelete;
 
   private OpenRepo changeRepo;
   private OpenRepo allUsersRepo;
   private Map<Change.Id, StagedResult> staged;
   private boolean checkExpectedState = true;
+  private boolean saveObjects = true;
+  private boolean atomicRefUpdates = true;
   private String refLogMessage;
   private PersonIdent refLogIdent;
+  private PushCertificate pushCert;
 
-  @AssistedInject
+  @Inject
   NoteDbUpdateManager(
       @GerritPersonIdent Provider<PersonIdent> serverIdent,
       GitRepositoryManager repoManager,
@@ -224,6 +285,7 @@
     changeUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     draftUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
     robotCommentUpdates = MultimapBuilder.hashKeys().arrayListValues().build();
+    rewriters = MultimapBuilder.hashKeys().arrayListValues().build();
     toDelete = new HashSet<>();
   }
 
@@ -247,14 +309,14 @@
   public NoteDbUpdateManager setChangeRepo(
       Repository repo, RevWalk rw, @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
     checkState(changeRepo == null, "change repo already initialized");
-    changeRepo = new OpenRepo(repo, rw, ins, cmds, false);
+    changeRepo = new OpenRepo(repo, rw, ins, cmds, false, saveObjects);
     return this;
   }
 
   public NoteDbUpdateManager setAllUsersRepo(
       Repository repo, RevWalk rw, @Nullable ObjectInserter ins, ChainedReceiveCommands cmds) {
     checkState(allUsersRepo == null, "All-Users repo already initialized");
-    allUsersRepo = new OpenRepo(repo, rw, ins, cmds, false);
+    allUsersRepo = new OpenRepo(repo, rw, ins, cmds, false, saveObjects);
     return this;
   }
 
@@ -263,6 +325,37 @@
     return this;
   }
 
+  /**
+   * Set whether to save objects and make them available in {@link StagedResult}s.
+   *
+   * <p>If set, all objects inserted into all repos managed by this instance will be buffered in
+   * memory, and the {@link StagedResult}s will return non-null lists from {@link
+   * StagedResult#changeObjects()} and {@link StagedResult#allUsersObjects()}.
+   *
+   * <p>Not recommended if modifying a large number of changes with a single manager.
+   *
+   * @param saveObjects whether to save objects; defaults to true.
+   * @return this
+   */
+  public NoteDbUpdateManager setSaveObjects(boolean saveObjects) {
+    this.saveObjects = saveObjects;
+    return this;
+  }
+
+  /**
+   * Set whether to use atomic ref updates.
+   *
+   * <p>Can be set to false when the change updates represented by this manager aren't logically
+   * related, e.g. when the updater is only used to group objects together with a single inserter.
+   *
+   * @param atomicRefUpdates whether to use atomic ref updates; defaults to true.
+   * @return this
+   */
+  public NoteDbUpdateManager setAtomicRefUpdates(boolean atomicRefUpdates) {
+    this.atomicRefUpdates = atomicRefUpdates;
+    return this;
+  }
+
   public NoteDbUpdateManager setRefLogMessage(String message) {
     this.refLogMessage = message;
     return this;
@@ -273,6 +366,25 @@
     return this;
   }
 
+  /**
+   * Set a push certificate for the push that originally triggered this NoteDb update.
+   *
+   * <p>The pusher will not necessarily have specified any of the NoteDb refs explicitly, such as
+   * when processing a push to {@code refs/for/master}. That's fine; this is just passed to the
+   * underlying {@link BatchRefUpdate}, and the implementation decides what to do with it.
+   *
+   * <p>The cert should be associated with the main repo. There is currently no way of associating a
+   * push cert with the {@code All-Users} repo, since it is not currently possible to update draft
+   * changes via push.
+   *
+   * @param pushCert push certificate; may be null.
+   * @return this
+   */
+  public NoteDbUpdateManager setPushCertificate(PushCertificate pushCert) {
+    this.pushCert = pushCert;
+    return this;
+  }
+
   public OpenRepo getChangeRepo() throws IOException {
     initChangeRepo();
     return changeRepo;
@@ -300,7 +412,7 @@
     ObjectInserter ins = repo.newObjectInserter(); // Closed by OpenRepo#close.
     ObjectReader reader = ins.newReader(); // Not closed by OpenRepo#close.
     try (RevWalk rw = new RevWalk(reader)) { // Doesn't escape OpenRepo constructor.
-      return new OpenRepo(repo, rw, ins, new ChainedReceiveCommands(repo), true) {
+      return new OpenRepo(repo, rw, ins, new ChainedReceiveCommands(repo), true, saveObjects) {
         @Override
         public void close() {
           reader.close();
@@ -317,7 +429,14 @@
     return changeUpdates.isEmpty()
         && draftUpdates.isEmpty()
         && robotCommentUpdates.isEmpty()
-        && toDelete.isEmpty();
+        && rewriters.isEmpty()
+        && toDelete.isEmpty()
+        && !hasCommands(changeRepo)
+        && !hasCommands(allUsersRepo);
+  }
+
+  private static boolean hasCommands(@Nullable OpenRepo or) {
+    return or != null && !or.cmds.isEmpty();
   }
 
   /**
@@ -344,6 +463,10 @@
     if (rcu != null) {
       robotCommentUpdates.put(rcu.getRefName(), rcu);
     }
+    DeleteCommentRewriter deleteCommentRewriter = update.getDeleteCommentRewriter();
+    if (deleteCommentRewriter != null) {
+      rewriters.put(deleteCommentRewriter.getRefName(), deleteCommentRewriter);
+    }
   }
 
   public void add(ChangeDraftUpdate draftUpdate) {
@@ -385,6 +508,10 @@
       Set<Change.Id> changeIds = new HashSet<>();
       for (ReceiveCommand cmd : changeRepo.getCommandsSnapshot()) {
         Change.Id changeId = Change.Id.fromRef(cmd.getRefName());
+        if (changeId == null || !cmd.getRefName().equals(RefNames.changeMetaRef(changeId))) {
+          // Not a meta ref update, likely due to a repo update along with the change meta update.
+          continue;
+        }
         changeIds.add(changeId);
         Optional<ObjectId> metaId = Optional.of(cmd.getNewId());
         staged.put(
@@ -450,13 +577,19 @@
     }
   }
 
-  public void execute() throws OrmException, IOException {
+  @Nullable
+  public BatchRefUpdate execute() throws OrmException, IOException {
+    return execute(false);
+  }
+
+  @Nullable
+  public BatchRefUpdate execute(boolean dryrun) throws OrmException, IOException {
     // Check before even inspecting the list, as this is a programmer error.
     if (migration.failChangeWrites()) {
       throw new OrmException(CHANGES_READ_ONLY);
     }
     if (isEmpty()) {
-      return;
+      return null;
     }
     try (Timer1.Context timer = metrics.updateLatency.start(CHANGES)) {
       stage();
@@ -468,36 +601,82 @@
       // we may have stale draft comments. Doing it in this order allows stale
       // comments to be filtered out by ChangeNotes, reflecting the fact that
       // comments can only go from DRAFT to PUBLISHED, not vice versa.
-      execute(changeRepo);
-      execute(allUsersRepo);
+      BatchRefUpdate result = execute(changeRepo, dryrun, pushCert);
+      execute(allUsersRepo, dryrun, null);
+      return result;
     } finally {
       close();
     }
   }
 
-  private void execute(OpenRepo or) throws IOException {
+  private BatchRefUpdate execute(OpenRepo or, boolean dryrun, @Nullable PushCertificate pushCert)
+      throws IOException {
     if (or == null || or.cmds.isEmpty()) {
-      return;
+      return null;
     }
-    or.flush();
+    if (!dryrun) {
+      or.flush();
+    } else {
+      // OpenRepo buffers objects separately; caller may assume that objects are available in the
+      // inserter it previously passed via setChangeRepo.
+      or.flushToFinalInserter();
+    }
+
     BatchRefUpdate bru = or.repo.getRefDatabase().newBatchUpdate();
-    bru.setRefLogMessage(firstNonNull(refLogMessage, "Update NoteDb refs"), false);
+    bru.setPushCertificate(pushCert);
+    if (refLogMessage != null) {
+      bru.setRefLogMessage(refLogMessage, false);
+    } else {
+      bru.setRefLogMessage(firstNonNull(guessRestApiHandler(), "Update NoteDb refs"), false);
+    }
     bru.setRefLogIdent(refLogIdent != null ? refLogIdent : serverIdent.get());
+    bru.setAtomic(atomicRefUpdates);
     or.cmds.addTo(bru);
     bru.setAllowNonFastForwards(true);
-    bru.execute(or.rw, NullProgressMonitor.INSTANCE);
 
-    boolean lockFailure = false;
-    for (ReceiveCommand cmd : bru.getCommands()) {
-      if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
-        lockFailure = true;
-      } else if (cmd.getResult() != ReceiveCommand.Result.OK) {
-        throw new IOException("Update failed: " + bru);
+    if (!dryrun) {
+      RefUpdateUtil.executeChecked(bru, or.rw);
+    }
+    return bru;
+  }
+
+  private static String guessRestApiHandler() {
+    StackTraceElement[] trace = Thread.currentThread().getStackTrace();
+    int i = findRestApiServlet(trace);
+    if (i < 0) {
+      return null;
+    }
+    try {
+      for (i--; i >= 0; i--) {
+        String cn = trace[i].getClassName();
+        Class<?> cls = Class.forName(cn);
+        if (RestModifyView.class.isAssignableFrom(cls) && cls != RetryingRestModifyView.class) {
+          return viewName(cn);
+        }
+      }
+      return null;
+    } catch (ClassNotFoundException e) {
+      return null;
+    }
+  }
+
+  private static String viewName(String cn) {
+    String impl = cn.replace('$', '.');
+    for (String p : PACKAGE_PREFIXES) {
+      if (impl.startsWith(p)) {
+        return impl.substring(p.length());
       }
     }
-    if (lockFailure) {
-      throw new LockFailureException("Update failed with one or more lock failures: " + bru);
+    return impl;
+  }
+
+  private static int findRestApiServlet(StackTraceElement[] trace) {
+    for (int i = 0; i < trace.length; i++) {
+      if (SERVLET_NAMES.contains(trace[i].getClassName())) {
+        return i;
+      }
     }
+    return -1;
   }
 
   private void addCommands() throws OrmException, IOException {
@@ -515,6 +694,21 @@
     if (!robotCommentUpdates.isEmpty()) {
       addUpdates(robotCommentUpdates, changeRepo);
     }
+    if (!rewriters.isEmpty()) {
+      Optional<String> conflictKey =
+          rewriters
+              .keySet()
+              .stream()
+              .filter(k -> (draftUpdates.containsKey(k) || robotCommentUpdates.containsKey(k)))
+              .findAny();
+      if (conflictKey.isPresent()) {
+        throw new IllegalArgumentException(
+            String.format(
+                "cannot update and rewrite ref %s in one BatchUpdate", conflictKey.get()));
+      }
+      addRewrites(rewriters, changeRepo);
+    }
+
     for (Change.Id id : toDelete) {
       doDelete(id);
     }
@@ -623,6 +817,9 @@
 
       ObjectId curr = old;
       for (U u : updates) {
+        if (u.isRootOnly() && !old.equals(ObjectId.zeroId())) {
+          throw new OrmException("Given ChangeUpdate is only allowed on initial commit");
+        }
         ObjectId next = u.apply(or.rw, or.tempIns, curr);
         if (next == null) {
           continue;
@@ -635,6 +832,35 @@
     }
   }
 
+  private static void addRewrites(ListMultimap<String, NoteDbRewriter> rewriters, OpenRepo openRepo)
+      throws OrmException, IOException {
+    for (Map.Entry<String, Collection<NoteDbRewriter>> entry : rewriters.asMap().entrySet()) {
+      String refName = entry.getKey();
+      ObjectId oldTip = openRepo.cmds.get(refName).orElse(ObjectId.zeroId());
+
+      if (oldTip.equals(ObjectId.zeroId())) {
+        throw new OrmException(String.format("Ref %s is empty", refName));
+      }
+
+      ObjectId currTip = oldTip;
+      try {
+        for (NoteDbRewriter noteDbRewriter : entry.getValue()) {
+          ObjectId nextTip =
+              noteDbRewriter.rewriteCommitHistory(openRepo.rw, openRepo.tempIns, currTip);
+          if (nextTip != null) {
+            currTip = nextTip;
+          }
+        }
+      } catch (ConfigInvalidException e) {
+        throw new OrmException("Cannot rewrite commit history", e);
+      }
+
+      if (!oldTip.equals(currTip)) {
+        openRepo.cmds.add(new ReceiveCommand(oldTip, currTip, refName));
+      }
+    }
+  }
+
   private static <U extends AbstractChangeUpdate> boolean allowWrite(
       Collection<U> updates, ObjectId old) {
     if (!old.equals(ObjectId.zeroId())) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
index c708bfe..e560ec8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigration.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// 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.
@@ -14,26 +14,132 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.server.notedb.NoteDbTable.CHANGES;
+
+import com.google.auto.value.AutoValue;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.inject.AbstractModule;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.lib.Config;
 
 /**
- * Holds the current state of the NoteDb migration.
+ * Current low-level settings of the NoteDb migration for changes.
  *
- * <p>The migration will proceed one root entity type at a time. A <em>root entity</em> is an entity
- * stored in ReviewDb whose key's {@code getParentKey()} method returns null. For an example of the
- * entity hierarchy rooted at Change, see the diagram in {@code
- * com.google.gerrit.reviewdb.client.Change}.
+ * <p>This class only describes the migration state of the {@link
+ * com.google.gerrit.reviewdb.client.Change Change} entity group, since it is possible for a given
+ * site to be in different states of the Change NoteDb migration process while staying at the same
+ * ReviewDb schema version. It does <em>not</em> describe the migration state of non-Change tables;
+ * those are automatically migrated using the ReviewDb schema migration process, so the NoteDb
+ * migration state at a given ReviewDb schema cannot vary.
  *
- * <p>During a transitional period, each root entity group from ReviewDb may be either <em>written
- * to</em> or <em>both written to and read from</em> NoteDb.
+ * <p>In many places, core Gerrit code should not directly care about the NoteDb migration state,
+ * and should prefer high-level APIs like {@link com.google.gerrit.server.ApprovalsUtil
+ * ApprovalsUtil} that don't require callers to inspect the migration state. The
+ * <em>implementation</em> of those utilities does care about the state, and should query the {@code
+ * NotesMigration} for the properties of the migration, for example, {@link #changePrimaryStorage()
+ * where new changes should be stored}.
+ *
+ * <p>Core Gerrit code is mostly interested in one facet of the migration at a time (reading or
+ * writing, say), but not all combinations of return values are supported or even make sense.
  *
  * <p>This class controls the state of the migration according to options in {@code gerrit.config}.
  * In general, any changes to these options should only be made by adventurous administrators, who
  * know what they're doing, on non-production data, for the purposes of testing the NoteDb
- * implementation. Changing options quite likely requires re-running {@code RebuildNoteDb}. For
+ * implementation. Changing options quite likely requires re-running {@code MigrateToNoteDb}. For
  * these reasons, the options remain undocumented.
+ *
+ * <p><strong>Note:</strong> Callers should not assume the values returned by {@code
+ * NotesMigration}'s methods will not change in a running server.
  */
 public abstract class NotesMigration {
+  public static final String SECTION_NOTE_DB = "noteDb";
+
+  private static final String DISABLE_REVIEW_DB = "disableReviewDb";
+  private static final String PRIMARY_STORAGE = "primaryStorage";
+  private static final String READ = "read";
+  private static final String SEQUENCE = "sequence";
+  private static final String WRITE = "write";
+
+  public static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      bind(MutableNotesMigration.class);
+      bind(NotesMigration.class).to(MutableNotesMigration.class);
+    }
+  }
+
+  @AutoValue
+  abstract static class Snapshot {
+    static Builder builder() {
+      // Default values are defined as what we would read from an empty config.
+      return create(new Config()).toBuilder();
+    }
+
+    static Snapshot create(Config cfg) {
+      return new AutoValue_NotesMigration_Snapshot.Builder()
+          .setWriteChanges(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, false))
+          .setReadChanges(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, false))
+          .setReadChangeSequence(cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, false))
+          .setChangePrimaryStorage(
+              cfg.getEnum(
+                  SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, PrimaryStorage.REVIEW_DB))
+          .setDisableChangeReviewDb(
+              cfg.getBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, false))
+          .setFailOnLoadForTest(false) // Only set in tests, can't be set via config.
+          .build();
+    }
+
+    abstract boolean writeChanges();
+
+    abstract boolean readChanges();
+
+    abstract boolean readChangeSequence();
+
+    abstract PrimaryStorage changePrimaryStorage();
+
+    abstract boolean disableChangeReviewDb();
+
+    abstract boolean failOnLoadForTest();
+
+    abstract Builder toBuilder();
+
+    void setConfigValues(Config cfg) {
+      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), WRITE, writeChanges());
+      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), READ, readChanges());
+      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), SEQUENCE, readChangeSequence());
+      cfg.setEnum(SECTION_NOTE_DB, CHANGES.key(), PRIMARY_STORAGE, changePrimaryStorage());
+      cfg.setBoolean(SECTION_NOTE_DB, CHANGES.key(), DISABLE_REVIEW_DB, disableChangeReviewDb());
+    }
+
+    @AutoValue.Builder
+    abstract static class Builder {
+      abstract Builder setWriteChanges(boolean writeChanges);
+
+      abstract Builder setReadChanges(boolean readChanges);
+
+      abstract Builder setReadChangeSequence(boolean readChangeSequence);
+
+      abstract Builder setChangePrimaryStorage(PrimaryStorage changePrimaryStorage);
+
+      abstract Builder setDisableChangeReviewDb(boolean disableChangeReviewDb);
+
+      abstract Builder setFailOnLoadForTest(boolean failOnLoadForTest);
+
+      abstract Snapshot autoBuild();
+
+      Snapshot build() {
+        Snapshot s = autoBuild();
+        checkArgument(
+            !(s.disableChangeReviewDb() && s.changePrimaryStorage() != PrimaryStorage.NOTE_DB),
+            "cannot disable ReviewDb for changes if default change primary storage is ReviewDb");
+        return s;
+      }
+    }
+  }
+
+  protected final AtomicReference<Snapshot> snapshot;
+
   /**
    * Read changes from NoteDb.
    *
@@ -44,11 +150,16 @@
    * <p>If true and {@code writeChanges() = false}, changes can still be read from NoteDb, but any
    * attempts to write will generate an error.
    */
-  public abstract boolean readChanges();
+  public final boolean readChanges() {
+    return snapshot.get().readChanges();
+  }
 
   /**
    * Write changes to NoteDb.
    *
+   * <p>This method is awkwardly named because you should be using either {@link
+   * #commitChangeWrites()} or {@link #failChangeWrites()} instead.
+   *
    * <p>Updates to change data are written to NoteDb refs, but ReviewDb is still the source of
    * truth. Change data will not be written unless the NoteDb refs are already up to date, and the
    * write path will attempt to rebuild the change if not.
@@ -57,7 +168,9 @@
    * readChanges() = false}, writes to NoteDb are simply ignored; if {@code true}, any attempts to
    * write will generate an error.
    */
-  protected abstract boolean writeChanges();
+  public final boolean rawWriteChangesSetting() {
+    return snapshot.get().writeChanges();
+  }
 
   /**
    * Read sequential change ID numbers from NoteDb.
@@ -65,10 +178,14 @@
    * <p>If true, change IDs are read from {@code refs/sequences/changes} in All-Projects. If false,
    * change IDs are read from ReviewDb's native sequences.
    */
-  public abstract boolean readChangeSequence();
+  public final boolean readChangeSequence() {
+    return snapshot.get().readChangeSequence();
+  }
 
   /** @return default primary storage for new changes. */
-  public abstract PrimaryStorage changePrimaryStorage();
+  public final PrimaryStorage changePrimaryStorage() {
+    return snapshot.get().changePrimaryStorage();
+  }
 
   /**
    * Disable ReviewDb access for changes.
@@ -77,18 +194,20 @@
    * results; updates do nothing, as does opening, committing, or rolling back a transaction on the
    * Changes table.
    */
-  public abstract boolean disableChangeReviewDb();
+  public final boolean disableChangeReviewDb() {
+    return snapshot.get().disableChangeReviewDb();
+  }
 
   /**
    * Whether to fail when reading any data from NoteDb.
    *
    * <p>Used in conjunction with {@link #readChanges()} for tests.
    */
-  public boolean failOnLoad() {
-    return false;
+  public boolean failOnLoadForTest() {
+    return snapshot.get().failOnLoadForTest();
   }
 
-  public boolean commitChangeWrites() {
+  public final boolean commitChangeWrites() {
     // It may seem odd that readChanges() without writeChanges() means we should
     // attempt to commit writes. However, this method is used by callers to know
     // whether or not they should short-circuit and skip attempting to read or
@@ -99,14 +218,33 @@
     // same codepath. This specific condition is used by the auto-rebuilding
     // path to rebuild a change and stage the results, but not commit them due
     // to failChangeWrites().
-    return writeChanges() || readChanges();
+    return rawWriteChangesSetting() || readChanges();
   }
 
-  public boolean failChangeWrites() {
-    return !writeChanges() && readChanges();
+  public final boolean failChangeWrites() {
+    return !rawWriteChangesSetting() && readChanges();
   }
 
-  public boolean enabled() {
-    return writeChanges() || readChanges();
+  public final void setConfigValues(Config cfg) {
+    snapshot.get().setConfigValues(cfg);
+  }
+
+  @Override
+  public final boolean equals(Object o) {
+    return o instanceof NotesMigration
+        && snapshot.get().equals(((NotesMigration) o).snapshot.get());
+  }
+
+  @Override
+  public final int hashCode() {
+    return snapshot.get().hashCode();
+  }
+
+  protected NotesMigration(Snapshot snapshot) {
+    this.snapshot = new AtomicReference<>(snapshot);
+  }
+
+  final Snapshot snapshot() {
+    return snapshot.get();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigrationState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigrationState.java
new file mode 100644
index 0000000..c682aed
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NotesMigrationState.java
@@ -0,0 +1,92 @@
+// 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.notedb;
+
+import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+import com.google.gerrit.server.notedb.NotesMigration.Snapshot;
+import java.util.Optional;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Possible high-level states of the NoteDb migration for changes.
+ *
+ * <p>This class describes the series of states required to migrate a site from ReviewDb-only to
+ * NoteDb-only. This process has several steps, and covers only a small subset of the theoretically
+ * possible combinations of {@link NotesMigration} return values.
+ *
+ * <p>These states are ordered: a one-way migration from ReviewDb to NoteDb will pass through states
+ * in the order in which they are defined.
+ */
+public enum NotesMigrationState {
+  REVIEW_DB(false, false, false, PrimaryStorage.REVIEW_DB, false),
+
+  WRITE(false, true, false, PrimaryStorage.REVIEW_DB, false),
+
+  READ_WRITE_NO_SEQUENCE(true, true, false, PrimaryStorage.REVIEW_DB, false),
+
+  READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY(true, true, true, PrimaryStorage.REVIEW_DB, false),
+
+  READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY(true, true, true, PrimaryStorage.NOTE_DB, false),
+
+  NOTE_DB(true, true, true, PrimaryStorage.NOTE_DB, true);
+
+  public static final NotesMigrationState FINAL = NOTE_DB;
+
+  public static Optional<NotesMigrationState> forConfig(Config cfg) {
+    return forSnapshot(Snapshot.create(cfg));
+  }
+
+  public static Optional<NotesMigrationState> forNotesMigration(NotesMigration migration) {
+    return forSnapshot(migration.snapshot());
+  }
+
+  private static Optional<NotesMigrationState> forSnapshot(Snapshot s) {
+    return Stream.of(values()).filter(v -> v.snapshot.equals(s)).findFirst();
+  }
+
+  private final Snapshot snapshot;
+
+  NotesMigrationState(
+      // Arguments match abstract methods in NotesMigration.
+      boolean readChanges,
+      boolean rawWriteChangesSetting,
+      boolean readChangeSequence,
+      PrimaryStorage changePrimaryStorage,
+      boolean disableChangeReviewDb) {
+    this.snapshot =
+        Snapshot.builder()
+            .setReadChanges(readChanges)
+            .setWriteChanges(rawWriteChangesSetting)
+            .setReadChangeSequence(readChangeSequence)
+            .setChangePrimaryStorage(changePrimaryStorage)
+            .setDisableChangeReviewDb(disableChangeReviewDb)
+            .build();
+  }
+
+  public void setConfigValues(Config cfg) {
+    snapshot.setConfigValues(cfg);
+  }
+
+  public String toText() {
+    Config cfg = new Config();
+    setConfigValues(cfg);
+    return cfg.toText();
+  }
+
+  Snapshot snapshot() {
+    return snapshot;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java
index 32be9c5..d668801 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PatchSetState.java
@@ -18,9 +18,6 @@
   /** Published and visible to anyone who can see the change; the default. */
   PUBLISHED,
 
-  /** Draft patch set, only visible to certain users. */
-  DRAFT,
-
   /**
    * Deleted patch set.
    *
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
index 3f0db77..4917e65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/PrimaryStorageMigrator.java
@@ -46,12 +46,12 @@
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
 import com.google.gerrit.server.notedb.NoteDbChangeState.RefState;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
-import com.google.gerrit.server.project.ChangeControl;
 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.server.update.BatchUpdateOp;
 import com.google.gerrit.server.update.ChangeContext;
+import com.google.gerrit.server.update.RetryHelper;
 import com.google.gerrit.server.update.UpdateException;
 import com.google.gwtorm.server.AtomicUpdate;
 import com.google.gwtorm.server.OrmException;
@@ -81,15 +81,27 @@
 public class PrimaryStorageMigrator {
   private static final Logger log = LoggerFactory.getLogger(PrimaryStorageMigrator.class);
 
+  /**
+   * Exception thrown during migration if the change has no {@code noteDbState} field at the
+   * beginning of the migration.
+   */
+  public static class NoNoteDbStateException extends RuntimeException {
+    private static final long serialVersionUID = 1L;
+
+    private NoNoteDbStateException(Change.Id id) {
+      super("change " + id + " has no note_db_state; rebuild it first");
+    }
+  }
+
   private final AllUsersName allUsers;
-  private final BatchUpdate.Factory batchUpdateFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
+  private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeRebuilder rebuilder;
   private final ChangeUpdate.Factory updateFactory;
   private final GitRepositoryManager repoManager;
   private final InternalUser.Factory internalUserFactory;
   private final Provider<InternalChangeQuery> queryProvider;
   private final Provider<ReviewDb> db;
+  private final RetryHelper retryHelper;
 
   private final long skewMs;
   private final long timeoutMs;
@@ -102,11 +114,11 @@
       GitRepositoryManager repoManager,
       AllUsersName allUsers,
       ChangeRebuilder rebuilder,
-      ChangeControl.GenericFactory changeControlFactory,
+      ChangeNotes.Factory changeNotesFactory,
       Provider<InternalChangeQuery> queryProvider,
       ChangeUpdate.Factory updateFactory,
       InternalUser.Factory internalUserFactory,
-      BatchUpdate.Factory batchUpdateFactory) {
+      RetryHelper retryHelper) {
     this(
         cfg,
         db,
@@ -114,11 +126,11 @@
         allUsers,
         rebuilder,
         null,
-        changeControlFactory,
+        changeNotesFactory,
         queryProvider,
         updateFactory,
         internalUserFactory,
-        batchUpdateFactory);
+        retryHelper);
   }
 
   @VisibleForTesting
@@ -129,21 +141,21 @@
       AllUsersName allUsers,
       ChangeRebuilder rebuilder,
       @Nullable Retryer<NoteDbChangeState> testEnsureRebuiltRetryer,
-      ChangeControl.GenericFactory changeControlFactory,
+      ChangeNotes.Factory changeNotesFactory,
       Provider<InternalChangeQuery> queryProvider,
       ChangeUpdate.Factory updateFactory,
       InternalUser.Factory internalUserFactory,
-      BatchUpdate.Factory batchUpdateFactory) {
+      RetryHelper retryHelper) {
     this.db = db;
     this.repoManager = repoManager;
     this.allUsers = allUsers;
     this.rebuilder = rebuilder;
     this.testEnsureRebuiltRetryer = testEnsureRebuiltRetryer;
-    this.changeControlFactory = changeControlFactory;
+    this.changeNotesFactory = changeNotesFactory;
     this.queryProvider = queryProvider;
     this.updateFactory = updateFactory;
     this.internalUserFactory = internalUserFactory;
-    this.batchUpdateFactory = batchUpdateFactory;
+    this.retryHelper = retryHelper;
     skewMs = NoteDbChangeState.getReadOnlySkew(cfg);
 
     String s = "notedb";
@@ -266,7 +278,7 @@
     // the primary storage to NoteDb.
 
     setPrimaryStorageNoteDb(id, rebuiltState);
-    log.info("Migrated change {} to NoteDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
+    log.debug("Migrated change {} to NoteDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
   }
 
   private Change setReadOnlyInReviewDb(Change.Id id) throws OrmException {
@@ -281,9 +293,11 @@
                     NoteDbChangeState state = NoteDbChangeState.parse(change);
                     if (state == null) {
                       // Could rebuild the change here, but that's more complexity, and this
-                      // really shouldn't happen.
-                      throw new OrmRuntimeException(
-                          "change " + id + " has no note_db_state; rebuild it first");
+                      // normally shouldn't happen.
+                      //
+                      // Known cases where this happens are described in and handled by
+                      // NoteDbMigrator#canSkipPrimaryStorageMigration.
+                      throw new NoNoteDbStateException(id);
                     }
                     // If the change is already read-only, then the lease is held by another
                     // (likely failed) migrator thread. Fail early, as we can't take over
@@ -400,7 +414,7 @@
     rebuilder.rebuildReviewDb(db(), project, id);
     setPrimaryStorageReviewDb(id, newMetaId);
     releaseReadOnlyLeaseInNoteDb(project, id);
-    log.info("Migrated change {} to ReviewDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
+    log.debug("Migrated change {} to ReviewDb primary in {}ms", id, sw.elapsed(MILLISECONDS));
   }
 
   private ObjectId setReadOnlyInNoteDb(Project.NameKey project, Change.Id id)
@@ -409,7 +423,7 @@
     Timestamp until = new Timestamp(now.getTime() + timeoutMs);
     ChangeUpdate update =
         updateFactory.create(
-            changeControlFactory.controlFor(db.get(), project, id, internalUserFactory.create()));
+            changeNotesFactory.createChecked(db.get(), project, id), internalUserFactory.create());
     update.setReadOnlyUntil(until);
     return update.commit();
   }
@@ -451,19 +465,27 @@
   private void releaseReadOnlyLeaseInNoteDb(Project.NameKey project, Change.Id id)
       throws OrmException {
     // Use a BatchUpdate since ReviewDb is primary at this point, so it needs to reflect the update.
-    try (BatchUpdate bu =
-        batchUpdateFactory.create(
-            db.get(), project, internalUserFactory.create(), TimeUtil.nowTs())) {
-      bu.addOp(
-          id,
-          new BatchUpdateOp() {
-            @Override
-            public boolean updateChange(ChangeContext ctx) {
-              ctx.getUpdate(ctx.getChange().currentPatchSetId()).setReadOnlyUntil(new Timestamp(0));
-              return true;
+    // (In practice retrying won't happen, since we aren't using fused updates at this point.)
+    try {
+      retryHelper.execute(
+          updateFactory -> {
+            try (BatchUpdate bu =
+                updateFactory.create(
+                    db.get(), project, internalUserFactory.create(), TimeUtil.nowTs())) {
+              bu.addOp(
+                  id,
+                  new BatchUpdateOp() {
+                    @Override
+                    public boolean updateChange(ChangeContext ctx) {
+                      ctx.getUpdate(ctx.getChange().currentPatchSetId())
+                          .setReadOnlyUntil(new Timestamp(0));
+                      return true;
+                    }
+                  });
+              bu.execute();
+              return null;
             }
           });
-      bu.execute();
     } catch (RestApiException | UpdateException e) {
       throw new OrmException(e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
index 0b097d3..11266f9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RepoSequence.java
@@ -36,6 +36,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
@@ -67,6 +68,7 @@
  * numbers.
  */
 public class RepoSequence {
+  @FunctionalInterface
   public interface Seed {
     int get() throws OrmException;
   }
@@ -85,9 +87,11 @@
   private static final Retryer<RefUpdate.Result> RETRYER = retryerBuilder().build();
 
   private final GitRepositoryManager repoManager;
+  private final GitReferenceUpdated gitRefUpdated;
   private final Project.NameKey projectName;
   private final String refName;
   private final Seed seed;
+  private final int floor;
   private final int batchSize;
   private final Runnable afterReadRef;
   private final Retryer<RefUpdate.Result> retryer;
@@ -102,23 +106,68 @@
 
   public RepoSequence(
       GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
       Project.NameKey projectName,
       String name,
       Seed seed,
       int batchSize) {
-    this(repoManager, projectName, name, seed, batchSize, Runnables.doNothing(), RETRYER);
+    this(
+        repoManager,
+        gitRefUpdated,
+        projectName,
+        name,
+        seed,
+        batchSize,
+        Runnables.doNothing(),
+        RETRYER,
+        0);
+  }
+
+  public RepoSequence(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      Project.NameKey projectName,
+      String name,
+      Seed seed,
+      int batchSize,
+      int floor) {
+    this(
+        repoManager,
+        gitRefUpdated,
+        projectName,
+        name,
+        seed,
+        batchSize,
+        Runnables.doNothing(),
+        RETRYER,
+        floor);
   }
 
   @VisibleForTesting
   RepoSequence(
       GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
       Project.NameKey projectName,
       String name,
       Seed seed,
       int batchSize,
       Runnable afterReadRef,
       Retryer<RefUpdate.Result> retryer) {
+    this(repoManager, gitRefUpdated, projectName, name, seed, batchSize, afterReadRef, retryer, 0);
+  }
+
+  RepoSequence(
+      GitRepositoryManager repoManager,
+      GitReferenceUpdated gitRefUpdated,
+      Project.NameKey projectName,
+      String name,
+      Seed seed,
+      int batchSize,
+      Runnable afterReadRef,
+      Retryer<RefUpdate.Result> retryer,
+      int floor) {
     this.repoManager = checkNotNull(repoManager, "repoManager");
+    this.gitRefUpdated = checkNotNull(gitRefUpdated, "gitRefUpdated");
     this.projectName = checkNotNull(projectName, "projectName");
 
     checkArgument(
@@ -130,6 +179,7 @@
     this.refName = RefNames.REFS_SEQUENCES + name;
 
     this.seed = checkNotNull(seed, "seed");
+    this.floor = floor;
 
     checkArgument(batchSize > 0, "expected batchSize > 0, got: %s", batchSize);
     this.batchSize = batchSize;
@@ -212,11 +262,15 @@
   }
 
   private void checkResult(RefUpdate.Result result) throws OrmException {
-    if (result != RefUpdate.Result.NEW && result != RefUpdate.Result.FORCED) {
+    if (!refUpdated(result)) {
       throw new OrmException("failed to update " + refName + ": " + result);
     }
   }
 
+  private boolean refUpdated(RefUpdate.Result result) {
+    return result == RefUpdate.Result.NEW || result == RefUpdate.Result.FORCED;
+  }
+
   private class TryAcquire implements Callable<RefUpdate.Result> {
     private final Repository repo;
     private final RevWalk rw;
@@ -233,15 +287,17 @@
     @Override
     public RefUpdate.Result call() throws Exception {
       Ref ref = repo.exactRef(refName);
+      int nextCandidate;
       afterReadRef.run();
       ObjectId oldId;
       if (ref == null) {
         oldId = ObjectId.zeroId();
-        next = seed.get();
+        nextCandidate = seed.get();
       } else {
         oldId = ref.getObjectId();
-        next = parse(oldId);
+        nextCandidate = parse(oldId);
       }
+      next = Math.max(floor, nextCandidate);
       return store(repo, rw, oldId, next + count);
     }
 
@@ -274,7 +330,11 @@
     }
     ru.setNewObjectId(newId);
     ru.setForceUpdate(true); // Required for non-commitish updates.
-    return ru.update(rw);
+    RefUpdate.Result result = ru.update(rw);
+    if (refUpdated(result)) {
+      gitRefUpdated.fire(projectName, ru, null);
+    }
+    return result;
   }
 
   public static ReceiveCommand storeNew(ObjectInserter ins, String name, int val)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
index f250646..fad9832 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ReviewerStateInternal.java
@@ -29,13 +29,17 @@
   /** The user was previously a reviewer on the change, but was removed. */
   REMOVED(new FooterKey("Removed"), ReviewerState.REMOVED);
 
+  public static ReviewerStateInternal fromReviewerState(ReviewerState state) {
+    return ReviewerStateInternal.values()[state.ordinal()];
+  }
+
   static {
     boolean ok = true;
     if (ReviewerStateInternal.values().length != ReviewerState.values().length) {
       ok = false;
     }
-    for (ReviewerStateInternal s : ReviewerStateInternal.values()) {
-      ok &= s.name().equals(s.state.name());
+    for (int i = 0; i < ReviewerStateInternal.values().length; i++) {
+      ok &= ReviewerState.values()[i].equals(ReviewerStateInternal.values()[i].state);
     }
     if (!ok) {
       throw new IllegalStateException(
@@ -58,6 +62,10 @@
     return footerKey;
   }
 
+  FooterKey getByEmailFooterKey() {
+    return new FooterKey(footerKey.getName() + "-email");
+  }
+
   public ReviewerState asReviewerState() {
     return state;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
index aec8442..deec7e9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
@@ -61,7 +61,7 @@
     MutableInteger p = new MutableInteger();
     trimLeadingEmptyLines(raw, p);
     if (p.value >= raw.length) {
-      comments = null;
+      comments = ImmutableList.of();
       return;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
index e6549f0..99d9615 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -24,8 +24,8 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
+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.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
@@ -44,7 +44,7 @@
   private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
   private ObjectId metaId;
 
-  @AssistedInject
+  @Inject
   RobotCommentNotes(Args args, @Assisted Change change) {
     super(args, change.getId(), PrimaryStorage.of(change), false);
     this.change = change;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
index ad22330..53c9dc4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
@@ -15,9 +15,11 @@
 package com.google.gerrit.server.notedb.rebuild;
 
 import com.google.common.base.MoreObjects.ToStringHelper;
+import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gwtorm.server.OrmException;
 import java.sql.Timestamp;
@@ -35,11 +37,17 @@
                   "^Change has been successfully (merged|cherry-picked|rebased|pushed).*$"),
           Change.Status.NEW, Pattern.compile("^Restored(\n.*)*$"));
 
+  private static final Pattern PRIVATE_SET_REGEXP = Pattern.compile("^Set private$");
+  private static final Pattern PRIVATE_UNSET_REGEXP = Pattern.compile("^Unset private$");
+
   private static final Pattern TOPIC_SET_REGEXP = Pattern.compile("^Topic set to (.+)$");
   private static final Pattern TOPIC_CHANGED_REGEXP =
       Pattern.compile("^Topic changed from (.+) to (.+)$");
   private static final Pattern TOPIC_REMOVED_REGEXP = Pattern.compile("^Topic (.+) removed$");
 
+  private static final Pattern WIP_SET_REGEXP = Pattern.compile("^Set Work In Progress$");
+  private static final Pattern WIP_UNSET_REGEXP = Pattern.compile("^Set Ready For Review$");
+
   private final Change change;
   private final Change noteDbChange;
   private final Optional<Change.Status> status;
@@ -80,7 +88,9 @@
   void apply(ChangeUpdate update) throws OrmException {
     checkUpdate(update);
     update.setChangeMessage(message.getMessage());
+    setPrivate(update);
     setTopic(update);
+    setWorkInProgress(update);
 
     if (status.isPresent()) {
       Change.Status s = status.get();
@@ -106,6 +116,25 @@
     return Optional.empty();
   }
 
+  private void setPrivate(ChangeUpdate update) {
+    String msg = message.getMessage();
+    if (msg == null) {
+      return;
+    }
+    Matcher m = PRIVATE_SET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      update.setPrivate(true);
+      noteDbChange.setPrivate(true);
+      return;
+    }
+
+    m = PRIVATE_UNSET_REGEXP.matcher(msg);
+    if (m.matches()) {
+      update.setPrivate(false);
+      noteDbChange.setPrivate(false);
+    }
+  }
+
   private void setTopic(ChangeUpdate update) {
     String msg = message.getMessage();
     if (msg == null) {
@@ -133,6 +162,22 @@
     }
   }
 
+  private void setWorkInProgress(ChangeUpdate update) {
+    String msg = Strings.nullToEmpty(message.getMessage());
+    String tag = message.getTag();
+    if (ChangeMessagesUtil.TAG_SET_WIP.equals(tag)
+        || ChangeMessagesUtil.TAG_UPLOADED_WIP_PATCH_SET.equals(tag)
+        || WIP_SET_REGEXP.matcher(msg).matches()) {
+      update.setWorkInProgress(true);
+      noteDbChange.setWorkInProgress(true);
+    } else if (ChangeMessagesUtil.TAG_SET_READY.equals(tag)
+        || ChangeMessagesUtil.TAG_UPLOADED_PATCH_SET.equals(tag)
+        || WIP_UNSET_REGEXP.matcher(msg).matches()) {
+      update.setWorkInProgress(false);
+      noteDbChange.setWorkInProgress(false);
+    }
+  }
+
   @Override
   protected void addToString(ToStringHelper helper) {
     helper.add("message", message);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
index 6f9090f..8ce9987 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilder.java
@@ -25,7 +25,6 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import java.io.IOException;
-import java.util.concurrent.Callable;
 
 public abstract class ChangeRebuilder {
   public static class NoPatchSetsException extends OrmException {
@@ -43,14 +42,11 @@
   }
 
   public final ListenableFuture<Result> rebuildAsync(
-      final Change.Id id, ListeningExecutorService executor) {
+      Change.Id id, ListeningExecutorService executor) {
     return executor.submit(
-        new Callable<Result>() {
-          @Override
-          public Result call() throws Exception {
-            try (ReviewDb db = schemaFactory.open()) {
-              return rebuild(db, id);
-            }
+        () -> {
+          try (ReviewDb db = schemaFactory.open()) {
+            return rebuild(db, id);
           }
         });
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
index c970cb0..8d5af69 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.notedb.rebuild;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
@@ -179,15 +180,15 @@
   private Result rebuild(ReviewDb db, Change.Id changeId, boolean checkReadOnly)
       throws IOException, OrmException {
     db = ReviewDbUtil.unwrapDb(db);
-    // Read change just to get project; this instance is then discarded so we
-    // can read a consistent ChangeBundle inside a transaction.
+    // Read change just to get project; this instance is then discarded so we can read a consistent
+    // ChangeBundle inside a transaction.
     Change change = db.changes().get(changeId);
     if (change == null) {
       throw new NoSuchChangeException(changeId);
     }
     try (NoteDbUpdateManager manager = updateManagerFactory.create(change.getProject())) {
       buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
-      return execute(db, changeId, manager, checkReadOnly);
+      return execute(db, changeId, manager, checkReadOnly, true);
     }
   }
 
@@ -216,11 +217,15 @@
   @Override
   public Result execute(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
       throws OrmException, IOException {
-    return execute(db, changeId, manager, true);
+    return execute(db, changeId, manager, true, true);
   }
 
   public Result execute(
-      ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager, boolean checkReadOnly)
+      ReviewDb db,
+      Change.Id changeId,
+      NoteDbUpdateManager manager,
+      boolean checkReadOnly,
+      boolean executeManager)
       throws OrmException, IOException {
     db = ReviewDbUtil.unwrapDb(db);
     Change change = checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
@@ -228,9 +233,17 @@
       throw new NoSuchChangeException(changeId);
     }
 
-    final String oldNoteDbState = change.getNoteDbState();
+    String oldNoteDbStateStr = change.getNoteDbState();
     Result r = manager.stageAndApplyDelta(change);
-    final String newNoteDbState = change.getNoteDbState();
+    String newNoteDbStateStr = change.getNoteDbState();
+    if (newNoteDbStateStr == null) {
+      throw new OrmException(
+          String.format(
+              "Rebuilding change %s produced no writes to NoteDb: %s",
+              changeId, bundleReader.fromReviewDb(db, changeId)));
+    }
+    NoteDbChangeState newNoteDbState =
+        checkNotNull(NoteDbChangeState.parse(changeId, newNoteDbStateStr));
     try {
       db.changes()
           .atomicUpdate(
@@ -241,54 +254,52 @@
                   if (checkReadOnly) {
                     NoteDbChangeState.checkNotReadOnly(change, skewMs);
                   }
-                  String currNoteDbState = change.getNoteDbState();
-                  if (Objects.equals(currNoteDbState, newNoteDbState)) {
+                  String currNoteDbStateStr = change.getNoteDbState();
+                  if (Objects.equals(currNoteDbStateStr, newNoteDbStateStr)) {
                     // Another thread completed the same rebuild we were about to.
                     throw new AbortUpdateException();
-                  } else if (!Objects.equals(oldNoteDbState, currNoteDbState)) {
+                  } else if (!Objects.equals(oldNoteDbStateStr, currNoteDbStateStr)) {
                     // Another thread updated the state to something else.
-                    throw new ConflictingUpdateException(change, oldNoteDbState);
+                    throw new ConflictingUpdateRuntimeException(change, oldNoteDbStateStr);
                   }
-                  change.setNoteDbState(newNoteDbState);
+                  change.setNoteDbState(newNoteDbStateStr);
                   return change;
                 }
               });
-    } catch (ConflictingUpdateException e) {
-      // Rethrow as an OrmException so the caller knows to use staged results.
-      // Strictly speaking they are not completely up to date, but result we
-      // send to the caller is the same as if this rebuild had executed before
-      // the other thread.
-      throw new OrmException(e.getMessage());
+    } catch (ConflictingUpdateRuntimeException e) {
+      // Rethrow as an OrmException so the caller knows to use staged results. Strictly speaking
+      // they are not completely up to date, but result we send to the caller is the same as if this
+      // rebuild had executed before the other thread.
+      throw new ConflictingUpdateException(e);
     } catch (AbortUpdateException e) {
-      if (NoteDbChangeState.parse(changeId, newNoteDbState)
-          .isUpToDate(
-              manager.getChangeRepo().cmds.getRepoRefCache(),
-              manager.getAllUsersRepo().cmds.getRepoRefCache())) {
-        // If the state in ReviewDb matches NoteDb at this point, it means
-        // another thread successfully completed this rebuild. It's ok to not
-        // execute the update in this case, since the object referenced in the
-        // Result was flushed to the repo by whatever thread won the race.
+      if (newNoteDbState.isUpToDate(
+          manager.getChangeRepo().cmds.getRepoRefCache(),
+          manager.getAllUsersRepo().cmds.getRepoRefCache())) {
+        // If the state in ReviewDb matches NoteDb at this point, it means another thread
+        // successfully completed this rebuild. It's ok to not execute the update in this case,
+        // since the object referenced in the Result was flushed to the repo by whatever thread won
+        // the race.
         return r;
       }
-      // If the state doesn't match, that means another thread attempted this
-      // rebuild, but failed. Fall through and try to update the ref again.
+      // If the state doesn't match, that means another thread attempted this rebuild, but
+      // failed. Fall through and try to update the ref again.
     }
     if (migration.failChangeWrites()) {
-      // Don't even attempt to execute if read-only, it would fail anyway. But
-      // do throw an exception to the caller so they know to use the staged
-      // results instead of reading from the repo.
+      // Don't even attempt to execute if read-only, it would fail anyway. But do throw an exception
+      // to the caller so they know to use the staged results instead of reading from the repo.
       throw new OrmException(NoteDbUpdateManager.CHANGES_READ_ONLY);
     }
-    manager.execute();
+    if (executeManager) {
+      manager.execute();
+    }
     return r;
   }
 
-  private static Change checkNoteDbState(Change c) throws OrmException {
+  static Change checkNoteDbState(Change c) throws OrmException {
     // Can only rebuild a change if its primary storage is ReviewDb.
     NoteDbChangeState s = NoteDbChangeState.parse(c);
     if (s != null && s.getPrimaryStorage() != PrimaryStorage.REVIEW_DB) {
-      throw new OrmException(
-          String.format("cannot rebuild change " + c.getId() + " with state " + s));
+      throw new OrmException(String.format("cannot rebuild change %s with state %s", c.getId(), s));
     }
     return c;
   }
@@ -301,16 +312,23 @@
     if (bundle.getPatchSets().isEmpty()) {
       throw new NoPatchSetsException(change.getId());
     }
+    if (change.getLastUpdatedOn().compareTo(change.getCreatedOn()) < 0) {
+      // A bug in data migration might set created_on to the time of the migration. The
+      // correct timestamps were lost, but we can at least set it so created_on is not after
+      // last_updated_on.
+      // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7397
+      change.setCreatedOn(change.getLastUpdatedOn());
+    }
 
-    // We will rebuild all events, except for draft comments, in buckets based
-    // on author and timestamp.
+    // We will rebuild all events, except for draft comments, in buckets based on author and
+    // timestamp.
     List<Event> events = new ArrayList<>();
     ListMultimap<Account.Id, DraftCommentEvent> draftCommentEvents =
         MultimapBuilder.hashKeys().arrayListValues().build();
 
     events.addAll(getHashtagsEvents(change, manager));
 
-    // Delete ref only after hashtags have been read
+    // Delete ref only after hashtags have been read.
     deleteChangeMetaRef(change, manager.getChangeRepo().cmds);
     deleteDraftRefs(change, manager.getAllUsersRepo());
 
@@ -429,10 +447,12 @@
     setPostSubmitDeps(events);
     new EventSorter(events).sort();
 
-    // Ensure the first event in the list creates the change, setting the author
-    // and any required footers.
+    // Ensure the first event in the list creates the change, setting the author and any required
+    // footers. Also force the creation time of the first patch set to match the creation time of
+    // the change.
     Event first = events.get(0);
     if (first instanceof PatchSetEvent && change.getOwner().equals(first.user)) {
+      first.when = change.getCreatedOn();
       ((PatchSetEvent) first).createChange = true;
     } else {
       events.add(0, new CreateChangeEvent(change, minPsNum));
@@ -440,22 +460,17 @@
 
     // Final pass to correct some inconsistencies.
     //
-    // First, fill in any missing patch set IDs using the latest patch set of
-    // the change at the time of the event, because NoteDb can't represent
-    // actions with no associated patch set ID. This workaround is as if a user
-    // added a ChangeMessage on the change by replying from the latest patch
-    // set.
+    // First, fill in any missing patch set IDs using the latest patch set of the change at the time
+    // of the event, because NoteDb can't represent actions with no associated patch set ID. This
+    // workaround is as if a user added a ChangeMessage on the change by replying from the latest
+    // patch set.
     //
-    // Start with the first patch set that actually exists. If there are no
-    // patch sets at all, minPsNum will be null, so just bail and use 1 as the
-    // patch set ID. The corresponding patch set won't exist, but this change is
-    // probably corrupt anyway, as deleting the last draft patch set should have
-    // deleted the whole change.
+    // Start with the first patch set that actually exists. If there are no patch sets at all,
+    // minPsNum will be null, so just bail and use 1 as the patch set ID.
     //
-    // Second, ensure timestamps are nondecreasing, by copying the previous
-    // timestamp if this happens. This assumes that the only way this can happen
-    // is due to dependency constraints, and it is ok to give an event the same
-    // timestamp as one of its dependencies.
+    // Second, ensure timestamps are nondecreasing, by copying the previous timestamp if this
+    // happens. This assumes that the only way this can happen is due to dependency constraints, and
+    // it is ok to give an event the same timestamp as one of its dependencies.
     int ps = firstNonNull(minPsNum, 1);
     for (int i = 0; i < events.size(); i++) {
       Event e = events.get(i);
@@ -492,8 +507,8 @@
     if (projectCache != null) {
       labelNameComparator = projectCache.get(change.getProject()).getLabelTypes().nameComparator();
     } else {
-      // No project cache available, bail and use natural ordering; there's no
-      // semantic difference anyway difference.
+      // No project cache available, bail and use natural ordering; there's no semantic difference
+      // anyway difference.
       labelNameComparator = Ordering.natural();
     }
     ChangeUpdate update =
@@ -515,8 +530,7 @@
   }
 
   private void flushEventsToDraftUpdate(
-      NoteDbUpdateManager manager, EventList<DraftCommentEvent> events, Change change)
-      throws OrmException {
+      NoteDbUpdateManager manager, EventList<DraftCommentEvent> events, Change change) {
     if (events.isEmpty()) {
       return;
     }
@@ -623,6 +637,9 @@
     update.setChangeId(change.getKey().get());
     update.setBranch(change.getDest().get());
     update.setSubject(change.getOriginalSubject());
+    if (change.getRevertOf() != null) {
+      update.setRevertOf(change.getRevertOf().get());
+    }
   }
 
   @Override
@@ -636,9 +653,18 @@
     db.changes().beginTransaction(changeId);
     try {
       Change c = db.changes().get(changeId);
-      PrimaryStorage ps = PrimaryStorage.of(c);
-      if (ps != PrimaryStorage.NOTE_DB) {
-        throw new OrmException("primary storage of " + changeId + " is " + ps);
+      if (c != null) {
+        PrimaryStorage ps = PrimaryStorage.of(c);
+        switch (ps) {
+          case REVIEW_DB:
+            return; // Nothing to do.
+          case NOTE_DB:
+            break; // Continue and rebuild.
+          default:
+            throw new OrmException("primary storage of " + changeId + " is " + ps);
+        }
+      } else {
+        c = notes.getChange();
       }
       db.changes().upsert(Collections.singleton(c));
       putExactlyEntities(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
index c8a649e..1fffab4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
@@ -24,9 +24,13 @@
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class CommentEvent extends Event {
+  private static final Logger log = LoggerFactory.getLogger(CommentEvent.class);
+
   public final Comment c;
   private final Change change;
   private final PatchSet ps;
@@ -57,10 +61,19 @@
   }
 
   @Override
-  void apply(ChangeUpdate update) throws OrmException {
+  void apply(ChangeUpdate update) {
     checkUpdate(update);
     if (c.revId == null) {
-      setCommentRevId(c, cache, change, ps);
+      try {
+        setCommentRevId(c, cache, change, ps);
+      } catch (PatchListNotAvailableException e) {
+        log.warn(
+            "Unable to determine parent commit of patch set {} ({}); omitting inline comment {}",
+            ps.getId(),
+            ps.getRevision(),
+            c);
+        return;
+      }
     }
     update.putComment(PatchLineComment.Status.PUBLISHED, c);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
index c6ffffc..d8e7480 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateException.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2016 The Android Open Source Project
+// 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.
@@ -14,16 +14,19 @@
 
 package com.google.gerrit.server.notedb.rebuild;
 
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gwtorm.server.OrmRuntimeException;
+import com.google.gwtorm.server.OrmException;
 
-class ConflictingUpdateException extends OrmRuntimeException {
+/**
+ * {@link com.google.gwtorm.server.OrmException} thrown by {@link ChangeRebuilder} when rebuilding a
+ * change failed because another operation modified its {@link
+ * com.google.gerrit.server.notedb.NoteDbChangeState}.
+ */
+public class ConflictingUpdateException extends OrmException {
   private static final long serialVersionUID = 1L;
 
-  ConflictingUpdateException(Change change, String expectedNoteDbState) {
-    super(
-        String.format(
-            "Expected change %s to have noteDbState %s but was %s",
-            change.getId(), expectedNoteDbState, change.getNoteDbState()));
+  // Always created from a ConflictingUpdateRuntimeException because it originates from an
+  // AtomicUpdate, which cannot throw checked exceptions.
+  ConflictingUpdateException(ConflictingUpdateRuntimeException cause) {
+    super(cause.getMessage(), cause);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateRuntimeException.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateRuntimeException.java
new file mode 100644
index 0000000..abfafa2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ConflictingUpdateRuntimeException.java
@@ -0,0 +1,29 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.notedb.rebuild;
+
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gwtorm.server.OrmRuntimeException;
+
+class ConflictingUpdateRuntimeException extends OrmRuntimeException {
+  private static final long serialVersionUID = 1L;
+
+  ConflictingUpdateRuntimeException(Change change, String expectedNoteDbState) {
+    super(
+        String.format(
+            "Expected change %s to have noteDbState %s but was %s",
+            change.getId(), expectedNoteDbState, change.getNoteDbState()));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
index 914930c..3bc3a58 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
@@ -24,9 +24,13 @@
 import com.google.gerrit.server.notedb.ChangeDraftUpdate;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.patch.PatchListCache;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.server.patch.PatchListNotAvailableException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 class DraftCommentEvent extends Event {
+  private static final Logger log = LoggerFactory.getLogger(DraftCommentEvent.class);
+
   public final Comment c;
   private final Change change;
   private final PatchSet ps;
@@ -56,9 +60,18 @@
     throw new UnsupportedOperationException();
   }
 
-  void applyDraft(ChangeDraftUpdate draftUpdate) throws OrmException {
+  void applyDraft(ChangeDraftUpdate draftUpdate) {
     if (c.revId == null) {
-      setCommentRevId(c, cache, change, ps);
+      try {
+        setCommentRevId(c, cache, change, ps);
+      } catch (PatchListNotAvailableException e) {
+        log.warn(
+            "Unable to determine parent commit of patch set {} ({}); omitting draft inline comment",
+            ps.getId(),
+            ps.getRevision(),
+            c);
+        return;
+      }
     }
     draftUpdate.putComment(c);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
index b1bd6ec..55d5a31 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
@@ -57,6 +57,12 @@
       // TODO(dborowitz): Stamp approximate approvals at this time.
       update.fixStatus(change.getStatus());
     }
+    if (change.isPrivate() != noteDbChange.isPrivate()) {
+      update.setPrivate(change.isPrivate());
+    }
+    if (change.isWorkInProgress() != noteDbChange.isWorkInProgress()) {
+      update.setWorkInProgress(change.isWorkInProgress());
+    }
     if (change.getSubmissionId() != null && noteDbChange.getSubmissionId() == null) {
       update.setSubmissionId(change.getSubmissionId());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/GcAllUsers.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/GcAllUsers.java
new file mode 100644
index 0000000..0653192
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/GcAllUsers.java
@@ -0,0 +1,122 @@
+// 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.server.notedb.rebuild;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static org.eclipse.jgit.lib.ConfigConstants.CONFIG_GC_SECTION;
+import static org.eclipse.jgit.lib.ConfigConstants.CONFIG_KEY_AUTO;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GarbageCollectionResult;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GarbageCollection;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LocalDiskRepositoryManager;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.function.Consumer;
+import org.eclipse.jgit.lib.Repository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class GcAllUsers {
+  private static final Logger log = LoggerFactory.getLogger(GcAllUsers.class);
+
+  private final AllUsersName allUsers;
+  private final GarbageCollection.Factory gcFactory;
+  private final GitRepositoryManager repoManager;
+
+  @Inject
+  GcAllUsers(
+      AllUsersName allUsers,
+      GarbageCollection.Factory gcFactory,
+      GitRepositoryManager repoManager) {
+    this.allUsers = allUsers;
+    this.gcFactory = gcFactory;
+    this.repoManager = repoManager;
+  }
+
+  public void runWithLogger() {
+    // Print log messages using logger, and skip progress.
+    run(s -> log.info(s), null);
+  }
+
+  public void run(PrintWriter writer) {
+    // Print both log messages and progress to given writer.
+    run(checkNotNull(writer)::println, writer);
+  }
+
+  private void run(Consumer<String> logOneLine, @Nullable PrintWriter progressWriter) {
+    if (!(repoManager instanceof LocalDiskRepositoryManager)) {
+      logOneLine.accept("Skipping GC of " + allUsers + "; not a local disk repo");
+      return;
+    }
+    if (!enableAutoGc(logOneLine)) {
+      logOneLine.accept(
+          "Skipping GC of "
+              + allUsers
+              + " due to disabling "
+              + CONFIG_GC_SECTION
+              + "."
+              + CONFIG_KEY_AUTO);
+      logOneLine.accept(
+          "If loading accounts is slow after the NoteDb migration, run `git gc` on "
+              + allUsers
+              + " manually");
+      return;
+    }
+
+    if (progressWriter == null) {
+      // Mimic log line from GarbageCollection.
+      logOneLine.accept("collecting garbage for \"" + allUsers + "\":\n");
+    }
+    GarbageCollectionResult result =
+        gcFactory.create().run(ImmutableList.of(allUsers), progressWriter);
+    if (!result.hasErrors()) {
+      return;
+    }
+    for (GarbageCollectionResult.Error e : result.getErrors()) {
+      switch (e.getType()) {
+        case GC_ALREADY_SCHEDULED:
+          logOneLine.accept("GC already scheduled for " + e.getProjectName());
+          break;
+        case GC_FAILED:
+          logOneLine.accept("GC failed for " + e.getProjectName());
+          break;
+        case REPOSITORY_NOT_FOUND:
+          logOneLine.accept(e.getProjectName() + " repo not found");
+          break;
+        default:
+          logOneLine.accept("GC failed for " + e.getProjectName() + ": " + e.getType());
+          break;
+      }
+    }
+  }
+
+  private boolean enableAutoGc(Consumer<String> logOneLine) {
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      return repo.getConfig().getInt(CONFIG_GC_SECTION, CONFIG_KEY_AUTO, -1) != 0;
+    } catch (IOException e) {
+      logOneLine.accept(
+          "Error reading config for " + allUsers + ":\n" + Throwables.getStackTraceAsString(e));
+      return false;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/MigrationException.java
new file mode 100644
index 0000000..0cf78be
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/MigrationException.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.server.notedb.rebuild;
+
+import java.io.IOException;
+
+/** Exception thrown by {@link NoteDbMigrator} when migration fails. */
+public class MigrationException extends IOException {
+  private static final long serialVersionUID = 1L;
+
+  MigrationException(String message) {
+    super(message);
+  }
+
+  MigrationException(String message, Throwable why) {
+    super(message, why);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
new file mode 100644
index 0000000..24e8236
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NoteDbMigrator.java
@@ -0,0 +1,1028 @@
+// 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.notedb.rebuild;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.reviewdb.server.ReviewDbUtil.unwrapDb;
+import static com.google.gerrit.server.notedb.NotesMigration.SECTION_NOTE_DB;
+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.WRITE;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.joining;
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Stopwatch;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Iterables;
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Ordering;
+import com.google.common.collect.SetMultimap;
+import com.google.common.collect.Streams;
+import com.google.common.util.concurrent.Futures;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.common.FormatUtil;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.server.ReviewDbWrapper;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.InternalUser;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.GerritServerConfigProvider;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.notedb.ChangeBundleReader;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NoteDbTable;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.notedb.NotesMigrationState;
+import com.google.gerrit.server.notedb.PrimaryStorageMigrator;
+import com.google.gerrit.server.notedb.PrimaryStorageMigrator.NoNoteDbStateException;
+import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.update.ChainedReceiveCommands;
+import com.google.gerrit.server.update.RefUpdateUtil;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+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.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.io.PrintWriter;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.internal.storage.file.FileRepository;
+import org.eclipse.jgit.internal.storage.file.PackInserter;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.eclipse.jgit.util.FS;
+import org.eclipse.jgit.util.io.NullOutputStream;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** One stop shop for migrating a site's change storage from ReviewDb to NoteDb. */
+public class NoteDbMigrator implements AutoCloseable {
+  private static final Logger log = LoggerFactory.getLogger(NoteDbMigrator.class);
+
+  private static final String AUTO_MIGRATE = "autoMigrate";
+  private static final String TRIAL = "trial";
+
+  public static boolean getAutoMigrate(Config cfg) {
+    return cfg.getBoolean(SECTION_NOTE_DB, NoteDbTable.CHANGES.key(), AUTO_MIGRATE, false);
+  }
+
+  private static void setAutoMigrate(Config cfg, boolean autoMigrate) {
+    cfg.setBoolean(SECTION_NOTE_DB, NoteDbTable.CHANGES.key(), AUTO_MIGRATE, autoMigrate);
+  }
+
+  public static boolean getTrialMode(Config cfg) {
+    return cfg.getBoolean(SECTION_NOTE_DB, NoteDbTable.CHANGES.key(), TRIAL, false);
+  }
+
+  public static void setTrialMode(Config cfg, boolean trial) {
+    cfg.setBoolean(SECTION_NOTE_DB, NoteDbTable.CHANGES.key(), TRIAL, trial);
+  }
+
+  public static class Builder {
+    private final Config cfg;
+    private final SitePaths sitePaths;
+    private final Provider<PersonIdent> serverIdent;
+    private final AllUsersName allUsers;
+    private final SchemaFactory<ReviewDb> schemaFactory;
+    private final GitRepositoryManager repoManager;
+    private final NoteDbUpdateManager.Factory updateManagerFactory;
+    private final ChangeBundleReader bundleReader;
+    private final AllProjectsName allProjects;
+    private final InternalUser.Factory userFactory;
+    private final ThreadLocalRequestContext requestContext;
+    private final ChangeRebuilderImpl rebuilder;
+    private final WorkQueue workQueue;
+    private final MutableNotesMigration globalNotesMigration;
+    private final PrimaryStorageMigrator primaryStorageMigrator;
+    private final DynamicSet<NotesMigrationStateListener> listeners;
+
+    private int threads;
+    private ImmutableList<Project.NameKey> projects = ImmutableList.of();
+    private ImmutableList<Change.Id> changes = ImmutableList.of();
+    private OutputStream progressOut = NullOutputStream.INSTANCE;
+    private NotesMigrationState stopAtState;
+    private boolean trial;
+    private boolean forceRebuild;
+    private int sequenceGap = -1;
+    private boolean autoMigrate;
+
+    @Inject
+    Builder(
+        GerritServerConfigProvider configProvider,
+        SitePaths sitePaths,
+        @GerritPersonIdent Provider<PersonIdent> serverIdent,
+        AllUsersName allUsers,
+        SchemaFactory<ReviewDb> schemaFactory,
+        GitRepositoryManager repoManager,
+        NoteDbUpdateManager.Factory updateManagerFactory,
+        ChangeBundleReader bundleReader,
+        AllProjectsName allProjects,
+        ThreadLocalRequestContext requestContext,
+        InternalUser.Factory userFactory,
+        ChangeRebuilderImpl rebuilder,
+        WorkQueue workQueue,
+        MutableNotesMigration globalNotesMigration,
+        PrimaryStorageMigrator primaryStorageMigrator,
+        DynamicSet<NotesMigrationStateListener> listeners) {
+      // Reload gerrit.config/notedb.config on each migrator invocation, in case a previous
+      // migration in the same process modified the on-disk contents. This ensures the defaults for
+      // trial/autoMigrate get set correctly below.
+      this.cfg = configProvider.get();
+      this.sitePaths = sitePaths;
+      this.serverIdent = serverIdent;
+      this.allUsers = allUsers;
+      this.schemaFactory = schemaFactory;
+      this.repoManager = repoManager;
+      this.updateManagerFactory = updateManagerFactory;
+      this.bundleReader = bundleReader;
+      this.allProjects = allProjects;
+      this.requestContext = requestContext;
+      this.userFactory = userFactory;
+      this.rebuilder = rebuilder;
+      this.workQueue = workQueue;
+      this.globalNotesMigration = globalNotesMigration;
+      this.primaryStorageMigrator = primaryStorageMigrator;
+      this.listeners = listeners;
+      this.trial = getTrialMode(cfg);
+      this.autoMigrate = getAutoMigrate(cfg);
+    }
+
+    /**
+     * Set the number of threads used by parallelizable phases of the migration, such as rebuilding
+     * all changes.
+     *
+     * <p>Not all phases are parallelizable, and calling {@link #rebuild()} directly will do
+     * substantial work in the calling thread regardless of the number of threads configured.
+     *
+     * <p>By default, all work is done in the calling thread.
+     *
+     * @param threads thread count; if less than 2, all work happens in the calling thread.
+     * @return this.
+     */
+    public Builder setThreads(int threads) {
+      this.threads = threads;
+      return this;
+    }
+
+    /**
+     * Limit the set of projects that are processed.
+     *
+     * <p>Incompatible with {@link #setChanges(Collection)}.
+     *
+     * <p>By default, all projects will be processed.
+     *
+     * @param projects set of projects; if null or empty, all projects will be processed.
+     * @return this.
+     */
+    public Builder setProjects(@Nullable Collection<Project.NameKey> projects) {
+      this.projects = projects != null ? ImmutableList.copyOf(projects) : ImmutableList.of();
+      return this;
+    }
+
+    /**
+     * Limit the set of changes that are processed.
+     *
+     * <p>Incompatible with {@link #setProjects(Collection)}.
+     *
+     * <p>By default, all changes will be processed.
+     *
+     * @param changes set of changes; if null or empty, all changes will be processed.
+     * @return this.
+     */
+    public Builder setChanges(@Nullable Collection<Change.Id> changes) {
+      this.changes = changes != null ? ImmutableList.copyOf(changes) : ImmutableList.of();
+      return this;
+    }
+
+    /**
+     * Set output stream for progress monitors.
+     *
+     * <p>By default, there is no progress monitor output (although there may be other logs).
+     *
+     * @param progressOut output stream.
+     * @return this.
+     */
+    public Builder setProgressOut(OutputStream progressOut) {
+      this.progressOut = checkNotNull(progressOut);
+      return this;
+    }
+
+    /**
+     * Stop at a specific migration state, for testing only.
+     *
+     * @param stopAtState state to stop at.
+     * @return this.
+     */
+    @VisibleForTesting
+    public Builder setStopAtStateForTesting(NotesMigrationState stopAtState) {
+      this.stopAtState = stopAtState;
+      return this;
+    }
+
+    /**
+     * Rebuild in "trial mode": configure Gerrit to write to and read from NoteDb, but leave
+     * ReviewDb as the source of truth for all changes.
+     *
+     * <p>By default, trial mode is off, and NoteDb is the source of truth for all changes following
+     * the migration.
+     *
+     * @param trial whether to rebuild in trial mode.
+     * @return this.
+     */
+    public Builder setTrialMode(boolean trial) {
+      this.trial = trial;
+      return this;
+    }
+
+    /**
+     * Rebuild all changes in NoteDb from ReviewDb, even if Gerrit is currently configured to read
+     * from NoteDb.
+     *
+     * <p>Only supported if ReviewDb is still the source of truth for all changes.
+     *
+     * <p>By default, force rebuilding is off.
+     *
+     * @param forceRebuild whether to force rebuilding.
+     * @return this.
+     */
+    public Builder setForceRebuild(boolean forceRebuild) {
+      this.forceRebuild = forceRebuild;
+      return this;
+    }
+
+    /**
+     * Gap between ReviewDb change sequence numbers and NoteDb.
+     *
+     * <p>If NoteDb sequences are enabled in a running server, there is a race between the migration
+     * step that calls {@code nextChangeId()} to seed the ref, and other threads that call {@code
+     * nextChangeId()} to create new changes. In order to prevent these operations stepping on one
+     * another, we use this value to skip some predefined sequence numbers. This is strongly
+     * recommended in a running server.
+     *
+     * <p>If the migration takes place offline, there is no race with other threads, and this option
+     * may be set to 0. However, admins may still choose to use a gap, for example to make it easier
+     * to distinguish changes that were created before and after the NoteDb migration.
+     *
+     * <p>By default, uses the value from {@code noteDb.changes.initialSequenceGap} in {@code
+     * gerrit.config}, which defaults to 1000.
+     *
+     * @param sequenceGap sequence gap size; if negative, use the default.
+     * @return this.
+     */
+    public Builder setSequenceGap(int sequenceGap) {
+      this.sequenceGap = sequenceGap;
+      return this;
+    }
+
+    /**
+     * Enable auto-migration on subsequent daemon launches.
+     *
+     * <p>If true, prior to running any migration steps, sets the necessary configuration in {@code
+     * gerrit.config} to make {@code gerrit.war daemon} retry the migration on next startup, if it
+     * fails.
+     *
+     * @param autoMigrate whether to set auto-migration config.
+     * @return this.
+     */
+    public Builder setAutoMigrate(boolean autoMigrate) {
+      this.autoMigrate = autoMigrate;
+      return this;
+    }
+
+    public NoteDbMigrator build() throws MigrationException {
+      return new NoteDbMigrator(
+          sitePaths,
+          schemaFactory,
+          serverIdent,
+          allUsers,
+          repoManager,
+          updateManagerFactory,
+          bundleReader,
+          allProjects,
+          requestContext,
+          userFactory,
+          rebuilder,
+          globalNotesMigration,
+          primaryStorageMigrator,
+          listeners,
+          threads > 1
+              ? MoreExecutors.listeningDecorator(
+                  workQueue.createQueue(threads, "RebuildChange", true))
+              : MoreExecutors.newDirectExecutorService(),
+          projects,
+          changes,
+          progressOut,
+          stopAtState,
+          trial,
+          forceRebuild,
+          sequenceGap >= 0 ? sequenceGap : Sequences.getChangeSequenceGap(cfg),
+          autoMigrate);
+    }
+  }
+
+  private final FileBasedConfig gerritConfig;
+  private final FileBasedConfig noteDbConfig;
+  private final SchemaFactory<ReviewDb> schemaFactory;
+  private final Provider<PersonIdent> serverIdent;
+  private final AllUsersName allUsers;
+  private final GitRepositoryManager repoManager;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final ChangeBundleReader bundleReader;
+  private final AllProjectsName allProjects;
+  private final ThreadLocalRequestContext requestContext;
+  private final InternalUser.Factory userFactory;
+  private final ChangeRebuilderImpl rebuilder;
+  private final MutableNotesMigration globalNotesMigration;
+  private final PrimaryStorageMigrator primaryStorageMigrator;
+  private final DynamicSet<NotesMigrationStateListener> listeners;
+
+  private final ListeningExecutorService executor;
+  private final ImmutableList<Project.NameKey> projects;
+  private final ImmutableList<Change.Id> changes;
+  private final OutputStream progressOut;
+  private final NotesMigrationState stopAtState;
+  private final boolean trial;
+  private final boolean forceRebuild;
+  private final int sequenceGap;
+  private final boolean autoMigrate;
+
+  private NoteDbMigrator(
+      SitePaths sitePaths,
+      SchemaFactory<ReviewDb> schemaFactory,
+      Provider<PersonIdent> serverIdent,
+      AllUsersName allUsers,
+      GitRepositoryManager repoManager,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeBundleReader bundleReader,
+      AllProjectsName allProjects,
+      ThreadLocalRequestContext requestContext,
+      InternalUser.Factory userFactory,
+      ChangeRebuilderImpl rebuilder,
+      MutableNotesMigration globalNotesMigration,
+      PrimaryStorageMigrator primaryStorageMigrator,
+      DynamicSet<NotesMigrationStateListener> listeners,
+      ListeningExecutorService executor,
+      ImmutableList<Project.NameKey> projects,
+      ImmutableList<Change.Id> changes,
+      OutputStream progressOut,
+      NotesMigrationState stopAtState,
+      boolean trial,
+      boolean forceRebuild,
+      int sequenceGap,
+      boolean autoMigrate)
+      throws MigrationException {
+    if (!changes.isEmpty() && !projects.isEmpty()) {
+      throw new MigrationException("Cannot set both changes and projects");
+    }
+    if (sequenceGap < 0) {
+      throw new MigrationException("Sequence gap must be non-negative: " + sequenceGap);
+    }
+
+    this.schemaFactory = schemaFactory;
+    this.serverIdent = serverIdent;
+    this.allUsers = allUsers;
+    this.rebuilder = rebuilder;
+    this.repoManager = repoManager;
+    this.updateManagerFactory = updateManagerFactory;
+    this.bundleReader = bundleReader;
+    this.allProjects = allProjects;
+    this.requestContext = requestContext;
+    this.userFactory = userFactory;
+    this.globalNotesMigration = globalNotesMigration;
+    this.primaryStorageMigrator = primaryStorageMigrator;
+    this.listeners = listeners;
+    this.executor = executor;
+    this.projects = projects;
+    this.changes = changes;
+    this.progressOut = progressOut;
+    this.stopAtState = stopAtState;
+    this.trial = trial;
+    this.forceRebuild = forceRebuild;
+    this.sequenceGap = sequenceGap;
+    this.autoMigrate = autoMigrate;
+
+    // Stack notedb.config over gerrit.config, in the same way as GerritServerConfigProvider.
+    this.gerritConfig = new FileBasedConfig(sitePaths.gerrit_config.toFile(), FS.detect());
+    this.noteDbConfig =
+        new FileBasedConfig(gerritConfig, sitePaths.notedb_config.toFile(), FS.detect());
+  }
+
+  @Override
+  public void close() {
+    executor.shutdownNow();
+  }
+
+  public void migrate() throws OrmException, IOException {
+    if (!changes.isEmpty() || !projects.isEmpty()) {
+      throw new MigrationException(
+          "Cannot set changes or projects during full migration; call rebuild() instead");
+    }
+    Optional<NotesMigrationState> maybeState = loadState();
+    if (!maybeState.isPresent()) {
+      throw new MigrationException("Could not determine initial migration state");
+    }
+
+    NotesMigrationState state = maybeState.get();
+    if (trial && state.compareTo(READ_WRITE_NO_SEQUENCE) > 0) {
+      throw new MigrationException(
+          "Migration has already progressed past the endpoint of the \"trial mode\" state;"
+              + " NoteDb is already the primary storage for some changes");
+    }
+    if (forceRebuild && state.compareTo(READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY) > 0) {
+      throw new MigrationException(
+          "Cannot force rebuild changes; NoteDb is already the primary storage for some changes");
+    }
+    setControlFlags();
+
+    boolean rebuilt = false;
+    while (state.compareTo(NOTE_DB) < 0) {
+      if (state.equals(stopAtState)) {
+        return;
+      }
+      boolean stillNeedsRebuild = forceRebuild && !rebuilt;
+      if (trial && state.compareTo(READ_WRITE_NO_SEQUENCE) >= 0) {
+        if (stillNeedsRebuild && state == READ_WRITE_NO_SEQUENCE) {
+          // We're at the end state of trial mode, but still need a rebuild due to forceRebuild. Let
+          // the loop go one more time.
+        } else {
+          return;
+        }
+      }
+      switch (state) {
+        case REVIEW_DB:
+          state = turnOnWrites(state);
+          break;
+        case WRITE:
+          state = rebuildAndEnableReads(state);
+          rebuilt = true;
+          break;
+        case READ_WRITE_NO_SEQUENCE:
+          if (stillNeedsRebuild) {
+            state = rebuildAndEnableReads(state);
+            rebuilt = true;
+          } else {
+            state = enableSequences(state);
+          }
+          break;
+        case READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY:
+          if (stillNeedsRebuild) {
+            state = rebuildAndEnableReads(state);
+            rebuilt = true;
+          } else {
+            state = setNoteDbPrimary(state);
+          }
+          break;
+        case READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY:
+          // The only way we can get here is if there was a failure on a previous run of
+          // setNoteDbPrimary, since that method moves to NOTE_DB if it completes
+          // successfully. Assume that not all changes were converted and re-run the step.
+          // migrateToNoteDbPrimary is a relatively fast no-op for already-migrated changes, so this
+          // isn't actually repeating work.
+          state = setNoteDbPrimary(state);
+          break;
+        case NOTE_DB:
+          // Done!
+          break;
+        default:
+          throw new MigrationException(
+              "Migration out of the following state is not supported:\n" + state.toText());
+      }
+    }
+  }
+
+  private NotesMigrationState turnOnWrites(NotesMigrationState prev) throws IOException {
+    return saveState(prev, WRITE);
+  }
+
+  private NotesMigrationState rebuildAndEnableReads(NotesMigrationState prev)
+      throws OrmException, IOException {
+    rebuild();
+    return saveState(prev, READ_WRITE_NO_SEQUENCE);
+  }
+
+  private NotesMigrationState enableSequences(NotesMigrationState prev)
+      throws OrmException, IOException {
+    try (ReviewDb db = schemaFactory.open()) {
+      @SuppressWarnings("deprecation")
+      final int nextChangeId = db.nextChangeId();
+
+      RepoSequence seq =
+          new RepoSequence(
+              repoManager,
+              GitReferenceUpdated.DISABLED,
+              allProjects,
+              Sequences.NAME_CHANGES,
+              // If sequenceGap is 0, this writes into the sequence ref the same ID that is returned
+              // by the call to seq.next() below. If we actually used this as a change ID, that
+              // would be a problem, but we just discard it, so this is safe.
+              () -> nextChangeId + sequenceGap - 1,
+              1,
+              nextChangeId);
+      seq.next();
+    }
+    return saveState(prev, READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY);
+  }
+
+  private NotesMigrationState setNoteDbPrimary(NotesMigrationState prev)
+      throws MigrationException, OrmException, IOException {
+    checkState(
+        projects.isEmpty() && changes.isEmpty(),
+        "Should not have attempted setNoteDbPrimary with a subset of changes");
+    checkState(
+        prev == READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY
+            || prev == READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY,
+        "Unexpected start state for setNoteDbPrimary: %s",
+        prev);
+
+    // Before changing the primary storage of old changes, ensure new changes are created with
+    // NoteDb primary.
+    prev = saveState(prev, READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY);
+
+    Stopwatch sw = Stopwatch.createStarted();
+    log.info("Setting primary storage to NoteDb");
+    List<Change.Id> allChanges;
+    try (ReviewDb db = unwrapDb(schemaFactory.open())) {
+      allChanges = Streams.stream(db.changes().all()).map(Change::getId).collect(toList());
+    }
+
+    try (ContextHelper contextHelper = new ContextHelper()) {
+      List<ListenableFuture<Boolean>> futures =
+          allChanges
+              .stream()
+              .map(
+                  id ->
+                      executor.submit(
+                          () -> {
+                            try (ManualRequestContext ctx = contextHelper.open()) {
+                              try {
+                                primaryStorageMigrator.migrateToNoteDbPrimary(id);
+                              } catch (NoNoteDbStateException e) {
+                                if (canSkipPrimaryStorageMigration(
+                                    ctx.getReviewDbProvider().get(), id)) {
+                                  log.warn(
+                                      "Change {} previously failed to rebuild;"
+                                          + " skipping primary storage migration",
+                                      id,
+                                      e);
+                                } else {
+                                  throw e;
+                                }
+                              }
+                              return true;
+                            } catch (Exception e) {
+                              log.error("Error migrating primary storage for " + id, e);
+                              return false;
+                            }
+                          }))
+              .collect(toList());
+
+      boolean ok = futuresToBoolean(futures, "Error migrating primary storage");
+      double t = sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
+      log.info(
+          String.format(
+              "Migrated primary storage of %d changes in %.01fs (%.01f/s)\n",
+              allChanges.size(), t, allChanges.size() / t));
+      if (!ok) {
+        throw new MigrationException("Migrating primary storage for some changes failed, see log");
+      }
+    }
+
+    return disableReviewDb(prev);
+  }
+
+  /**
+   * Checks whether a change is so corrupt that it can be completely skipped by the primary storage
+   * migration step.
+   *
+   * <p>To get to the point where this method is called from {@link #setNoteDbPrimary}, it means we
+   * attempted to rebuild it, and encountered an error that was then caught in {@link
+   * #rebuildProject} and skipped. As a result, there is no {@code noteDbState} field in the change
+   * by the time we get to {@link #setNoteDbPrimary}, so {@code migrateToNoteDbPrimary} throws an
+   * exception.
+   *
+   * <p>We have to do this hacky double-checking because we don't have a way for the rebuilding
+   * phase to communicate to the primary storage migration phase that the change is skippable. It
+   * would be possible to store this info in some field in this class, but there is no guarantee
+   * that the rebuild and primary storage migration phases are run in the same JVM invocation.
+   *
+   * <p>In an ideal world, we could do this through the {@link
+   * com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage} enum, having a separate value
+   * for errors. However, that would be an invasive change touching many non-migration-related parts
+   * of the NoteDb migration code, which is too risky to attempt in the stable branch where this bug
+   * had to be fixed.
+   *
+   * <p>As of this writing, the only case where this happens is when a change has no patch sets.
+   */
+  private static boolean canSkipPrimaryStorageMigration(ReviewDb db, Change.Id id) {
+    try {
+      return Iterables.isEmpty(unwrapDb(db).patchSets().byChange(id));
+    } catch (Exception e) {
+      log.error("Error checking if change " + id + " can be skipped, assuming no", e);
+      return false;
+    }
+  }
+
+  private NotesMigrationState disableReviewDb(NotesMigrationState prev) throws IOException {
+    return saveState(prev, NOTE_DB, c -> setAutoMigrate(c, false));
+  }
+
+  private Optional<NotesMigrationState> loadState() throws IOException {
+    try {
+      gerritConfig.load();
+      noteDbConfig.load();
+      return NotesMigrationState.forConfig(noteDbConfig);
+    } catch (ConfigInvalidException | IllegalArgumentException e) {
+      log.warn("error reading NoteDb migration options from " + noteDbConfig.getFile(), e);
+      return Optional.empty();
+    }
+  }
+
+  private NotesMigrationState saveState(
+      NotesMigrationState expectedOldState, NotesMigrationState newState) throws IOException {
+    return saveState(expectedOldState, newState, c -> {});
+  }
+
+  private NotesMigrationState saveState(
+      NotesMigrationState expectedOldState,
+      NotesMigrationState newState,
+      Consumer<Config> additionalUpdates)
+      throws IOException {
+    synchronized (globalNotesMigration) {
+      // This read-modify-write is racy. We're counting on the fact that no other Gerrit operation
+      // modifies gerrit.config, and hoping that admins don't either.
+      Optional<NotesMigrationState> actualOldState = loadState();
+      if (!actualOldState.equals(Optional.of(expectedOldState))) {
+        throw new MigrationException(
+            "Cannot move to new state:\n"
+                + newState.toText()
+                + "\n\n"
+                + "Expected this state in gerrit.config:\n"
+                + expectedOldState.toText()
+                + "\n\n"
+                + (actualOldState.isPresent()
+                    ? "But found this state:\n" + actualOldState.get().toText()
+                    : "But could not parse the current state"));
+      }
+
+      preStateChange(expectedOldState, newState);
+
+      newState.setConfigValues(noteDbConfig);
+      additionalUpdates.accept(noteDbConfig);
+      noteDbConfig.save();
+
+      // Only set in-memory state once it's been persisted to storage.
+      globalNotesMigration.setFrom(newState);
+      log.info("Migration state: {} => {}", expectedOldState, newState);
+
+      return newState;
+    }
+  }
+
+  private void preStateChange(NotesMigrationState oldState, NotesMigrationState newState)
+      throws IOException {
+    for (NotesMigrationStateListener listener : listeners) {
+      listener.preStateChange(oldState, newState);
+    }
+  }
+
+  private void setControlFlags() throws MigrationException {
+    synchronized (globalNotesMigration) {
+      try {
+        noteDbConfig.load();
+        setAutoMigrate(noteDbConfig, autoMigrate);
+        setTrialMode(noteDbConfig, trial);
+        noteDbConfig.save();
+      } catch (ConfigInvalidException | IOException e) {
+        throw new MigrationException("Error saving auto-migration config", e);
+      }
+    }
+  }
+
+  public void rebuild() throws MigrationException, OrmException {
+    if (!globalNotesMigration.commitChangeWrites()) {
+      throw new MigrationException("Cannot rebuild without noteDb.changes.write=true");
+    }
+    Stopwatch sw = Stopwatch.createStarted();
+    log.info("Rebuilding changes in NoteDb");
+
+    ImmutableListMultimap<Project.NameKey, Change.Id> changesByProject = getChangesByProject();
+    List<ListenableFuture<Boolean>> futures = new ArrayList<>();
+    try (ContextHelper contextHelper = new ContextHelper()) {
+      List<Project.NameKey> projectNames =
+          Ordering.usingToString().sortedCopy(changesByProject.keySet());
+      for (Project.NameKey project : projectNames) {
+        ListenableFuture<Boolean> future =
+            executor.submit(
+                () -> {
+                  try {
+                    return rebuildProject(contextHelper.getReviewDb(), changesByProject, project);
+                  } catch (Exception e) {
+                    log.error("Error rebuilding project " + project, e);
+                    return false;
+                  }
+                });
+        futures.add(future);
+      }
+
+      boolean ok = futuresToBoolean(futures, "Error rebuilding projects");
+      double t = sw.elapsed(TimeUnit.MILLISECONDS) / 1000d;
+      log.info(
+          String.format(
+              "Rebuilt %d changes in %.01fs (%.01f/s)\n",
+              changesByProject.size(), t, changesByProject.size() / t));
+      if (!ok) {
+        throw new MigrationException("Rebuilding some changes failed, see log");
+      }
+    }
+  }
+
+  private ImmutableListMultimap<Project.NameKey, Change.Id> getChangesByProject()
+      throws OrmException {
+    // Memoize all changes so we can close the db connection and allow other threads to use the full
+    // connection pool.
+    SetMultimap<Project.NameKey, Change.Id> out =
+        MultimapBuilder.treeKeys(comparing(Project.NameKey::get))
+            .treeSetValues(comparing(Change.Id::get))
+            .build();
+    try (ReviewDb db = unwrapDb(schemaFactory.open())) {
+      if (!projects.isEmpty()) {
+        return byProject(db.changes().all(), c -> projects.contains(c.getProject()), out);
+      }
+      if (!changes.isEmpty()) {
+        return byProject(db.changes().get(changes), c -> true, out);
+      }
+      return byProject(db.changes().all(), c -> true, out);
+    }
+  }
+
+  private static ImmutableListMultimap<Project.NameKey, Change.Id> byProject(
+      Iterable<Change> changes,
+      Predicate<Change> pred,
+      SetMultimap<Project.NameKey, Change.Id> out) {
+    Streams.stream(changes).filter(pred).forEach(c -> out.put(c.getProject(), c.getId()));
+    return ImmutableListMultimap.copyOf(out);
+  }
+
+  private static ObjectInserter newPackInserter(Repository repo) {
+    if (!(repo instanceof FileRepository)) {
+      return repo.newObjectInserter();
+    }
+    PackInserter ins = ((FileRepository) repo).getObjectDatabase().newPackInserter();
+    ins.checkExisting(false);
+    return ins;
+  }
+
+  private boolean rebuildProject(
+      ReviewDb db,
+      ImmutableListMultimap<Project.NameKey, Change.Id> allChanges,
+      Project.NameKey project) {
+    checkArgument(allChanges.containsKey(project));
+    boolean ok = true;
+    ProgressMonitor pm =
+        new TextProgressMonitor(
+            new PrintWriter(new BufferedWriter(new OutputStreamWriter(progressOut, UTF_8))));
+    try (Repository changeRepo = repoManager.openRepository(project);
+        // Only use a PackInserter for the change repo, not All-Users.
+        //
+        // It's not possible to share a single inserter for All-Users across all project tasks, and
+        // we don't want to add one pack per project to All-Users. Adding many loose objects is
+        // preferable to many packs.
+        //
+        // Anyway, the number of objects inserted into All-Users is proportional to the number
+        // of pending draft comments, which should not be high (relative to the total number of
+        // changes), so the number of loose objects shouldn't be too unreasonable.
+        ObjectInserter changeIns = newPackInserter(changeRepo);
+        ObjectReader changeReader = changeIns.newReader();
+        RevWalk changeRw = new RevWalk(changeReader);
+        Repository allUsersRepo = repoManager.openRepository(allUsers);
+        ObjectInserter allUsersIns = allUsersRepo.newObjectInserter();
+        ObjectReader allUsersReader = allUsersIns.newReader();
+        RevWalk allUsersRw = new RevWalk(allUsersReader)) {
+      ChainedReceiveCommands changeCmds = new ChainedReceiveCommands(changeRepo);
+      ChainedReceiveCommands allUsersCmds = new ChainedReceiveCommands(allUsersRepo);
+
+      Collection<Change.Id> changes = allChanges.get(project);
+      pm.beginTask(FormatUtil.elide("Rebuilding " + project.get(), 50), changes.size());
+      int toSave = 0;
+      try {
+        for (Change.Id changeId : changes) {
+          // NoteDbUpdateManager assumes that all commands in its OpenRepo were added by itself, so
+          // we can't share the top-level ChainedReceiveCommands. Use a new set of commands sharing
+          // the same underlying repo, and copy commands back to the top-level
+          // ChainedReceiveCommands later. This also assumes that each ref in the final list of
+          // commands was only modified by a single NoteDbUpdateManager; since we use one manager
+          // per change, and each ref corresponds to exactly one change, this assumption should be
+          // safe.
+          ChainedReceiveCommands tmpChangeCmds =
+              new ChainedReceiveCommands(changeCmds.getRepoRefCache());
+          ChainedReceiveCommands tmpAllUsersCmds =
+              new ChainedReceiveCommands(allUsersCmds.getRepoRefCache());
+
+          try (NoteDbUpdateManager manager =
+              updateManagerFactory
+                  .create(project)
+                  .setAtomicRefUpdates(false)
+                  .setSaveObjects(false)
+                  .setChangeRepo(changeRepo, changeRw, changeIns, tmpChangeCmds)
+                  .setAllUsersRepo(allUsersRepo, allUsersRw, allUsersIns, tmpAllUsersCmds)) {
+            rebuild(db, changeId, manager);
+
+            // Executing with dryRun=true writes all objects to the underlying inserters and adds
+            // commands to the ChainedReceiveCommands. Afterwards, we can discard the manager, so we
+            // don't keep using any memory beyond what may be buffered in the PackInserter.
+            manager.execute(true);
+
+            tmpChangeCmds.getCommands().values().forEach(c -> addCommand(changeCmds, c));
+            tmpAllUsersCmds.getCommands().values().forEach(c -> addCommand(allUsersCmds, c));
+
+            toSave++;
+          } catch (NoPatchSetsException e) {
+            log.warn(e.getMessage());
+          } catch (ConflictingUpdateException ex) {
+            log.warn(
+                "Rebuilding detected a conflicting ReviewDb update for change {};"
+                    + " will be auto-rebuilt at runtime",
+                changeId);
+          } catch (Throwable t) {
+            log.error("Failed to rebuild change " + changeId, t);
+            ok = false;
+          }
+          pm.update(1);
+        }
+      } finally {
+        pm.endTask();
+      }
+
+      pm.beginTask(FormatUtil.elide("Saving " + project.get(), 50), ProgressMonitor.UNKNOWN);
+      try {
+        save(changeRepo, changeRw, changeIns, changeCmds);
+        save(allUsersRepo, allUsersRw, allUsersIns, allUsersCmds);
+        // This isn't really useful progress. If we passed a real ProgressMonitor to
+        // BatchRefUpdate#execute we might get something more incremental, but that doesn't allow us
+        // to specify the repo name in the task text.
+        pm.update(toSave);
+      } catch (LockFailureException e) {
+        log.warn(
+            "Rebuilding detected a conflicting NoteDb update for the following refs, which will"
+                + " be auto-rebuilt at runtime: {}",
+            e.getFailedRefs().stream().distinct().sorted().collect(joining(", ")));
+      } catch (IOException e) {
+        log.error("Failed to save NoteDb state for " + project, e);
+      } finally {
+        pm.endTask();
+      }
+    } catch (RepositoryNotFoundException e) {
+      log.warn("Repository {} not found", project);
+    } catch (IOException e) {
+      log.error("Failed to rebuild project " + project, e);
+    }
+    return ok;
+  }
+
+  private void rebuild(ReviewDb db, Change.Id changeId, NoteDbUpdateManager manager)
+      throws OrmException, IOException {
+    // Match ChangeRebuilderImpl#stage, but without calling manager.stage(), since that can only be
+    // called after building updates for all changes.
+    Change change =
+        ChangeRebuilderImpl.checkNoteDbState(ChangeNotes.readOneReviewDbChange(db, changeId));
+    if (change == null) {
+      // Could log here instead, but this matches the behavior of ChangeRebuilderImpl#stage.
+      throw new NoSuchChangeException(changeId);
+    }
+    rebuilder.buildUpdates(manager, bundleReader.fromReviewDb(db, changeId));
+
+    rebuilder.execute(db, changeId, manager, true, false);
+  }
+
+  private static void addCommand(ChainedReceiveCommands cmds, ReceiveCommand cmd) {
+    // ChainedReceiveCommands doesn't allow no-ops, but these occur when rebuilding a
+    // previously-rebuilt change.
+    if (!cmd.getOldId().equals(cmd.getNewId())) {
+      cmds.add(cmd);
+    }
+  }
+
+  private void save(Repository repo, RevWalk rw, ObjectInserter ins, ChainedReceiveCommands cmds)
+      throws IOException {
+    if (cmds.isEmpty()) {
+      return;
+    }
+    ins.flush();
+    BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+    bru.setRefLogMessage("Migrate changes to NoteDb", false);
+    bru.setRefLogIdent(serverIdent.get());
+    bru.setAtomic(false);
+    bru.setAllowNonFastForwards(true);
+    cmds.addTo(bru);
+    RefUpdateUtil.executeChecked(bru, rw);
+  }
+
+  private static boolean futuresToBoolean(List<ListenableFuture<Boolean>> futures, String errMsg) {
+    try {
+      return Futures.allAsList(futures).get().stream().allMatch(b -> b);
+    } catch (InterruptedException | ExecutionException e) {
+      log.error(errMsg, e);
+      return false;
+    }
+  }
+
+  private class ContextHelper implements AutoCloseable {
+    private final Thread callingThread;
+    private ReviewDb db;
+    private Runnable closeDb;
+
+    ContextHelper() {
+      callingThread = Thread.currentThread();
+    }
+
+    ManualRequestContext open() throws OrmException {
+      return new ManualRequestContext(
+          userFactory.create(),
+          // Reuse the same lazily-opened ReviewDb on the original calling thread, otherwise open
+          // SchemaFactory in the normal way.
+          Thread.currentThread().equals(callingThread) ? this::getReviewDb : schemaFactory,
+          requestContext);
+    }
+
+    synchronized ReviewDb getReviewDb() throws OrmException {
+      if (db == null) {
+        ReviewDb actual = schemaFactory.open();
+        closeDb = actual::close;
+        db =
+            new ReviewDbWrapper(unwrapDb(actual)) {
+              @Override
+              public void close() {
+                // Closed by ContextHelper#close.
+              }
+            };
+      }
+      return db;
+    }
+
+    @Override
+    public synchronized void close() {
+      if (db != null) {
+        closeDb.run();
+        db = null;
+        closeDb = null;
+      }
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NotesMigrationStateListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NotesMigrationStateListener.java
new file mode 100644
index 0000000..aef30a2
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/NotesMigrationStateListener.java
@@ -0,0 +1,35 @@
+// 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.notedb.rebuild;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.server.notedb.NotesMigrationState;
+import java.io.IOException;
+
+/** Listener for state changes performed by {@link OnlineNoteDbMigrator}. */
+@ExtensionPoint
+public interface NotesMigrationStateListener {
+  /**
+   * Invoked just before saving the new migration state.
+   *
+   * @param oldState state prior to this state change.
+   * @param newState state after this state change.
+   * @throws IOException if an error occurred, which will cause the migration to abort. Exceptions
+   *     that should be considered non-fatal must be caught (and ideally logged) by the
+   *     implementation rather than thrown.
+   */
+  void preStateChange(NotesMigrationState oldState, NotesMigrationState newState)
+      throws IOException;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/OnlineNoteDbMigrator.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/OnlineNoteDbMigrator.java
new file mode 100644
index 0000000..65755ed
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/OnlineNoteDbMigrator.java
@@ -0,0 +1,108 @@
+// 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.notedb.rebuild;
+
+import com.google.common.base.Stopwatch;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.OnlineUpgrader;
+import com.google.gerrit.server.index.VersionManager;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import com.google.inject.name.Named;
+import com.google.inject.name.Names;
+import java.util.concurrent.TimeUnit;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+@Singleton
+public class OnlineNoteDbMigrator implements LifecycleListener {
+  private static final Logger log = LoggerFactory.getLogger(OnlineNoteDbMigrator.class);
+
+  private static final String TRIAL = "OnlineNoteDbMigrator/trial";
+
+  public static class Module extends LifecycleModule {
+    private final boolean trial;
+
+    public Module(boolean trial) {
+      this.trial = trial;
+    }
+
+    @Override
+    public void configure() {
+      listener().to(OnlineNoteDbMigrator.class);
+      bindConstant().annotatedWith(Names.named(TRIAL)).to(trial);
+    }
+  }
+
+  private final GcAllUsers gcAllUsers;
+  private final OnlineUpgrader indexUpgrader;
+  private final Provider<NoteDbMigrator.Builder> migratorBuilderProvider;
+  private final boolean upgradeIndex;
+  private final boolean trial;
+
+  @Inject
+  OnlineNoteDbMigrator(
+      @GerritServerConfig Config cfg,
+      GcAllUsers gcAllUsers,
+      OnlineUpgrader indexUpgrader,
+      Provider<NoteDbMigrator.Builder> migratorBuilderProvider,
+      @Named(TRIAL) boolean trial) {
+    this.gcAllUsers = gcAllUsers;
+    this.indexUpgrader = indexUpgrader;
+    this.migratorBuilderProvider = migratorBuilderProvider;
+    this.upgradeIndex = VersionManager.getOnlineUpgrade(cfg);
+    this.trial = trial || NoteDbMigrator.getTrialMode(cfg);
+  }
+
+  @Override
+  public void start() {
+    Thread t = new Thread(this::migrate);
+    t.setDaemon(true);
+    t.setName(getClass().getSimpleName());
+    t.start();
+  }
+
+  private void migrate() {
+    log.info("Starting online NoteDb migration");
+    if (upgradeIndex) {
+      log.info("Online index schema upgrades will be deferred until NoteDb migration is complete");
+    }
+    Stopwatch sw = Stopwatch.createStarted();
+    // TODO(dborowitz): Tune threads, maybe expose a progress monitor somewhere.
+    try (NoteDbMigrator migrator =
+        migratorBuilderProvider.get().setAutoMigrate(true).setTrialMode(trial).build()) {
+      migrator.migrate();
+    } catch (Exception e) {
+      log.error("Error in online NoteDb migration", e);
+    }
+    gcAllUsers.runWithLogger();
+    log.info("Online NoteDb migration completed in {}s", sw.elapsed(TimeUnit.SECONDS));
+
+    if (upgradeIndex) {
+      log.info("Starting deferred index schema upgrades");
+      indexUpgrader.start();
+    }
+  }
+
+  @Override
+  public void stop() {
+    // Do nothing; upgrade process uses daemon threads and knows how to recover from failures on
+    // next attempt.
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
index e0ad640..acb80c0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
@@ -17,7 +17,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.notedb.PatchSetState;
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
 import java.util.List;
@@ -65,9 +64,6 @@
     if (!groups.isEmpty()) {
       update.setGroups(ps.getGroups());
     }
-    if (ps.isDraft()) {
-      update.setPatchSetState(PatchSetState.DRAFT);
-    }
   }
 
   private void setRevision(ChangeUpdate update, PatchSet ps) throws IOException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
index 74a3132..19568cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/AutoMerger.java
@@ -77,7 +77,7 @@
   public RevCommit merge(
       Repository repo,
       RevWalk rw,
-      final ObjectInserter ins,
+      ObjectInserter ins,
       RevCommit merge,
       ThreeWayMergeStrategy mergeStrategy)
       throws IOException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java
index bfa7ec3..8ce4dd3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryKey.java
@@ -61,7 +61,7 @@
   }
 
   @Override
-  public boolean equals(final Object o) {
+  public boolean equals(Object o) {
     if (o instanceof DiffSummaryKey) {
       DiffSummaryKey k = (DiffSummaryKey) o;
       return Objects.equals(oldId, k.oldId)
@@ -89,7 +89,7 @@
     return n.toString();
   }
 
-  private void writeObject(final ObjectOutputStream out) throws IOException {
+  private void writeObject(ObjectOutputStream out) throws IOException {
     writeCanBeNull(out, oldId);
     out.writeInt(parentNum == null ? 0 : parentNum);
     writeNotNull(out, newId);
@@ -100,7 +100,7 @@
     out.writeChar(c);
   }
 
-  private void readObject(final ObjectInputStream in) throws IOException {
+  private void readObject(ObjectInputStream in) throws IOException {
     oldId = readCanBeNull(in);
     int n = in.readInt();
     parentNum = n == 0 ? null : Integer.valueOf(n);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
index f4e3d6b..8bca19f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/DiffSummaryLoader.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
@@ -32,7 +32,7 @@
   private final DiffSummaryKey key;
   private final Project.NameKey project;
 
-  @AssistedInject
+  @Inject
   DiffSummaryLoader(PatchListCache plc, @Assisted DiffSummaryKey k, @Assisted Project.NameKey p) {
     patchListCache = plc;
     key = k;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/EditTransformer.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/EditTransformer.java
new file mode 100644
index 0000000..9083ede
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/EditTransformer.java
@@ -0,0 +1,313 @@
+// 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.patch;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.Multimaps.toMultimap;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.groupingBy;
+import static java.util.stream.Collectors.toList;
+
+import com.google.auto.value.AutoValue;
+import com.google.common.base.MoreObjects;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Multimap;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.function.Function;
+import java.util.stream.Stream;
+import org.eclipse.jgit.diff.Edit;
+
+/**
+ * Transformer of edits regarding their base trees. An edit describes a difference between {@code
+ * treeA} and {@code treeB}. This class allows to describe the edit as a difference between {@code
+ * treeA'} and {@code treeB'} given the transformation of {@code treeA} to {@code treeA'} and {@code
+ * treeB} to {@code treeB'}. Edits which can't be transformed due to conflicts with the
+ * transformation are omitted.
+ */
+class EditTransformer {
+
+  private List<ContextAwareEdit> edits;
+
+  /**
+   * Creates a new {@code EditTransformer} for the edits contained in the specified {@code
+   * PatchListEntry}s.
+   *
+   * @param patchListEntries a list of {@code PatchListEntry}s containing the edits
+   */
+  public EditTransformer(List<PatchListEntry> patchListEntries) {
+    edits = patchListEntries.stream().flatMap(EditTransformer::toEdits).collect(toImmutableList());
+  }
+
+  /**
+   * Transforms the references of side A of the edits. If the edits describe differences between
+   * {@code treeA} and {@code treeB} and the specified {@code PatchListEntry}s define a
+   * transformation from {@code treeA} to {@code treeA'}, the resulting edits will be defined as
+   * differences between {@code treeA'} and {@code treeB}. Edits which can't be transformed due to
+   * conflicts with the transformation are omitted.
+   *
+   * @param transformationEntries a list of {@code PatchListEntry}s defining the transformation of
+   *     {@code treeA} to {@code treeA'}
+   */
+  public void transformReferencesOfSideA(List<PatchListEntry> transformationEntries) {
+    transformEdits(transformationEntries, SideAStrategy.INSTANCE);
+  }
+
+  /**
+   * Transforms the references of side B of the edits. If the edits describe differences between
+   * {@code treeA} and {@code treeB} and the specified {@code PatchListEntry}s define a
+   * transformation from {@code treeB} to {@code treeB'}, the resulting edits will be defined as
+   * differences between {@code treeA} and {@code treeB'}. Edits which can't be transformed due to
+   * conflicts with the transformation are omitted.
+   *
+   * @param transformationEntries a list of {@code PatchListEntry}s defining the transformation of
+   *     {@code treeB} to {@code treeB'}
+   */
+  public void transformReferencesOfSideB(List<PatchListEntry> transformationEntries) {
+    transformEdits(transformationEntries, SideBStrategy.INSTANCE);
+  }
+
+  /**
+   * Returns the transformed edits per file path they modify in {@code treeB'}.
+   *
+   * @return the transformed edits per file path
+   */
+  public Multimap<String, ContextAwareEdit> getEditsPerFilePath() {
+    return edits
+        .stream()
+        .collect(
+            toMultimap(
+                ContextAwareEdit::getNewFilePath, Function.identity(), ArrayListMultimap::create));
+  }
+
+  public static Stream<ContextAwareEdit> toEdits(PatchListEntry patchListEntry) {
+    ImmutableList<Edit> edits = patchListEntry.getEdits();
+    if (edits.isEmpty()) {
+      return Stream.of(ContextAwareEdit.createForNoContentEdit(patchListEntry));
+    }
+
+    return edits.stream().map(edit -> ContextAwareEdit.create(patchListEntry, edit));
+  }
+
+  private void transformEdits(List<PatchListEntry> transformingEntries, SideStrategy sideStrategy) {
+    Map<String, List<ContextAwareEdit>> editsPerFilePath =
+        edits.stream().collect(groupingBy(sideStrategy::getFilePath));
+    Map<String, List<PatchListEntry>> transEntriesPerPath =
+        transformingEntries.stream().collect(groupingBy(EditTransformer::getOldFilePath));
+
+    edits =
+        editsPerFilePath
+            .entrySet()
+            .stream()
+            .flatMap(
+                pathAndEdits -> {
+                  List<PatchListEntry> transEntries =
+                      transEntriesPerPath.getOrDefault(pathAndEdits.getKey(), ImmutableList.of());
+                  return transformEdits(sideStrategy, pathAndEdits.getValue(), transEntries);
+                })
+            .collect(toList());
+  }
+
+  private static String getOldFilePath(PatchListEntry patchListEntry) {
+    return MoreObjects.firstNonNull(patchListEntry.getOldName(), patchListEntry.getNewName());
+  }
+
+  private static Stream<ContextAwareEdit> transformEdits(
+      SideStrategy sideStrategy,
+      List<ContextAwareEdit> originalEdits,
+      List<PatchListEntry> transformingEntries) {
+    if (transformingEntries.isEmpty()) {
+      return originalEdits.stream();
+    }
+
+    // TODO(aliceks): Find a way to prevent an explosion of the number of entries.
+    return transformingEntries
+        .stream()
+        .flatMap(
+            transEntry ->
+                transformEdits(
+                        sideStrategy, originalEdits, transEntry.getEdits(), transEntry.getNewName())
+                    .stream());
+  }
+
+  private static List<ContextAwareEdit> transformEdits(
+      SideStrategy sideStrategy,
+      List<ContextAwareEdit> unorderedOriginalEdits,
+      List<Edit> unorderedTransformingEdits,
+      String adjustedFilePath) {
+    List<ContextAwareEdit> originalEdits = new ArrayList<>(unorderedOriginalEdits);
+    originalEdits.sort(comparing(sideStrategy::getBegin).thenComparing(sideStrategy::getEnd));
+    List<Edit> transformingEdits = new ArrayList<>(unorderedTransformingEdits);
+    transformingEdits.sort(comparing(Edit::getBeginA).thenComparing(Edit::getEndA));
+
+    int shiftedAmount = 0;
+    int transIndex = 0;
+    int origIndex = 0;
+    List<ContextAwareEdit> resultingEdits = new ArrayList<>(originalEdits.size());
+    while (origIndex < originalEdits.size() && transIndex < transformingEdits.size()) {
+      ContextAwareEdit originalEdit = originalEdits.get(origIndex);
+      Edit transformingEdit = transformingEdits.get(transIndex);
+      if (transformingEdit.getEndA() <= sideStrategy.getBegin(originalEdit)) {
+        shiftedAmount = transformingEdit.getEndB() - transformingEdit.getEndA();
+        transIndex++;
+      } else if (sideStrategy.getEnd(originalEdit) <= transformingEdit.getBeginA()) {
+        resultingEdits.add(sideStrategy.create(originalEdit, shiftedAmount, adjustedFilePath));
+        origIndex++;
+      } else {
+        // Overlapping -> ignore.
+        origIndex++;
+      }
+    }
+    for (int i = origIndex; i < originalEdits.size(); i++) {
+      resultingEdits.add(
+          sideStrategy.create(originalEdits.get(i), shiftedAmount, adjustedFilePath));
+    }
+    return resultingEdits;
+  }
+
+  @AutoValue
+  abstract static class ContextAwareEdit {
+    static ContextAwareEdit create(PatchListEntry patchListEntry, Edit edit) {
+      return create(
+          patchListEntry.getOldName(),
+          patchListEntry.getNewName(),
+          edit.getBeginA(),
+          edit.getEndA(),
+          edit.getBeginB(),
+          edit.getEndB(),
+          false);
+    }
+
+    static ContextAwareEdit createForNoContentEdit(PatchListEntry patchListEntry) {
+      return create(
+          patchListEntry.getOldName(), patchListEntry.getNewName(), -1, -1, -1, -1, false);
+    }
+
+    static ContextAwareEdit create(
+        String oldFilePath,
+        String newFilePath,
+        int beginA,
+        int endA,
+        int beginB,
+        int endB,
+        boolean filePathAdjusted) {
+      String adjustedOldFilePath = MoreObjects.firstNonNull(oldFilePath, newFilePath);
+      boolean implicitRename = !Objects.equals(oldFilePath, newFilePath) && filePathAdjusted;
+      return new AutoValue_EditTransformer_ContextAwareEdit(
+          adjustedOldFilePath, newFilePath, beginA, endA, beginB, endB, implicitRename);
+    }
+
+    public abstract String getOldFilePath();
+
+    public abstract String getNewFilePath();
+
+    public abstract int getBeginA();
+
+    public abstract int getEndA();
+
+    public abstract int getBeginB();
+
+    public abstract int getEndB();
+
+    // Used for equals(), for which this value is important.
+    public abstract boolean isImplicitRename();
+
+    public Optional<Edit> toEdit() {
+      if (getBeginA() < 0) {
+        return Optional.empty();
+      }
+
+      return Optional.of(new Edit(getBeginA(), getEndA(), getBeginB(), getEndB()));
+    }
+  }
+
+  private interface SideStrategy {
+    String getFilePath(ContextAwareEdit edit);
+
+    int getBegin(ContextAwareEdit edit);
+
+    int getEnd(ContextAwareEdit edit);
+
+    ContextAwareEdit create(ContextAwareEdit edit, int shiftedAmount, String adjustedFilePath);
+  }
+
+  private enum SideAStrategy implements SideStrategy {
+    INSTANCE;
+
+    @Override
+    public String getFilePath(ContextAwareEdit edit) {
+      return edit.getOldFilePath();
+    }
+
+    @Override
+    public int getBegin(ContextAwareEdit edit) {
+      return edit.getBeginA();
+    }
+
+    @Override
+    public int getEnd(ContextAwareEdit edit) {
+      return edit.getEndA();
+    }
+
+    @Override
+    public ContextAwareEdit create(
+        ContextAwareEdit edit, int shiftedAmount, String adjustedFilePath) {
+      return ContextAwareEdit.create(
+          adjustedFilePath,
+          edit.getNewFilePath(),
+          edit.getBeginA() + shiftedAmount,
+          edit.getEndA() + shiftedAmount,
+          edit.getBeginB(),
+          edit.getEndB(),
+          !Objects.equals(edit.getOldFilePath(), adjustedFilePath));
+    }
+  }
+
+  private enum SideBStrategy implements SideStrategy {
+    INSTANCE;
+
+    @Override
+    public String getFilePath(ContextAwareEdit edit) {
+      return edit.getNewFilePath();
+    }
+
+    @Override
+    public int getBegin(ContextAwareEdit edit) {
+      return edit.getBeginB();
+    }
+
+    @Override
+    public int getEnd(ContextAwareEdit edit) {
+      return edit.getEndB();
+    }
+
+    @Override
+    public ContextAwareEdit create(
+        ContextAwareEdit edit, int shiftedAmount, String adjustedFilePath) {
+      return ContextAwareEdit.create(
+          edit.getOldFilePath(),
+          adjustedFilePath,
+          edit.getBeginA(),
+          edit.getEndA(),
+          edit.getBeginB() + shiftedAmount,
+          edit.getEndB() + shiftedAmount,
+          !Objects.equals(edit.getNewFilePath(), adjustedFilePath));
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java
index e51b4ab..a182335 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiff.java
@@ -18,7 +18,9 @@
 import static com.google.gerrit.server.ioutil.BasicSerialization.readVarInt32;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeEnum;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
+import static java.util.stream.Collectors.toList;
 
+import com.google.common.collect.ImmutableList;
 import com.google.gerrit.reviewdb.client.CodedEnum;
 import java.io.IOException;
 import java.io.InputStream;
@@ -54,27 +56,29 @@
   }
 
   private transient Status status;
-  private transient List<Edit> edits;
+  private transient ImmutableList<Edit> edits;
 
   IntraLineDiff(Status status) {
     this.status = status;
-    this.edits = Collections.emptyList();
+    this.edits = ImmutableList.of();
   }
 
   IntraLineDiff(List<Edit> edits) {
     this.status = Status.EDIT_LIST;
-    this.edits = Collections.unmodifiableList(edits);
+    this.edits = ImmutableList.copyOf(edits);
   }
 
   public Status getStatus() {
     return status;
   }
 
-  public List<Edit> getEdits() {
-    return edits;
+  public ImmutableList<Edit> getEdits() {
+    // Edits are mutable objects. As we serialize IntraLineDiff asynchronously in H2CacheImpl, we
+    // must ensure that its state isn't modified until it was properly stored in the cache.
+    return deepCopyEdits(edits);
   }
 
-  private void writeObject(final ObjectOutputStream out) throws IOException {
+  private void writeObject(ObjectOutputStream out) throws IOException {
     writeEnum(out, status);
     writeVarInt32(out, edits.size());
     for (Edit e : edits) {
@@ -92,7 +96,7 @@
     }
   }
 
-  private void readObject(final ObjectInputStream in) throws IOException {
+  private void readObject(ObjectInputStream in) throws IOException {
     status = readEnum(in, Status.values());
     int editCount = readVarInt32(in);
     Edit[] editArray = new Edit[editCount];
@@ -105,10 +109,28 @@
         for (int j = 0; j < innerCount; j++) {
           inner[j] = readEdit(in);
         }
-        editArray[i] = new ReplaceEdit(editArray[i], toList(inner));
+        editArray[i] = new ReplaceEdit(editArray[i], asList(inner));
       }
     }
-    edits = toList(editArray);
+    edits = ImmutableList.copyOf(editArray);
+  }
+
+  private static ImmutableList<Edit> deepCopyEdits(List<Edit> edits) {
+    return edits.stream().map(IntraLineDiff::copy).collect(ImmutableList.toImmutableList());
+  }
+
+  private static Edit copy(Edit edit) {
+    if (edit instanceof ReplaceEdit) {
+      return copy((ReplaceEdit) edit);
+    }
+    return new Edit(edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB());
+  }
+
+  private static ReplaceEdit copy(ReplaceEdit edit) {
+    List<Edit> internalEdits =
+        edit.getInternalEdits().stream().map(IntraLineDiff::copy).collect(toList());
+    return new ReplaceEdit(
+        edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB(), internalEdits);
   }
 
   private static void writeEdit(OutputStream out, Edit e) throws IOException {
@@ -126,7 +148,7 @@
     return new Edit(beginA, endA, beginB, endB);
   }
 
-  private static List<Edit> toList(Edit[] l) {
+  private static List<Edit> asList(Edit[] l) {
     return Collections.unmodifiableList(Arrays.asList(l));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
index 46ee56a..4661485 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffArgs.java
@@ -15,8 +15,11 @@
 package com.google.gerrit.server.patch;
 
 import com.google.auto.value.AutoValue;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Project;
 import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.lib.ObjectId;
 
@@ -26,17 +29,33 @@
       Text aText,
       Text bText,
       List<Edit> edits,
+      Set<Edit> editsDueToRebase,
       Project.NameKey project,
       ObjectId commit,
       String path) {
-    return new AutoValue_IntraLineDiffArgs(aText, bText, edits, project, commit, path);
+    return new AutoValue_IntraLineDiffArgs(
+        aText, bText, deepCopyEdits(edits), deepCopyEdits(editsDueToRebase), project, commit, path);
+  }
+
+  private static ImmutableList<Edit> deepCopyEdits(List<Edit> edits) {
+    return edits.stream().map(IntraLineDiffArgs::copy).collect(ImmutableList.toImmutableList());
+  }
+
+  private static ImmutableSet<Edit> deepCopyEdits(Set<Edit> edits) {
+    return edits.stream().map(IntraLineDiffArgs::copy).collect(ImmutableSet.toImmutableSet());
+  }
+
+  private static Edit copy(Edit edit) {
+    return new Edit(edit.getBeginA(), edit.getEndA(), edit.getBeginB(), edit.getEndB());
   }
 
   public abstract Text aText();
 
   public abstract Text bText();
 
-  public abstract List<Edit> edits();
+  public abstract ImmutableList<Edit> edits();
+
+  public abstract ImmutableSet<Edit> editsDueToRebase();
 
   public abstract Project.NameKey project();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
index ed58408..06e2c45 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineDiffKey.java
@@ -21,7 +21,7 @@
 
 @AutoValue
 public abstract class IntraLineDiffKey implements Serializable {
-  public static final long serialVersionUID = 5L;
+  public static final long serialVersionUID = 12L;
 
   public static IntraLineDiffKey create(ObjectId aId, ObjectId bId, Whitespace whitespace) {
     return new AutoValue_IntraLineDiffKey(aId, bId, whitespace);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
index a571c46..f17f0b6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/IntraLineLoader.java
@@ -16,10 +16,13 @@
 package com.google.gerrit.server.patch;
 
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.server.config.ConfigUtil;
 import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
+import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -52,7 +55,7 @@
   private final IntraLineDiffKey key;
   private final IntraLineDiffArgs args;
 
-  @AssistedInject
+  @Inject
   IntraLineLoader(
       @DiffExecutor ExecutorService diffExecutor,
       @GerritServerConfig Config cfg,
@@ -75,12 +78,9 @@
   public IntraLineDiff call() throws Exception {
     Future<IntraLineDiff> result =
         diffExecutor.submit(
-            new Callable<IntraLineDiff>() {
-              @Override
-              public IntraLineDiff call() throws Exception {
-                return IntraLineLoader.compute(args.aText(), args.bText(), args.edits());
-              }
-            });
+            () ->
+                IntraLineLoader.compute(
+                    args.aText(), args.bText(), args.edits(), args.editsDueToRebase()));
     try {
       return result.get(timeoutMillis, TimeUnit.MILLISECONDS);
     } catch (InterruptedException | TimeoutException e) {
@@ -107,8 +107,13 @@
     }
   }
 
-  static IntraLineDiff compute(Text aText, Text bText, List<Edit> edits) throws Exception {
-    combineLineEdits(edits, aText, bText);
+  static IntraLineDiff compute(
+      Text aText,
+      Text bText,
+      ImmutableList<Edit> immutableEdits,
+      ImmutableSet<Edit> immutableEditsDueToRebase) {
+    List<Edit> edits = new ArrayList<>(immutableEdits);
+    combineLineEdits(edits, immutableEditsDueToRebase, aText, bText);
 
     for (int i = 0; i < edits.size(); i++) {
       Edit e = edits.get(i);
@@ -257,11 +262,19 @@
     return new IntraLineDiff(edits);
   }
 
-  private static void combineLineEdits(List<Edit> edits, Text a, Text b) {
+  private static void combineLineEdits(
+      List<Edit> edits, ImmutableSet<Edit> editsDueToRebase, Text a, Text b) {
     for (int j = 0; j < edits.size() - 1; ) {
       Edit c = edits.get(j);
       Edit n = edits.get(j + 1);
 
+      if (editsDueToRebase.contains(c) || editsDueToRebase.contains(n)) {
+        // Don't combine any edits which were identified as being introduced by a rebase as we would
+        // lose that information because of the combination.
+        j++;
+        continue;
+      }
+
       // Combine edits that are really close together. Right now our rule
       // is, coalesce two line edits which are only one line apart if that
       // common context line is either a "pointless line", or is identical
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
index b4c2fbe..aff519a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchFile.java
@@ -96,7 +96,7 @@
    * @throws IOException the patch or complete file content cannot be read.
    * @throws NoSuchEntityException
    */
-  public String getLine(final int file, final int line) throws IOException, NoSuchEntityException {
+  public String getLine(int file, int line) throws IOException, NoSuchEntityException {
     switch (file) {
       case 0:
         if (a == null) {
@@ -123,7 +123,7 @@
    * @throws IOException the patch or complete file content cannot be read.
    * @throws NoSuchEntityException the file is not exist.
    */
-  public int getLineCount(final int file) throws IOException, NoSuchEntityException {
+  public int getLineCount(int file) throws IOException, NoSuchEntityException {
     switch (file) {
       case 0:
         if (a == null) {
@@ -142,7 +142,7 @@
     }
   }
 
-  private Text load(final ObjectId tree, final String path)
+  private Text load(ObjectId tree, String path)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           IOException {
     if (path == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
index 020c354..16ede58 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchList.java
@@ -49,7 +49,7 @@
   private static final Comparator<PatchListEntry> PATCH_CMP =
       new Comparator<PatchListEntry>() {
         @Override
-        public int compare(final PatchListEntry a, final PatchListEntry b) {
+        public int compare(PatchListEntry a, PatchListEntry b) {
           return comparePaths(a.getNewName(), b.getNewName());
         }
       };
@@ -151,26 +151,26 @@
    *     specified, but is a current legacy artifact of how the cache is keyed versus how the
    *     database is keyed.
    */
-  public List<Patch> toPatchList(final PatchSet.Id setId) {
+  public List<Patch> toPatchList(PatchSet.Id setId) {
     final ArrayList<Patch> r = new ArrayList<>(patches.length);
-    for (final PatchListEntry e : patches) {
+    for (PatchListEntry e : patches) {
       r.add(e.toPatch(setId));
     }
     return r;
   }
 
   /** Find an entry by name, returning an empty entry if not present. */
-  public PatchListEntry get(final String fileName) {
+  public PatchListEntry get(String fileName) {
     final int index = search(fileName);
     return 0 <= index ? patches[index] : PatchListEntry.empty(fileName);
   }
 
-  private int search(final String fileName) {
+  private int search(String fileName) {
     PatchListEntry want = PatchListEntry.empty(fileName);
     return Arrays.binarySearch(patches, 0, patches.length, want, PATCH_CMP);
   }
 
-  private void writeObject(final ObjectOutputStream output) throws IOException {
+  private void writeObject(ObjectOutputStream output) throws IOException {
     final ByteArrayOutputStream buf = new ByteArrayOutputStream();
     try (DeflaterOutputStream out = new DeflaterOutputStream(buf)) {
       writeCanBeNull(out, oldId);
@@ -187,7 +187,7 @@
     writeBytes(output, buf.toByteArray());
   }
 
-  private void readObject(final ObjectInputStream input) throws IOException {
+  private void readObject(ObjectInputStream input) throws IOException {
     final ByteArrayInputStream buf = new ByteArrayInputStream(readBytes(input));
     try (InflaterInputStream in = new InflaterInputStream(buf)) {
       oldId = readCanBeNull(in);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
index c32a3f6..728d227 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCache.java
@@ -30,9 +30,6 @@
 
   IntraLineDiff getIntraLineDiff(IntraLineDiffKey key, IntraLineDiffArgs args);
 
-  DiffSummary getDiffSummary(Change change, PatchSet patchSet)
-      throws PatchListNotAvailableException;
-
   DiffSummary getDiffSummary(DiffSummaryKey key, Project.NameKey project)
       throws PatchListNotAvailableException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
index dc76c63..01c8b41 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListCacheImpl.java
@@ -15,6 +15,7 @@
 
 package com.google.gerrit.server.patch;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.cache.Cache;
 import com.google.common.util.concurrent.UncheckedExecutionException;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
@@ -99,12 +100,19 @@
       throws PatchListNotAvailableException {
     try {
       PatchList pl = fileCache.get(key, fileLoaderFactory.create(key, project));
+      if (pl instanceof LargeObjectTombstone) {
+        throw new PatchListObjectTooLargeException(
+            "Error computing " + key + ". Previous attempt failed with LargeObjectException");
+      }
       return pl;
     } catch (ExecutionException e) {
       PatchListLoader.log.warn("Error computing " + key, e);
       throw new PatchListNotAvailableException(e);
     } catch (UncheckedExecutionException e) {
       if (e.getCause() instanceof LargeObjectException) {
+        // Cache negative result so we don't need to redo expensive computations that would yield
+        // the same result.
+        fileCache.put(key, new LargeObjectTombstone());
         PatchListLoader.log.warn("Error computing " + key, e);
         throw new PatchListNotAvailableException(e);
       }
@@ -151,16 +159,6 @@
   }
 
   @Override
-  public DiffSummary getDiffSummary(Change change, PatchSet patchSet)
-      throws PatchListNotAvailableException {
-    Project.NameKey project = change.getProject();
-    ObjectId b = ObjectId.fromString(patchSet.getRevision().get());
-    Whitespace ws = Whitespace.IGNORE_NONE;
-    return getDiffSummary(
-        DiffSummaryKey.fromPatchListKey(PatchListKey.againstDefaultBase(b, ws)), project);
-  }
-
-  @Override
   public DiffSummary getDiffSummary(DiffSummaryKey key, Project.NameKey project)
       throws PatchListNotAvailableException {
     try {
@@ -176,4 +174,18 @@
       throw e;
     }
   }
+
+  /** Used to cache negative results in {@code fileCache}. */
+  @VisibleForTesting
+  public static class LargeObjectTombstone extends PatchList {
+    private static final long serialVersionUID = 1L;
+
+    @VisibleForTesting
+    public LargeObjectTombstone() {
+      // Initialize super class with valid values. We don't care about the inner state, but need to
+      // pass valid values that don't break (de)serialization.
+      super(
+          null, ObjectId.zeroId(), false, ComparisonType.againstAutoMerge(), new PatchListEntry[0]);
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
index a8a8b79..96f66f6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListEntry.java
@@ -25,6 +25,8 @@
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeString;
 import static com.google.gerrit.server.ioutil.BasicSerialization.writeVarInt32;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Patch.ChangeType;
 import com.google.gerrit.reviewdb.client.Patch.PatchType;
@@ -33,9 +35,9 @@
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
+import java.util.Collection;
 import java.util.List;
+import java.util.Set;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.patch.CombinedFileHeader;
@@ -46,14 +48,15 @@
 public class PatchListEntry {
   private static final byte[] EMPTY_HEADER = {};
 
-  static PatchListEntry empty(final String fileName) {
+  static PatchListEntry empty(String fileName) {
     return new PatchListEntry(
         ChangeType.MODIFIED,
         PatchType.UNIFIED,
         null,
         fileName,
         EMPTY_HEADER,
-        Collections.<Edit>emptyList(),
+        ImmutableList.of(),
+        ImmutableSet.of(),
         0,
         0,
         0,
@@ -65,7 +68,8 @@
   private final String oldName;
   private final String newName;
   private final byte[] header;
-  private final List<Edit> edits;
+  private final ImmutableList<Edit> edits;
+  private final ImmutableSet<Edit> editsDueToRebase;
   private final int insertions;
   private final int deletions;
   private final long size;
@@ -73,7 +77,8 @@
   // Note: When adding new fields, the serialVersionUID in PatchListKey must be
   // incremented so that entries from the cache are automatically invalidated.
 
-  PatchListEntry(FileHeader hdr, List<Edit> editList, long size, long sizeDelta) {
+  PatchListEntry(
+      FileHeader hdr, List<Edit> editList, Set<Edit> editsDueToRebase, long size, long sizeDelta) {
     changeType = toChangeType(hdr);
     patchType = toPatchType(hdr);
 
@@ -103,16 +108,19 @@
     header = compact(hdr);
 
     if (hdr instanceof CombinedFileHeader || hdr.getHunks().isEmpty()) {
-      edits = Collections.emptyList();
+      edits = ImmutableList.of();
     } else {
-      edits = Collections.unmodifiableList(editList);
+      edits = ImmutableList.copyOf(editList);
     }
+    this.editsDueToRebase = ImmutableSet.copyOf(editsDueToRebase);
 
     int ins = 0;
     int del = 0;
     for (Edit e : editList) {
-      del += e.getEndA() - e.getBeginA();
-      ins += e.getEndB() - e.getBeginB();
+      if (!editsDueToRebase.contains(e)) {
+        del += e.getEndA() - e.getBeginA();
+        ins += e.getEndB() - e.getBeginB();
+      }
     }
     insertions = ins;
     deletions = del;
@@ -126,7 +134,8 @@
       String oldName,
       String newName,
       byte[] header,
-      List<Edit> edits,
+      ImmutableList<Edit> edits,
+      ImmutableSet<Edit> editsDueToRebase,
       int insertions,
       int deletions,
       long size,
@@ -137,6 +146,7 @@
     this.newName = newName;
     this.header = header;
     this.edits = edits;
+    this.editsDueToRebase = editsDueToRebase;
     this.insertions = insertions;
     this.deletions = deletions;
     this.size = size;
@@ -149,6 +159,7 @@
     size += stringSize(newName);
     size += header.length;
     size += (8 + 16 + 4 * 4) * edits.size();
+    size += (8 + 16 + 4 * 4) * editsDueToRebase.size();
     return size;
   }
 
@@ -175,10 +186,14 @@
     return newName;
   }
 
-  public List<Edit> getEdits() {
+  public ImmutableList<Edit> getEdits() {
     return edits;
   }
 
+  public ImmutableSet<Edit> getEditsDueToRebase() {
+    return editsDueToRebase;
+  }
+
   public int getInsertions() {
     return insertions;
   }
@@ -209,7 +224,7 @@
     return headerLines;
   }
 
-  Patch toPatch(final PatchSet.Id setId) {
+  Patch toPatch(PatchSet.Id setId) {
     final Patch p = new Patch(new Patch.Key(setId, getNewName()));
     p.setChangeType(getChangeType());
     p.setPatchType(getPatchType());
@@ -230,12 +245,17 @@
     writeFixInt64(out, size);
     writeFixInt64(out, sizeDelta);
 
+    writeEditArray(out, edits);
+    writeEditArray(out, editsDueToRebase);
+  }
+
+  private static void writeEditArray(OutputStream out, Collection<Edit> edits) throws IOException {
     writeVarInt32(out, edits.size());
-    for (final Edit e : edits) {
-      writeVarInt32(out, e.getBeginA());
-      writeVarInt32(out, e.getEndA());
-      writeVarInt32(out, e.getBeginB());
-      writeVarInt32(out, e.getEndB());
+    for (Edit edit : edits) {
+      writeVarInt32(out, edit.getBeginA());
+      writeVarInt32(out, edit.getEndA());
+      writeVarInt32(out, edit.getBeginB());
+      writeVarInt32(out, edit.getEndB());
     }
   }
 
@@ -250,25 +270,37 @@
     long size = readFixInt64(in);
     long sizeDelta = readFixInt64(in);
 
-    int editCount = readVarInt32(in);
-    Edit[] editArray = new Edit[editCount];
-    for (int i = 0; i < editCount; i++) {
+    Edit[] editArray = readEditArray(in);
+    Edit[] editsDueToRebase = readEditArray(in);
+
+    return new PatchListEntry(
+        changeType,
+        patchType,
+        oldName,
+        newName,
+        hdr,
+        ImmutableList.copyOf(editArray),
+        ImmutableSet.copyOf(editsDueToRebase),
+        ins,
+        del,
+        size,
+        sizeDelta);
+  }
+
+  private static Edit[] readEditArray(InputStream in) throws IOException {
+    int numEdits = readVarInt32(in);
+    Edit[] edits = new Edit[numEdits];
+    for (int i = 0; i < numEdits; i++) {
       int beginA = readVarInt32(in);
       int endA = readVarInt32(in);
       int beginB = readVarInt32(in);
       int endB = readVarInt32(in);
-      editArray[i] = new Edit(beginA, endA, beginB, endB);
+      edits[i] = new Edit(beginA, endA, beginB, endB);
     }
-
-    return new PatchListEntry(
-        changeType, patchType, oldName, newName, hdr, toList(editArray), ins, del, size, sizeDelta);
+    return edits;
   }
 
-  private static List<Edit> toList(Edit[] l) {
-    return Collections.unmodifiableList(Arrays.asList(l));
-  }
-
-  private static byte[] compact(final FileHeader h) {
+  private static byte[] compact(FileHeader h) {
     final int end = end(h);
     if (h.getStartOffset() == 0 && end == h.getBuffer().length) {
       return h.getBuffer();
@@ -279,7 +311,7 @@
     return buf;
   }
 
-  private static int end(final FileHeader h) {
+  private static int end(FileHeader h) {
     if (h instanceof CombinedFileHeader) {
       return h.getEndOffset();
     }
@@ -289,7 +321,7 @@
     return h.getEndOffset();
   }
 
-  private static ChangeType toChangeType(final FileHeader hdr) {
+  private static ChangeType toChangeType(FileHeader hdr) {
     switch (hdr.getChangeType()) {
       case ADD:
         return Patch.ChangeType.ADDED;
@@ -306,7 +338,7 @@
     }
   }
 
-  private static PatchType toPatchType(final FileHeader hdr) {
+  private static PatchType toPatchType(FileHeader hdr) {
     PatchType pt;
 
     switch (hdr.getPatchType()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
index ae771d3..39ebcab 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListKey.java
@@ -32,7 +32,7 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 public class PatchListKey implements Serializable {
-  public static final long serialVersionUID = 24L;
+  public static final long serialVersionUID = 31L;
 
   public static final ImmutableBiMap<Whitespace, Character> WHITESPACE_TYPES =
       ImmutableBiMap.of(
@@ -53,6 +53,11 @@
     return new PatchListKey(parentNum, newId, ws);
   }
 
+  public static PatchListKey againstCommit(
+      AnyObjectId otherCommitId, AnyObjectId newId, Whitespace whitespace) {
+    return new PatchListKey(otherCommitId, newId, whitespace);
+  }
+
   /**
    * Old patch-set ID
    *
@@ -76,7 +81,7 @@
   private transient ObjectId newId;
   private transient Whitespace whitespace;
 
-  public PatchListKey(AnyObjectId a, AnyObjectId b, Whitespace ws) {
+  private PatchListKey(AnyObjectId a, AnyObjectId b, Whitespace ws) {
     oldId = a != null ? a.copy() : null;
     newId = b.copy();
     whitespace = ws;
@@ -123,7 +128,7 @@
   }
 
   @Override
-  public boolean equals(final Object o) {
+  public boolean equals(Object o) {
     if (o instanceof PatchListKey) {
       PatchListKey k = (PatchListKey) o;
       return Objects.equals(oldId, k.oldId)
@@ -151,7 +156,7 @@
     return n.toString();
   }
 
-  private void writeObject(final ObjectOutputStream out) throws IOException {
+  private void writeObject(ObjectOutputStream out) throws IOException {
     writeCanBeNull(out, oldId);
     out.writeInt(parentNum == null ? 0 : parentNum);
     writeNotNull(out, newId);
@@ -162,7 +167,7 @@
     out.writeChar(c);
   }
 
-  private void readObject(final ObjectInputStream in) throws IOException {
+  private void readObject(ObjectInputStream in) throws IOException {
     oldId = readCanBeNull(in);
     int n = in.readInt();
     parentNum = n == 0 ? null : Integer.valueOf(n);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
index 124fe8e..9ffd985 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListLoader.java
@@ -16,11 +16,17 @@
 package com.google.gerrit.server.patch;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static java.util.stream.Collectors.toSet;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMultimap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
@@ -29,12 +35,14 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
+import com.google.gerrit.server.patch.EditTransformer.ContextAwareEdit;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
@@ -42,8 +50,8 @@
 import java.util.concurrent.Future;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.TimeoutException;
-import java.util.stream.Stream;
 import org.eclipse.jgit.diff.DiffEntry;
+import org.eclipse.jgit.diff.DiffEntry.ChangeType;
 import org.eclipse.jgit.diff.DiffFormatter;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.EditList;
@@ -86,7 +94,7 @@
   private final long timeoutMillis;
   private final boolean save;
 
-  @AssistedInject
+  @Inject
   PatchListLoader(
       GitRepositoryManager mgr,
       PatchListCache plc,
@@ -144,7 +152,7 @@
     return save ? repo.newObjectInserter() : new InMemoryInserter(repo);
   }
 
-  public PatchList readPatchList(Repository repo, RevWalk rw, ObjectInserter ins)
+  private PatchList readPatchList(Repository repo, RevWalk rw, ObjectInserter ins)
       throws IOException, PatchListNotAvailableException {
     ObjectReader reader = rw.getObjectReader();
     checkArgument(reader.getCreatedFromInserter() == ins);
@@ -178,19 +186,12 @@
       df.setDetectRenames(true);
       List<DiffEntry> diffEntries = df.scan(aTree, bTree);
 
-      Set<String> paths = null;
-      if (key.getOldId() != null && b.getParentCount() == 1) {
-        PatchListKey newKey = PatchListKey.againstDefaultBase(key.getNewId(), key.getWhitespace());
-        PatchListKey oldKey = PatchListKey.againstDefaultBase(key.getOldId(), key.getWhitespace());
-        paths =
-            Stream.concat(
-                    patchListCache.get(newKey, project).getPatches().stream(),
-                    patchListCache.get(oldKey, project).getPatches().stream())
-                .map(PatchListEntry::getNewName)
-                .collect(toSet());
-      }
+      Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath = ImmutableMultimap.of();
+      EditsDueToRebaseResult editsDueToRebaseResult =
+          determineEditsDueToRebase(aCommit, b, diffEntries, df, rw);
+      diffEntries = editsDueToRebaseResult.getRelevantOriginalDiffEntries();
+      editsDueToRebasePerFilePath = editsDueToRebaseResult.getEditsDueToRebasePerFilePath();
 
-      int cnt = diffEntries.size();
       List<PatchListEntry> entries = new ArrayList<>();
       entries.add(
           newCommitMessage(
@@ -205,21 +206,206 @@
                 b,
                 comparisonType));
       }
-      for (int i = 0; i < cnt; i++) {
-        DiffEntry e = diffEntries.get(i);
-        if (paths == null || paths.contains(e.getNewPath()) || paths.contains(e.getOldPath())) {
-
-          FileHeader fh = toFileHeader(key, df, e);
-          long oldSize = getFileSize(reader, e.getOldMode(), e.getOldPath(), aTree);
-          long newSize = getFileSize(reader, e.getNewMode(), e.getNewPath(), bTree);
-          entries.add(newEntry(aTree, fh, newSize, newSize - oldSize));
-        }
+      for (DiffEntry diffEntry : diffEntries) {
+        Set<ContextAwareEdit> editsDueToRebase =
+            getEditsDueToRebase(editsDueToRebasePerFilePath, diffEntry);
+        Optional<PatchListEntry> patchListEntry =
+            getPatchListEntry(reader, df, diffEntry, aTree, bTree, editsDueToRebase);
+        patchListEntry.ifPresent(entries::add);
       }
       return new PatchList(
           a, b, isMerge, comparisonType, entries.toArray(new PatchListEntry[entries.size()]));
     }
   }
 
+  /**
+   * Identifies the edits which are present between {@code commitA} and {@code commitB} due to other
+   * commits in between those two. Edits which cannot be clearly attributed to those other commits
+   * (because they overlap with modifications introduced by {@code commitA} or {@code commitB}) are
+   * omitted from the result. The edits are expressed as differences between {@code treeA} of {@code
+   * commitA} and {@code treeB} of {@code commitB}.
+   *
+   * <p><b>Note:</b> If one of the commits is a merge commit, an empty {@code Multimap} will be
+   * returned.
+   *
+   * <p><b>Warning:</b> This method assumes that commitA and commitB are either a parent and child
+   * commit or represent two patch sets which belong to the same change. No checks are made to
+   * confirm this assumption! Passing arbitrary commits to this method may lead to strange results
+   * or take very long.
+   *
+   * <p>This logic could be expanded to arbitrary commits if the following adjustments were applied:
+   *
+   * <ul>
+   *   <li>If {@code commitA} is an ancestor of {@code commitB} (or the other way around), {@code
+   *       commitA} (or {@code commitB}) is used instead of its parent in this method.
+   *   <li>Special handling for merge commits is added. If only one of them is a merge commit, the
+   *       whole computation has to be done between the single parent and all parents of the merge
+   *       commit. If both of them are merge commits, all combinations of parents have to be
+   *       considered. Alternatively, we could decide to not support this feature for merge commits
+   *       (or just for specific types of merge commits).
+   * </ul>
+   *
+   * @param commitA the commit defining {@code treeA}
+   * @param commitB the commit defining {@code treeB}
+   * @param diffEntries the list of {@code DiffEntries} for the diff between {@code commitA} and
+   *     {@code commitB}
+   * @param df the {@code DiffFormatter}
+   * @param rw the current {@code RevWalk}
+   * @return an aggregated result of the computation
+   * @throws PatchListNotAvailableException if the edits can't be identified
+   * @throws IOException if an error occurred while accessing the repository
+   */
+  private EditsDueToRebaseResult determineEditsDueToRebase(
+      RevCommit commitA,
+      RevCommit commitB,
+      List<DiffEntry> diffEntries,
+      DiffFormatter df,
+      RevWalk rw)
+      throws PatchListNotAvailableException, IOException {
+    if (commitA == null
+        || isRootOrMergeCommit(commitA)
+        || isRootOrMergeCommit(commitB)
+        || areParentChild(commitA, commitB)
+        || haveCommonParent(commitA, commitB)) {
+      return EditsDueToRebaseResult.create(diffEntries, ImmutableMultimap.of());
+    }
+
+    PatchListKey oldKey = PatchListKey.againstDefaultBase(key.getOldId(), key.getWhitespace());
+    PatchList oldPatchList = patchListCache.get(oldKey, project);
+    PatchListKey newKey = PatchListKey.againstDefaultBase(key.getNewId(), key.getWhitespace());
+    PatchList newPatchList = patchListCache.get(newKey, project);
+
+    List<PatchListEntry> oldPatches = oldPatchList.getPatches();
+    List<PatchListEntry> newPatches = newPatchList.getPatches();
+    // TODO(aliceks): Have separate but more limited lists for parents and patch sets (but don't
+    // mess up renames/copies).
+    Set<String> touchedFilePaths = new HashSet<>();
+    for (PatchListEntry patchListEntry : oldPatches) {
+      touchedFilePaths.addAll(getTouchedFilePaths(patchListEntry));
+    }
+    for (PatchListEntry patchListEntry : newPatches) {
+      touchedFilePaths.addAll(getTouchedFilePaths(patchListEntry));
+    }
+
+    List<DiffEntry> relevantDiffEntries =
+        diffEntries
+            .stream()
+            .filter(diffEntry -> isTouched(touchedFilePaths, diffEntry))
+            .collect(toImmutableList());
+
+    RevCommit parentCommitA = commitA.getParent(0);
+    rw.parseBody(parentCommitA);
+    RevCommit parentCommitB = commitB.getParent(0);
+    rw.parseBody(parentCommitB);
+    List<DiffEntry> parentDiffEntries = df.scan(parentCommitA, parentCommitB);
+    // TODO(aliceks): Find a way to not construct a PatchListEntry as it contains many unnecessary
+    // details and we don't fill all of them properly.
+    List<PatchListEntry> parentPatchListEntries =
+        getRelevantPatchListEntries(
+            parentDiffEntries, parentCommitA, parentCommitB, touchedFilePaths, df);
+
+    EditTransformer editTransformer = new EditTransformer(parentPatchListEntries);
+    editTransformer.transformReferencesOfSideA(oldPatches);
+    editTransformer.transformReferencesOfSideB(newPatches);
+    return EditsDueToRebaseResult.create(
+        relevantDiffEntries, editTransformer.getEditsPerFilePath());
+  }
+
+  private static boolean isRootOrMergeCommit(RevCommit commit) {
+    return commit.getParentCount() != 1;
+  }
+
+  private static boolean areParentChild(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.equals(commitA.getParent(0), commitB)
+        || ObjectId.equals(commitB.getParent(0), commitA);
+  }
+
+  private static boolean haveCommonParent(RevCommit commitA, RevCommit commitB) {
+    return ObjectId.equals(commitA.getParent(0), commitB.getParent(0));
+  }
+
+  private static Set<String> getTouchedFilePaths(PatchListEntry patchListEntry) {
+    String oldFilePath = patchListEntry.getOldName();
+    String newFilePath = patchListEntry.getNewName();
+
+    return oldFilePath == null
+        ? ImmutableSet.of(newFilePath)
+        : ImmutableSet.of(oldFilePath, newFilePath);
+  }
+
+  private static boolean isTouched(Set<String> touchedFilePaths, DiffEntry diffEntry) {
+    String oldFilePath = diffEntry.getOldPath();
+    String newFilePath = diffEntry.getNewPath();
+    // One of the above file paths could be /dev/null but we need not explicitly check for this
+    // value as the set of file paths shouldn't contain it.
+    return touchedFilePaths.contains(oldFilePath) || touchedFilePaths.contains(newFilePath);
+  }
+
+  private List<PatchListEntry> getRelevantPatchListEntries(
+      List<DiffEntry> parentDiffEntries,
+      RevCommit parentCommitA,
+      RevCommit parentCommitB,
+      Set<String> touchedFilePaths,
+      DiffFormatter diffFormatter)
+      throws IOException {
+    List<PatchListEntry> parentPatchListEntries = new ArrayList<>(parentDiffEntries.size());
+    for (DiffEntry parentDiffEntry : parentDiffEntries) {
+      if (!isTouched(touchedFilePaths, parentDiffEntry)) {
+        continue;
+      }
+      FileHeader fileHeader = toFileHeader(parentCommitB, diffFormatter, parentDiffEntry);
+      // The code which uses this PatchListEntry doesn't care about the last three parameters. As
+      // they are expensive to compute, we use arbitrary values for them.
+      PatchListEntry patchListEntry =
+          newEntry(parentCommitA.getTree(), fileHeader, ImmutableSet.of(), 0, 0);
+      parentPatchListEntries.add(patchListEntry);
+    }
+    return parentPatchListEntries;
+  }
+
+  private static Set<ContextAwareEdit> getEditsDueToRebase(
+      Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath, DiffEntry diffEntry) {
+    if (editsDueToRebasePerFilePath.isEmpty()) {
+      return ImmutableSet.of();
+    }
+
+    String filePath = diffEntry.getNewPath();
+    if (diffEntry.getChangeType() == ChangeType.DELETE) {
+      filePath = diffEntry.getOldPath();
+    }
+    return ImmutableSet.copyOf(editsDueToRebasePerFilePath.get(filePath));
+  }
+
+  private Optional<PatchListEntry> getPatchListEntry(
+      ObjectReader objectReader,
+      DiffFormatter diffFormatter,
+      DiffEntry diffEntry,
+      RevTree treeA,
+      RevTree treeB,
+      Set<ContextAwareEdit> editsDueToRebase)
+      throws IOException {
+    FileHeader fileHeader = toFileHeader(key.getNewId(), diffFormatter, diffEntry);
+    long oldSize = getFileSize(objectReader, diffEntry.getOldMode(), diffEntry.getOldPath(), treeA);
+    long newSize = getFileSize(objectReader, diffEntry.getNewMode(), diffEntry.getNewPath(), treeB);
+    Set<Edit> contentEditsDueToRebase = getContentEdits(editsDueToRebase);
+    PatchListEntry patchListEntry =
+        newEntry(treeA, fileHeader, contentEditsDueToRebase, newSize, newSize - oldSize);
+    // All edits in a file are due to rebase -> exclude the file from the diff.
+    if (EditTransformer.toEdits(patchListEntry).allMatch(editsDueToRebase::contains)) {
+      return Optional.empty();
+    }
+    return Optional.of(patchListEntry);
+  }
+
+  private static Set<Edit> getContentEdits(Set<ContextAwareEdit> editsDueToRebase) {
+    return editsDueToRebase
+        .stream()
+        .map(ContextAwareEdit::toEdit)
+        .filter(Optional::isPresent)
+        .map(Optional::get)
+        .collect(toSet());
+  }
+
   private ComparisonType getComparisonType(RevObject a, RevCommit b) {
     for (int i = 0; i < b.getParentCount(); i++) {
       if (b.getParent(i).equals(a)) {
@@ -250,17 +436,13 @@
   }
 
   private FileHeader toFileHeader(
-      PatchListKey key, final DiffFormatter diffFormatter, final DiffEntry diffEntry)
-      throws IOException {
+      ObjectId commitB, DiffFormatter diffFormatter, DiffEntry diffEntry) throws IOException {
 
     Future<FileHeader> result =
         diffExecutor.submit(
-            new Callable<FileHeader>() {
-              @Override
-              public FileHeader call() throws IOException {
-                synchronized (diffEntry) {
-                  return diffFormatter.toFileHeader(diffEntry);
-                }
+            () -> {
+              synchronized (diffEntry) {
+                return diffFormatter.toFileHeader(diffEntry);
               }
             });
 
@@ -273,7 +455,7 @@
               + " in project "
               + project
               + " on commit "
-              + key.getNewId().name()
+              + commitB.name()
               + " on path "
               + diffEntry.getNewPath()
               + " comparing "
@@ -331,7 +513,7 @@
     RawText bRawText = new RawText(bContent);
     EditList edits = new HistogramDiff().diff(cmp, aRawText, bRawText);
     FileHeader fh = new FileHeader(rawHdr, edits, PatchType.UNIFIED);
-    return new PatchListEntry(fh, edits, size, sizeDelta);
+    return new PatchListEntry(fh, edits, ImmutableSet.of(), size, sizeDelta);
   }
 
   private static byte[] getRawHeader(boolean hasA, String fileName) {
@@ -354,18 +536,19 @@
     return hdr.toString().getBytes(UTF_8);
   }
 
-  private PatchListEntry newEntry(RevTree aTree, FileHeader fileHeader, long size, long sizeDelta) {
+  private static PatchListEntry newEntry(
+      RevTree aTree, FileHeader fileHeader, Set<Edit> editsDueToRebase, long size, long sizeDelta) {
     if (aTree == null // want combined diff
         || fileHeader.getPatchType() != PatchType.UNIFIED
         || fileHeader.getHunks().isEmpty()) {
-      return new PatchListEntry(fileHeader, Collections.<Edit>emptyList(), size, sizeDelta);
+      return new PatchListEntry(fileHeader, ImmutableList.of(), ImmutableSet.of(), size, sizeDelta);
     }
 
     List<Edit> edits = fileHeader.toEditList();
     if (edits.isEmpty()) {
-      return new PatchListEntry(fileHeader, Collections.<Edit>emptyList(), size, sizeDelta);
+      return new PatchListEntry(fileHeader, ImmutableList.of(), ImmutableSet.of(), size, sizeDelta);
     }
-    return new PatchListEntry(fileHeader, edits, size, sizeDelta);
+    return new PatchListEntry(fileHeader, edits, editsDueToRebase, size, sizeDelta);
   }
 
   private RevObject aFor(
@@ -402,4 +585,19 @@
     ins.flush();
     return id;
   }
+
+  @AutoValue
+  abstract static class EditsDueToRebaseResult {
+    public static EditsDueToRebaseResult create(
+        List<DiffEntry> relevantDiffEntries,
+        Multimap<String, ContextAwareEdit> editsDueToRebasePerFilePath) {
+      return new AutoValue_PatchListLoader_EditsDueToRebaseResult(
+          relevantDiffEntries, editsDueToRebasePerFilePath);
+    }
+
+    public abstract List<DiffEntry> getRelevantOriginalDiffEntries();
+
+    /** Returns the edits per file path they modify in {@code treeB}. */
+    public abstract Multimap<String, ContextAwareEdit> getEditsDueToRebasePerFilePath();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java
new file mode 100644
index 0000000..54e0e6c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListObjectTooLargeException.java
@@ -0,0 +1,27 @@
+// 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.patch;
+
+/**
+ * Exception thrown when the PatchList could not be computed because previous attempts failed with
+ * {@code LargeObjectException}. This is not thrown on the first computation.
+ */
+public class PatchListObjectTooLargeException extends PatchListNotAvailableException {
+  private static final long serialVersionUID = 1L;
+
+  public PatchListObjectTooLargeException(String message) {
+    super(message);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java
index f40eac6..942d0e0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchListWeigher.java
@@ -23,7 +23,8 @@
     int size =
         16
             + 4 * 8
-            + 2 * 36 // Size of PatchListKey, 64 bit JVM
+            + 2 * 36
+            + 8 // Size of PatchListKey, 64 bit JVM
             + 16
             + 3 * 8
             + 3 * 4
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
index 2dd5af7..6f3e055 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptBuilder.java
@@ -16,6 +16,7 @@
 
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.CommentDetail;
 import com.google.gerrit.common.data.PatchScript;
 import com.google.gerrit.common.data.PatchScript.DisplayMethod;
@@ -56,7 +57,7 @@
   private static final Comparator<Edit> EDIT_SORT =
       new Comparator<Edit>() {
         @Override
-        public int compare(final Edit o1, final Edit o2) {
+        public int compare(Edit o1, Edit o2) {
           return o1.getBeginA() - o2.getBeginA();
         }
       };
@@ -91,11 +92,11 @@
     this.projectKey = projectKey;
   }
 
-  void setChange(final Change c) {
+  void setChange(Change c) {
     this.change = c;
   }
 
-  void setDiffPrefs(final DiffPreferencesInfo dp) {
+  void setDiffPrefs(DiffPreferencesInfo dp) {
     diffPrefs = dp;
 
     context = diffPrefs.context;
@@ -106,14 +107,13 @@
     }
   }
 
-  void setTrees(final ComparisonType ct, final ObjectId a, final ObjectId b) {
+  void setTrees(ComparisonType ct, ObjectId a, ObjectId b) {
     comparisonType = ct;
     aId = a;
     bId = b;
   }
 
-  PatchScript toPatchScript(
-      final PatchListEntry content, final CommentDetail comments, final List<Patch> history)
+  PatchScript toPatchScript(PatchListEntry content, CommentDetail comments, List<Patch> history)
       throws IOException {
     reader = db.newObjectReader();
     try {
@@ -123,8 +123,7 @@
     }
   }
 
-  private PatchScript build(
-      final PatchListEntry content, final CommentDetail comments, final List<Patch> history)
+  private PatchScript build(PatchListEntry content, CommentDetail comments, List<Patch> history)
       throws IOException {
     boolean intralineDifferenceIsPossible = true;
     boolean intralineFailure = false;
@@ -137,6 +136,7 @@
     b.resolve(a, bId);
 
     edits = new ArrayList<>(content.getEdits());
+    ImmutableSet<Edit> editsDueToRebase = content.getEditsDueToRebase();
 
     if (!isModify(content)) {
       intralineDifferenceIsPossible = false;
@@ -144,7 +144,8 @@
       IntraLineDiff d =
           patchListCache.getIntraLineDiff(
               IntraLineDiffKey.create(a.id, b.id, diffPrefs.ignoreWhitespace),
-              IntraLineDiffArgs.create(a.src, b.src, edits, projectKey, bId, b.path));
+              IntraLineDiffArgs.create(
+                  a.src, b.src, edits, editsDueToRebase, projectKey, bId, b.path));
       if (d != null) {
         switch (d.getStatus()) {
           case EDIT_LIST:
@@ -216,6 +217,7 @@
         a.dst,
         b.dst,
         edits,
+        editsDueToRebase,
         a.displayMethod,
         b.displayMethod,
         a.mimeType.toString(),
@@ -246,7 +248,7 @@
     }
   }
 
-  private static String oldName(final PatchListEntry entry) {
+  private static String oldName(PatchListEntry entry) {
     switch (entry.getChangeType()) {
       case ADDED:
         return null;
@@ -261,7 +263,7 @@
     }
   }
 
-  private static String newName(final PatchListEntry entry) {
+  private static String newName(PatchListEntry entry) {
     switch (entry.getChangeType()) {
       case DELETED:
         return null;
@@ -275,7 +277,7 @@
     }
   }
 
-  private void ensureCommentsVisible(final CommentDetail comments) {
+  private void ensureCommentsVisible(CommentDetail comments) {
     if (comments.getCommentsA().isEmpty() && comments.getCommentsB().isEmpty()) {
       // No comments, no additional dummy edits are required.
       //
@@ -323,10 +325,10 @@
     Collections.sort(edits, EDIT_SORT);
   }
 
-  private void safeAdd(final List<Edit> empty, final Edit toAdd) {
+  private void safeAdd(List<Edit> empty, Edit toAdd) {
     final int a = toAdd.getBeginA();
     final int b = toAdd.getBeginB();
-    for (final Edit e : edits) {
+    for (Edit e : edits) {
       if (e.getBeginA() <= a && a <= e.getEndA()) {
         return;
       }
@@ -337,7 +339,7 @@
     empty.add(toAdd);
   }
 
-  private int mapA2B(final int a) {
+  private int mapA2B(int a) {
     if (edits.isEmpty()) {
       // Magic special case of an unmodified file.
       //
@@ -363,7 +365,7 @@
     return last.getEndB() + (a - last.getEndA());
   }
 
-  private int mapB2A(final int b) {
+  private int mapB2A(int b) {
     if (edits.isEmpty()) {
       // Magic special case of an unmodified file.
       //
@@ -391,7 +393,7 @@
 
   private void packContent(boolean ignoredWhitespace) {
     EditList list = new EditList(edits, context, a.size(), b.size());
-    for (final EditList.Hunk hunk : list.getHunks()) {
+    for (EditList.Hunk hunk : list.getHunks()) {
       while (hunk.next()) {
         if (hunk.isContextLine()) {
           final String lineA = a.src.getString(hunk.getCurA());
@@ -442,7 +444,7 @@
       dst.addLine(line, src.getString(line));
     }
 
-    void resolve(final Side other, final ObjectId within) throws IOException {
+    void resolve(Side other, ObjectId within) throws IOException {
       try {
         final boolean reuse;
         if (Patch.COMMIT_MSG.equals(path)) {
@@ -534,11 +536,7 @@
           }
         }
 
-        if (srcContent.length > 0 && srcContent[srcContent.length - 1] != '\n') {
-          dst.setMissingNewlineAtEnd(true);
-        }
         dst.setSize(size());
-        dst.setPath(path);
 
         if (mode == FileMode.SYMLINK) {
           fileMode = PatchScript.FileMode.SYMLINK;
@@ -550,7 +548,7 @@
       }
     }
 
-    private TreeWalk find(final ObjectId within)
+    private TreeWalk find(ObjectId within)
         throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
             IOException {
       if (path == null || within == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
index 82c6150..fe158f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchScriptFactory.java
@@ -28,7 +28,6 @@
 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.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
@@ -38,7 +37,9 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.LargeObjectException;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
+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.InvalidChangeOperationException;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gwtorm.server.OrmException;
@@ -61,14 +62,14 @@
 public class PatchScriptFactory implements Callable<PatchScript> {
   public interface Factory {
     PatchScriptFactory create(
-        ChangeControl control,
+        ChangeNotes notes,
         String fileName,
         @Assisted("patchSetA") PatchSet.Id patchSetA,
         @Assisted("patchSetB") PatchSet.Id patchSetB,
         DiffPreferencesInfo diffPrefs);
 
     PatchScriptFactory create(
-        ChangeControl control,
+        ChangeNotes notes,
         String fileName,
         int parentNum,
         PatchSet.Id patchSetB,
@@ -90,15 +91,15 @@
   private final PatchSet.Id psb;
   private final DiffPreferencesInfo diffPrefs;
   private final ChangeEditUtil editReader;
+  private final Provider<CurrentUser> userProvider;
+  private final PermissionBackend permissionBackend;
   private Optional<ChangeEdit> edit;
 
   private final Change.Id changeId;
   private boolean loadHistory = true;
   private boolean loadComments = true;
 
-  private Change change;
-  private Project.NameKey project;
-  private ChangeControl control;
+  private ChangeNotes notes;
   private ObjectId aId;
   private ObjectId bId;
   private List<Patch> history;
@@ -113,19 +114,23 @@
       ReviewDb db,
       CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
-      @Assisted ChangeControl control,
-      @Assisted final String fileName,
-      @Assisted("patchSetA") @Nullable final PatchSet.Id patchSetA,
-      @Assisted("patchSetB") final PatchSet.Id patchSetB,
+      Provider<CurrentUser> userProvider,
+      PermissionBackend permissionBackend,
+      @Assisted ChangeNotes notes,
+      @Assisted String fileName,
+      @Assisted("patchSetA") @Nullable PatchSet.Id patchSetA,
+      @Assisted("patchSetB") PatchSet.Id patchSetB,
       @Assisted DiffPreferencesInfo diffPrefs) {
     this.repoManager = grm;
     this.psUtil = psUtil;
     this.builderFactory = builderFactory;
     this.patchListCache = patchListCache;
     this.db = db;
-    this.control = control;
+    this.notes = notes;
     this.commentsUtil = commentsUtil;
     this.editReader = editReader;
+    this.userProvider = userProvider;
+    this.permissionBackend = permissionBackend;
 
     this.fileName = fileName;
     this.psa = patchSetA;
@@ -145,7 +150,9 @@
       ReviewDb db,
       CommentsUtil commentsUtil,
       ChangeEditUtil editReader,
-      @Assisted ChangeControl control,
+      Provider<CurrentUser> userProvider,
+      PermissionBackend permissionBackend,
+      @Assisted ChangeNotes notes,
       @Assisted String fileName,
       @Assisted int parentNum,
       @Assisted PatchSet.Id patchSetB,
@@ -155,9 +162,11 @@
     this.builderFactory = builderFactory;
     this.patchListCache = patchListCache;
     this.db = db;
-    this.control = control;
+    this.notes = notes;
     this.commentsUtil = commentsUtil;
     this.editReader = editReader;
+    this.userProvider = userProvider;
+    this.permissionBackend = permissionBackend;
 
     this.fileName = fileName;
     this.psa = null;
@@ -180,25 +189,27 @@
   @Override
   public PatchScript call()
       throws OrmException, LargeObjectException, AuthException, InvalidChangeOperationException,
-          IOException {
+          IOException, PermissionBackendException {
     if (parentNum < 0) {
       validatePatchSetId(psa);
     }
     validatePatchSetId(psb);
 
-    change = control.getChange();
-    project = change.getProject();
-
-    PatchSet psEntityA = psa != null ? psUtil.get(db, control.getNotes(), psa) : null;
-    PatchSet psEntityB =
-        psb.get() == 0 ? new PatchSet(psb) : psUtil.get(db, control.getNotes(), psb);
-
-    if ((psEntityA != null && !control.isPatchVisible(psEntityA, db))
-        || (psEntityB != null && !control.isPatchVisible(psEntityB, db))) {
-      throw new NoSuchChangeException(changeId);
+    PatchSet psEntityA = psa != null ? psUtil.get(db, notes, psa) : null;
+    PatchSet psEntityB = psb.get() == 0 ? new PatchSet(psb) : psUtil.get(db, notes, psb);
+    if (psEntityA != null || psEntityB != null) {
+      try {
+        permissionBackend
+            .user(userProvider)
+            .change(notes)
+            .database(db)
+            .check(ChangePermission.READ);
+      } catch (AuthException e) {
+        throw new NoSuchChangeException(changeId);
+      }
     }
 
-    try (Repository git = repoManager.openRepository(project)) {
+    try (Repository git = repoManager.openRepository(notes.getProjectName())) {
       bId = toObjectId(psEntityB);
       if (parentNum < 0) {
         aId = psEntityA != null ? toObjectId(psEntityA) : null;
@@ -209,11 +220,7 @@
         final PatchScriptBuilder b = newBuilder(list, git);
         final PatchListEntry content = list.get(fileName);
 
-        loadCommentsAndHistory(
-            control.getNotes(),
-            content.getChangeType(),
-            content.getOldName(),
-            content.getNewName());
+        loadCommentsAndHistory(content.getChangeType(), content.getOldName(), content.getNewName());
 
         return b.toPatchScript(content, comments, history);
       } catch (PatchListNotAvailableException e) {
@@ -225,37 +232,35 @@
         throw new LargeObjectException("File content is too large", err);
       }
     } catch (RepositoryNotFoundException e) {
-      log.error("Repository " + project + " not found", e);
+      log.error("Repository " + notes.getProjectName() + " not found", e);
       throw new NoSuchChangeException(changeId, e);
     } catch (IOException e) {
-      log.error("Cannot open repository " + project, e);
+      log.error("Cannot open repository " + notes.getProjectName(), e);
       throw new NoSuchChangeException(changeId, e);
     }
   }
 
-  private PatchListKey keyFor(final Whitespace whitespace) {
+  private PatchListKey keyFor(Whitespace whitespace) {
     if (parentNum < 0) {
-      return new PatchListKey(aId, bId, whitespace);
+      return PatchListKey.againstCommit(aId, bId, whitespace);
     }
     return PatchListKey.againstParentNum(parentNum + 1, bId, whitespace);
   }
 
-  private PatchList listFor(final PatchListKey key) throws PatchListNotAvailableException {
-    return patchListCache.get(key, project);
+  private PatchList listFor(PatchListKey key) throws PatchListNotAvailableException {
+    return patchListCache.get(key, notes.getProjectName());
   }
 
-  private PatchScriptBuilder newBuilder(final PatchList list, Repository git) {
+  private PatchScriptBuilder newBuilder(PatchList list, Repository git) {
     final PatchScriptBuilder b = builderFactory.get();
-    b.setRepository(git, project);
-    b.setChange(change);
+    b.setRepository(git, notes.getProjectName());
+    b.setChange(notes.getChange());
     b.setDiffPrefs(diffPrefs);
     b.setTrees(list.getComparisonType(), list.getOldId(), list.getNewId());
     return b;
   }
 
-  private ObjectId toObjectId(PatchSet ps)
-      throws NoSuchChangeException, AuthException, NoSuchChangeException, IOException,
-          OrmException {
+  private ObjectId toObjectId(PatchSet ps) throws AuthException, IOException, OrmException {
     if (ps.getId().get() == 0) {
       return getEditRev();
     }
@@ -271,16 +276,15 @@
     }
   }
 
-  private ObjectId getEditRev()
-      throws AuthException, NoSuchChangeException, IOException, OrmException {
-    edit = editReader.byChange(change);
+  private ObjectId getEditRev() throws AuthException, IOException, OrmException {
+    edit = editReader.byChange(notes);
     if (edit.isPresent()) {
-      return edit.get().getRef().getObjectId();
+      return edit.get().getEditCommit();
     }
-    throw new NoSuchChangeException(change.getId());
+    throw new NoSuchChangeException(notes.getChangeId());
   }
 
-  private void validatePatchSetId(final PatchSet.Id psId) throws NoSuchChangeException {
+  private void validatePatchSetId(PatchSet.Id psId) throws NoSuchChangeException {
     if (psId == null) { // OK, means use base;
     } else if (changeId.equals(psId.getParentKey())) { // OK, same change;
     } else {
@@ -288,8 +292,7 @@
     }
   }
 
-  private void loadCommentsAndHistory(
-      ChangeNotes notes, ChangeType changeType, String oldName, String newName)
+  private void loadCommentsAndHistory(ChangeType changeType, String oldName, String newName)
       throws OrmException {
     Map<Patch.Key, Patch> byKey = new HashMap<>();
 
@@ -301,9 +304,6 @@
       //
       history = new ArrayList<>();
       for (PatchSet ps : psUtil.byChange(db, notes)) {
-        if (!control.isPatchVisible(ps, db)) {
-          continue;
-        }
         String name = fileName;
         if (psa != null) {
           switch (changeType) {
@@ -357,7 +357,7 @@
           break;
       }
 
-      CurrentUser user = control.getUser();
+      CurrentUser user = userProvider.get();
       if (user.isIdentifiedUser()) {
         Account.Id me = user.getAccountId();
         switch (changeType) {
@@ -386,10 +386,9 @@
   }
 
   private void loadPublished(Map<Patch.Key, Patch> byKey, String file) throws OrmException {
-    ChangeNotes notes = control.getNotes();
     for (Comment c : commentsUtil.publishedByChangeFile(db, notes, changeId, file)) {
-      comments.include(change.getId(), c);
-      PatchSet.Id psId = new PatchSet.Id(change.getId(), c.key.patchSetId);
+      comments.include(notes.getChangeId(), c);
+      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
       Patch.Key pKey = new Patch.Key(psId, c.key.filename);
       Patch p = byKey.get(pKey);
       if (p != null) {
@@ -400,9 +399,9 @@
 
   private void loadDrafts(Map<Patch.Key, Patch> byKey, Account.Id me, String file)
       throws OrmException {
-    for (Comment c : commentsUtil.draftByChangeFileAuthor(db, control.getNotes(), file, me)) {
-      comments.include(change.getId(), c);
-      PatchSet.Id psId = new PatchSet.Id(change.getId(), c.key.patchSetId);
+    for (Comment c : commentsUtil.draftByChangeFileAuthor(db, notes, file, me)) {
+      comments.include(notes.getChangeId(), c);
+      PatchSet.Id psId = new PatchSet.Id(notes.getChangeId(), c.key.patchSetId);
       Patch.Key pKey = new Patch.Key(psId, c.key.filename);
       Patch p = byKey.get(pKey);
       if (p != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
index 3fc6ba6..41bade6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/PatchSetInfoFactory.java
@@ -22,7 +22,7 @@
 import com.google.gerrit.reviewdb.client.UserIdentity;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.account.AccountByEmailCache;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gwtorm.server.OrmException;
@@ -45,17 +45,17 @@
 public class PatchSetInfoFactory {
   private final GitRepositoryManager repoManager;
   private final PatchSetUtil psUtil;
-  private final AccountByEmailCache byEmailCache;
+  private final Emails emails;
 
   @Inject
-  public PatchSetInfoFactory(
-      GitRepositoryManager repoManager, PatchSetUtil psUtil, AccountByEmailCache byEmailCache) {
+  public PatchSetInfoFactory(GitRepositoryManager repoManager, PatchSetUtil psUtil, Emails emails) {
     this.repoManager = repoManager;
     this.psUtil = psUtil;
-    this.byEmailCache = byEmailCache;
+    this.emails = emails;
   }
 
-  public PatchSetInfo get(RevWalk rw, RevCommit src, PatchSet.Id psi) throws IOException {
+  public PatchSetInfo get(RevWalk rw, RevCommit src, PatchSet.Id psi)
+      throws IOException, OrmException {
     rw.parseBody(src);
     PatchSetInfo info = new PatchSetInfo(psi);
     info.setSubject(src.getShortMessage());
@@ -84,13 +84,13 @@
       PatchSetInfo info = get(rw, src, patchSet.getId());
       info.setParents(toParentInfos(src.getParents(), rw));
       return info;
-    } catch (IOException e) {
+    } catch (IOException | OrmException e) {
       throw new PatchSetInfoNotAvailableException(e);
     }
   }
 
   // TODO: The same method exists in EventFactory, find a common place for it
-  private UserIdentity toUserIdentity(final PersonIdent who) {
+  private UserIdentity toUserIdentity(PersonIdent who) throws IOException, OrmException {
     final UserIdentity u = new UserIdentity();
     u.setName(who.getName());
     u.setEmail(who.getEmailAddress());
@@ -100,7 +100,7 @@
     // If only one account has access to this email address, select it
     // as the identity of the user.
     //
-    final Set<Account.Id> a = byEmailCache.get(u.getEmail());
+    Set<Account.Id> a = emails.getAccountFor(u.getEmail());
     if (a.size() == 1) {
       u.setAccount(a.iterator().next());
     }
@@ -108,7 +108,7 @@
     return u;
   }
 
-  private List<PatchSetInfo.ParentInfo> toParentInfos(final RevCommit[] parents, final RevWalk walk)
+  private List<PatchSetInfo.ParentInfo> toParentInfos(RevCommit[] parents, RevWalk walk)
       throws IOException, MissingObjectException {
     List<PatchSetInfo.ParentInfo> pInfos = new ArrayList<>(parents.length);
     for (RevCommit parent : parents) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java b/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java
index f001591..90141715 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/patch/Text.java
@@ -168,7 +168,7 @@
 
   private Charset charset;
 
-  public Text(final byte[] r) {
+  public Text(byte[] r) {
     super(r);
   }
 
@@ -181,7 +181,7 @@
   }
 
   @Override
-  protected String decode(final int s, int e) {
+  protected String decode(int s, int e) {
     if (charset == null) {
       charset = charset(content, null);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java
new file mode 100644
index 0000000..4b06861
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermission.java
@@ -0,0 +1,56 @@
+// 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.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum ChangePermission implements ChangePermissionOrLabel {
+  READ(Permission.READ),
+  RESTORE,
+  DELETE,
+  ABANDON(Permission.ABANDON),
+  EDIT_ASSIGNEE(Permission.EDIT_ASSIGNEE),
+  EDIT_DESCRIPTION,
+  EDIT_HASHTAGS(Permission.EDIT_HASHTAGS),
+  EDIT_TOPIC_NAME(Permission.EDIT_TOPIC_NAME),
+  REMOVE_REVIEWER(Permission.REMOVE_REVIEWER),
+  ADD_PATCH_SET(Permission.ADD_PATCH_SET),
+  REBASE(Permission.REBASE),
+  SUBMIT(Permission.SUBMIT),
+  SUBMIT_AS(Permission.SUBMIT_AS);
+
+  private final String name;
+
+  ChangePermission() {
+    name = null;
+  }
+
+  ChangePermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  @Override
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  @Override
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.java
new file mode 100644
index 0000000..06c0d73
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ChangePermissionOrLabel.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.server.permissions;
+
+import java.util.Optional;
+
+/** A {@link ChangePermission} or a {@link LabelPermission}. */
+public interface ChangePermissionOrLabel {
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName();
+
+  /** @return readable identifier of this permission for exception message. */
+  public String describeForException();
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
new file mode 100644
index 0000000..4c6e6753
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/FailedPermissionBackend.java
@@ -0,0 +1,178 @@
+// 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.permissions;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.inject.Provider;
+import java.util.Collection;
+import java.util.Set;
+
+/**
+ * Helpers for {@link PermissionBackend} that must fail.
+ *
+ * <p>These helpers are useful to curry failure state identified inside a non-throwing factory
+ * method to the throwing {@code check} or {@code test} methods.
+ */
+public class FailedPermissionBackend {
+  public static ForProject project(String message) {
+    return project(message, null);
+  }
+
+  public static ForProject project(String message, Throwable cause) {
+    return new FailedProject(message, cause);
+  }
+
+  public static ForRef ref(String message) {
+    return ref(message, null);
+  }
+
+  public static ForRef ref(String message, Throwable cause) {
+    return new FailedRef(message, cause);
+  }
+
+  public static ForChange change(String message) {
+    return change(message, null);
+  }
+
+  public static ForChange change(String message, Throwable cause) {
+    return new FailedChange(message, cause);
+  }
+
+  private FailedPermissionBackend() {}
+
+  private static class FailedProject extends ForProject {
+    private final String message;
+    private final Throwable cause;
+
+    FailedProject(String message, Throwable cause) {
+      this.message = message;
+      this.cause = cause;
+    }
+
+    @Override
+    public ForProject database(Provider<ReviewDb> db) {
+      return this;
+    }
+
+    @Override
+    public ForProject user(CurrentUser user) {
+      return this;
+    }
+
+    @Override
+    public ForRef ref(String ref) {
+      return new FailedRef(message, cause);
+    }
+
+    @Override
+    public void check(ProjectPermission perm) throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+  }
+
+  private static class FailedRef extends ForRef {
+    private final String message;
+    private final Throwable cause;
+
+    FailedRef(String message, Throwable cause) {
+      this.message = message;
+      this.cause = cause;
+    }
+
+    @Override
+    public ForRef database(Provider<ReviewDb> db) {
+      return this;
+    }
+
+    @Override
+    public ForRef user(CurrentUser user) {
+      return this;
+    }
+
+    @Override
+    public ForChange change(ChangeData cd) {
+      return new FailedChange(message, cause);
+    }
+
+    @Override
+    public ForChange change(ChangeNotes notes) {
+      return new FailedChange(message, cause);
+    }
+
+    @Override
+    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
+      return new FailedChange(message, cause);
+    }
+
+    @Override
+    public void check(RefPermission perm) throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+  }
+
+  private static class FailedChange extends ForChange {
+    private final String message;
+    private final Throwable cause;
+
+    FailedChange(String message, Throwable cause) {
+      this.message = message;
+      this.cause = cause;
+    }
+
+    @Override
+    public ForChange database(Provider<ReviewDb> db) {
+      return this;
+    }
+
+    @Override
+    public ForChange user(CurrentUser user) {
+      return this;
+    }
+
+    @Override
+    public void check(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      throw new PermissionBackendException(message, cause);
+    }
+
+    @Override
+    public CurrentUser user() {
+      throw new UnsupportedOperationException("FailedPermissionBackend is not scoped to user");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java
new file mode 100644
index 0000000..13d6e48
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/GlobalPermission.java
@@ -0,0 +1,181 @@
+// 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.permissions;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.extensions.annotations.CapabilityScope;
+import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
+import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import java.lang.annotation.Annotation;
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Set;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Global server permissions built into Gerrit. */
+public enum GlobalPermission implements GlobalOrPluginPermission {
+  ACCESS_DATABASE(GlobalCapability.ACCESS_DATABASE),
+  ADMINISTRATE_SERVER(GlobalCapability.ADMINISTRATE_SERVER),
+  CREATE_ACCOUNT(GlobalCapability.CREATE_ACCOUNT),
+  CREATE_GROUP(GlobalCapability.CREATE_GROUP),
+  CREATE_PROJECT(GlobalCapability.CREATE_PROJECT),
+  EMAIL_REVIEWERS(GlobalCapability.EMAIL_REVIEWERS),
+  FLUSH_CACHES(GlobalCapability.FLUSH_CACHES),
+  KILL_TASK(GlobalCapability.KILL_TASK),
+  MAINTAIN_SERVER(GlobalCapability.MAINTAIN_SERVER),
+  MODIFY_ACCOUNT(GlobalCapability.MODIFY_ACCOUNT),
+  RUN_AS(GlobalCapability.RUN_AS),
+  RUN_GC(GlobalCapability.RUN_GC),
+  STREAM_EVENTS(GlobalCapability.STREAM_EVENTS),
+  VIEW_ALL_ACCOUNTS(GlobalCapability.VIEW_ALL_ACCOUNTS),
+  VIEW_CACHES(GlobalCapability.VIEW_CACHES),
+  VIEW_CONNECTIONS(GlobalCapability.VIEW_CONNECTIONS),
+  VIEW_PLUGINS(GlobalCapability.VIEW_PLUGINS),
+  VIEW_QUEUE(GlobalCapability.VIEW_QUEUE);
+
+  private static final Logger log = LoggerFactory.getLogger(GlobalPermission.class);
+  private static final ImmutableMap<String, GlobalPermission> BY_NAME;
+
+  static {
+    ImmutableMap.Builder<String, GlobalPermission> m = ImmutableMap.builder();
+    for (GlobalPermission p : values()) {
+      m.put(p.permissionName(), p);
+    }
+    BY_NAME = m.build();
+  }
+
+  @Nullable
+  public static GlobalPermission byName(String name) {
+    return BY_NAME.get(name);
+  }
+
+  /**
+   * Extracts the {@code @RequiresCapability} or {@code @RequiresAnyCapability} annotation.
+   *
+   * @param pluginName name of the declaring plugin. May be {@code null} or {@code "gerrit"} for
+   *     classes originating from the core server.
+   * @param clazz target class to extract annotation from.
+   * @return empty set if no annotations were found, or a collection of permissions, any of which
+   *     are suitable to enable access.
+   * @throws PermissionBackendException the annotation could not be parsed.
+   */
+  public static Set<GlobalOrPluginPermission> fromAnnotation(
+      @Nullable String pluginName, Class<?> clazz) throws PermissionBackendException {
+    RequiresCapability rc = findAnnotation(clazz, RequiresCapability.class);
+    RequiresAnyCapability rac = findAnnotation(clazz, RequiresAnyCapability.class);
+    if (rc != null && rac != null) {
+      log.error(
+          "Class {} uses both @{} and @{}",
+          clazz.getName(),
+          RequiresCapability.class.getSimpleName(),
+          RequiresAnyCapability.class.getSimpleName());
+      throw new PermissionBackendException("cannot extract permission");
+    } else if (rc != null) {
+      return Collections.singleton(
+          resolve(
+              pluginName,
+              rc.value(),
+              rc.scope(),
+              rc.fallBackToAdmin(),
+              clazz,
+              RequiresCapability.class));
+    } else if (rac != null) {
+      Set<GlobalOrPluginPermission> r = new LinkedHashSet<>();
+      for (String capability : rac.value()) {
+        r.add(
+            resolve(
+                pluginName,
+                capability,
+                rac.scope(),
+                rac.fallBackToAdmin(),
+                clazz,
+                RequiresAnyCapability.class));
+      }
+      return Collections.unmodifiableSet(r);
+    } else {
+      return Collections.emptySet();
+    }
+  }
+
+  public static Set<GlobalOrPluginPermission> fromAnnotation(Class<?> clazz)
+      throws PermissionBackendException {
+    return fromAnnotation(null, clazz);
+  }
+
+  private final String name;
+
+  GlobalPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  @Override
+  public String permissionName() {
+    return name;
+  }
+
+  @Override
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+
+  private static GlobalOrPluginPermission resolve(
+      @Nullable String pluginName,
+      String capability,
+      CapabilityScope scope,
+      boolean fallBackToAdmin,
+      Class<?> clazz,
+      Class<?> annotationClass)
+      throws PermissionBackendException {
+    if (pluginName != null
+        && !"gerrit".equals(pluginName)
+        && (scope == CapabilityScope.PLUGIN || scope == CapabilityScope.CONTEXT)) {
+      return new PluginPermission(pluginName, capability, fallBackToAdmin);
+    }
+
+    if (scope == CapabilityScope.PLUGIN) {
+      log.error(
+          "Class {} uses @{}(scope={}), but is not within a plugin",
+          clazz.getName(),
+          annotationClass.getSimpleName(),
+          scope.name());
+      throw new PermissionBackendException("cannot extract permission");
+    }
+
+    GlobalPermission perm = byName(capability);
+    if (perm == null) {
+      log.error("Class {} requires unknown capability {}", clazz.getName(), capability);
+      throw new PermissionBackendException("cannot extract permission");
+    }
+    return perm;
+  }
+
+  @Nullable
+  private static <T extends Annotation> T findAnnotation(Class<?> clazz, Class<T> annotation) {
+    for (; clazz != null; clazz = clazz.getSuperclass()) {
+      T t = clazz.getAnnotation(annotation);
+      if (t != null) {
+        return t;
+      }
+    }
+    return null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java
new file mode 100644
index 0000000..747c997
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/LabelPermission.java
@@ -0,0 +1,273 @@
+// 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.permissions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.SELF;
+
+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.server.util.LabelVote;
+import java.util.Optional;
+
+/** Permission representing a label. */
+public class LabelPermission implements ChangePermissionOrLabel {
+  public enum ForUser {
+    SELF,
+    ON_BEHALF_OF;
+  }
+
+  private final ForUser forUser;
+  private final String name;
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param type type description of the label.
+   */
+  public LabelPermission(LabelType type) {
+    this(SELF, type);
+  }
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+   * @param type type description of the label.
+   */
+  public LabelPermission(ForUser forUser, LabelType type) {
+    this(forUser, type.getName());
+  }
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public LabelPermission(String name) {
+    this(SELF, name);
+  }
+
+  /**
+   * Construct a reference to a label permission.
+   *
+   * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+   * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+   */
+  public LabelPermission(ForUser forUser, String name) {
+    this.forUser = checkNotNull(forUser, "ForUser");
+    this.name = LabelType.checkName(name);
+  }
+
+  /** @return {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+  public ForUser forUser() {
+    return forUser;
+  }
+
+  /** @return name of the label, e.g. {@code "Code-Review"}. */
+  public String label() {
+    return name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  @Override
+  public Optional<String> permissionName() {
+    switch (forUser) {
+      case SELF:
+        return Optional.of(Permission.forLabel(name));
+      case ON_BEHALF_OF:
+        return Optional.of(Permission.forLabelAs(name));
+    }
+    return Optional.empty();
+  }
+
+  @Override
+  public String describeForException() {
+    if (forUser == ON_BEHALF_OF) {
+      return "labelAs " + name;
+    }
+    return "label " + name;
+  }
+
+  @Override
+  public int hashCode() {
+    return name.hashCode();
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (other instanceof LabelPermission) {
+      LabelPermission b = (LabelPermission) other;
+      return forUser == b.forUser && name.equals(b.name);
+    }
+    return false;
+  }
+
+  @Override
+  public String toString() {
+    if (forUser == ON_BEHALF_OF) {
+      return "LabelAs[" + name + ']';
+    }
+    return "Label[" + name + ']';
+  }
+
+  /** A {@link LabelPermission} at a specific value. */
+  public static class WithValue implements ChangePermissionOrLabel {
+    private final ForUser forUser;
+    private final LabelVote label;
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, LabelValue value) {
+      this(SELF, type, value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(LabelType type, short value) {
+      this(SELF, type.getName(), value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(ForUser forUser, LabelType type, LabelValue value) {
+      this(forUser, type.getName(), value.getValue());
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param type description of the label.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(ForUser forUser, LabelType type, short value) {
+      this(forUser, type.getName(), value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(String name, short value) {
+      this(SELF, name, value);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param name name of the label, e.g. {@code "Code-Review"} or {@code "Verified"}.
+     * @param value numeric score assigned to the label.
+     */
+    public WithValue(ForUser forUser, String name, short value) {
+      this(forUser, LabelVote.create(name, value));
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param label label name and vote.
+     */
+    public WithValue(LabelVote label) {
+      this(SELF, label);
+    }
+
+    /**
+     * Construct a reference to a label at a specific value.
+     *
+     * @param forUser {@code SELF} (default) or {@code ON_BEHALF_OF} for labelAs behavior.
+     * @param label label name and vote.
+     */
+    public WithValue(ForUser forUser, LabelVote label) {
+      this.forUser = checkNotNull(forUser, "ForUser");
+      this.label = checkNotNull(label, "LabelVote");
+    }
+
+    /** @return {@code SELF} or {@code ON_BEHALF_OF} (or labelAs). */
+    public ForUser forUser() {
+      return forUser;
+    }
+
+    /** @return name of the label, e.g. {@code "Code-Review"}. */
+    public String label() {
+      return label.label();
+    }
+
+    /** @return specific value of the label, e.g. 1 or 2. */
+    public short value() {
+      return label.value();
+    }
+
+    /** @return name used in {@code project.config} permissions. */
+    @Override
+    public Optional<String> permissionName() {
+      switch (forUser) {
+        case SELF:
+          return Optional.of(Permission.forLabel(label()));
+        case ON_BEHALF_OF:
+          return Optional.of(Permission.forLabelAs(label()));
+      }
+      return Optional.empty();
+    }
+
+    @Override
+    public String describeForException() {
+      if (forUser == ON_BEHALF_OF) {
+        return "labelAs " + label.formatWithEquals();
+      }
+      return "label " + label.formatWithEquals();
+    }
+
+    @Override
+    public int hashCode() {
+      return label.hashCode();
+    }
+
+    @Override
+    public boolean equals(Object other) {
+      if (other instanceof WithValue) {
+        WithValue b = (WithValue) other;
+        return forUser == b.forUser && label.equals(b.label);
+      }
+      return false;
+    }
+
+    @Override
+    public String toString() {
+      if (forUser == ON_BEHALF_OF) {
+        return "LabelAs[" + label.format() + ']';
+      }
+      return "Label[" + label.format() + ']';
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java
new file mode 100644
index 0000000..ffd6094
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackend.java
@@ -0,0 +1,441 @@
+// 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.permissions;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.DefaultPermissionBackend;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.ImplementedBy;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.Iterator;
+import java.util.Set;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Checks authorization to perform an action on a project, reference, or change.
+ *
+ * <p>{@code check} methods should be used during action handlers to verify the user is allowed to
+ * exercise the specified permission. For convenience in implementation {@code check} methods throw
+ * {@link AuthException} if the permission is denied.
+ *
+ * <p>{@code test} methods should be used when constructing replies to the client and the result
+ * object needs to include a true/false hint indicating the user's ability to exercise the
+ * permission. This is suitable for configuring UI button state, but should not be relied upon to
+ * guard handlers before making state changes.
+ *
+ * <p>{@code PermissionBackend} is a singleton for the server, acting as a factory for lightweight
+ * request instances. Implementation classes may cache supporting data inside of {@link WithUser},
+ * {@link ForProject}, {@link ForRef}, and {@link ForChange} instances, in addition to storing
+ * within {@link CurrentUser} using a {@link com.google.gerrit.server.CurrentUser.PropertyKey}.
+ * {@link GlobalPermission} caching for {@link WithUser} may best cached inside {@link CurrentUser}
+ * as {@link WithUser} instances are frequently created.
+ *
+ * <p>Example use:
+ *
+ * <pre>
+ *   private final PermissionBackend permissions;
+ *   private final Provider<CurrentUser> user;
+ *
+ *   @Inject
+ *   Foo(PermissionBackend permissions, Provider<CurrentUser> user) {
+ *     this.permissions = permissions;
+ *     this.user = user;
+ *   }
+ *
+ *   public void apply(...) {
+ *     permissions.user(user).change(cd).check(ChangePermission.SUBMIT);
+ *   }
+ *
+ *   public UiAction.Description getDescription(ChangeResource rsrc) {
+ *     return new UiAction.Description()
+ *       .setLabel("Submit")
+ *       .setVisible(rsrc.permissions().testCond(ChangePermission.SUBMIT));
+ * }
+ * </pre>
+ */
+@ImplementedBy(DefaultPermissionBackend.class)
+public abstract class PermissionBackend {
+  private static final Logger logger = LoggerFactory.getLogger(PermissionBackend.class);
+
+  /** @return lightweight factory scoped to answer for the specified user. */
+  public abstract WithUser user(CurrentUser user);
+
+  /** @return lightweight factory scoped to answer for the specified user. */
+  public <U extends CurrentUser> WithUser user(Provider<U> user) {
+    return user(checkNotNull(user, "Provider<CurrentUser>").get());
+  }
+
+  /**
+   * Bulk evaluate a collection of {@link PermissionBackendCondition} for view handling.
+   *
+   * <p>Overridden implementations should call {@link PermissionBackendCondition#set(boolean)} to
+   * cache the result of {@code testOrFalse} in the condition for later evaluation. Caching the
+   * result will bypass the usual invocation of {@code testOrFalse}.
+   *
+   * <p>{@code conds} may contain duplicate entries (such as same user, resource, permission
+   * triplet). When duplicates exist, implementations should set a result into all instances to
+   * ensure {@code testOrFalse} does not get invoked during evaluation of the containing condition.
+   *
+   * @param conds conditions to consider.
+   */
+  public void bulkEvaluateTest(Collection<PermissionBackendCondition> conds) {
+    // Do nothing by default. The default implementation of PermissionBackendCondition
+    // delegates to the appropriate testOrFalse method in PermissionBackend.
+  }
+
+  /** PermissionBackend with an optional per-request ReviewDb handle. */
+  public abstract static class AcceptsReviewDb<T> {
+    protected Provider<ReviewDb> db;
+
+    public T database(Provider<ReviewDb> db) {
+      if (db != null) {
+        this.db = db;
+      }
+      return self();
+    }
+
+    public T database(ReviewDb db) {
+      return database(Providers.of(checkNotNull(db, "ReviewDb")));
+    }
+
+    @SuppressWarnings("unchecked")
+    private T self() {
+      return (T) this;
+    }
+  }
+
+  /** PermissionBackend scoped to a specific user. */
+  public abstract static class WithUser extends AcceptsReviewDb<WithUser> {
+    /** @return instance scoped for the specified project. */
+    public abstract ForProject project(Project.NameKey project);
+
+    /** @return instance scoped for the {@code ref}, and its parent project. */
+    public ForRef ref(Branch.NameKey ref) {
+      return project(ref.getParentKey()).ref(ref.get()).database(db);
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeData cd) {
+      try {
+        return ref(cd.change().getDest()).change(cd);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeNotes notes) {
+      return ref(notes.getChange().getDest()).change(notes);
+    }
+
+    /**
+     * @return instance scoped for the change loaded from index, and its destination ref and
+     *     project. This method should only be used when database access is harmful and potentially
+     *     stale data from the index is acceptable.
+     */
+    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
+      return ref(notes.getChange().getDest()).indexedChange(cd, notes);
+    }
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(GlobalOrPluginPermission perm)
+        throws AuthException, PermissionBackendException;
+
+    /**
+     * Verify scoped user can perform at least one listed permission.
+     *
+     * <p>If {@code any} is empty, the method completes normally and allows the caller to continue.
+     * Since no permissions were supplied to check, its assumed no permissions are necessary to
+     * continue with the caller's operation.
+     *
+     * <p>If the user has at least one of the permissions in {@code any}, the method completes
+     * normally, possibly without checking all listed permissions.
+     *
+     * <p>If {@code any} is non-empty and the user has none, {@link AuthException} is thrown for one
+     * of the failed permissions.
+     *
+     * @param any set of permissions to check.
+     */
+    public void checkAny(Set<GlobalOrPluginPermission> any)
+        throws PermissionBackendException, AuthException {
+      for (Iterator<GlobalOrPluginPermission> itr = any.iterator(); itr.hasNext(); ) {
+        try {
+          check(itr.next());
+          return;
+        } catch (AuthException err) {
+          if (!itr.hasNext()) {
+            throw err;
+          }
+        }
+      }
+    }
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(GlobalOrPluginPermission perm) throws PermissionBackendException {
+      return test(Collections.singleton(perm)).contains(perm);
+    }
+
+    public boolean testOrFalse(GlobalOrPluginPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    public BooleanCondition testCond(GlobalOrPluginPermission perm) {
+      return new PermissionBackendCondition.WithUser(this, perm);
+    }
+
+    /**
+     * Filter a set of projects using {@code check(perm)}.
+     *
+     * @param perm required permission in a project to be included in result.
+     * @param projects candidate set of projects; may be empty.
+     * @return filtered set of {@code projects} where {@code check(perm)} was successful.
+     * @throws PermissionBackendException backend cannot access its internal state.
+     */
+    public Set<Project.NameKey> filter(ProjectPermission perm, Collection<Project.NameKey> projects)
+        throws PermissionBackendException {
+      checkNotNull(perm, "ProjectPermission");
+      checkNotNull(projects, "projects");
+      Set<Project.NameKey> allowed = Sets.newHashSetWithExpectedSize(projects.size());
+      for (Project.NameKey project : projects) {
+        try {
+          project(project).check(perm);
+          allowed.add(project);
+        } catch (AuthException e) {
+          // Do not include this project in allowed.
+        } catch (PermissionBackendException e) {
+          if (e.getCause() instanceof RepositoryNotFoundException) {
+            logger.warn("Could not find repository of the project {} : ", project.get(), e);
+            // Do not include this project because doesn't exist
+          } else {
+            throw e;
+          }
+        }
+      }
+      return allowed;
+    }
+  }
+
+  /** PermissionBackend scoped to a user and project. */
+  public abstract static class ForProject extends AcceptsReviewDb<ForProject> {
+    /** @return new instance rescoped to same project, but different {@code user}. */
+    public abstract ForProject user(CurrentUser user);
+
+    /** @return instance scoped for {@code ref} in this project. */
+    public abstract ForRef ref(String ref);
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeData cd) {
+      try {
+        return ref(cd.change().getDest().get()).change(cd);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    /** @return instance scoped for the change, and its destination ref and project. */
+    public ForChange change(ChangeNotes notes) {
+      return ref(notes.getChange().getDest().get()).change(notes);
+    }
+
+    /**
+     * @return instance scoped for the change loaded from index, and its destination ref and
+     *     project. This method should only be used when database access is harmful and potentially
+     *     stale data from the index is acceptable.
+     */
+    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
+      return ref(notes.getChange().getDest().get()).indexedChange(cd, notes);
+    }
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(ProjectPermission perm)
+        throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(ProjectPermission perm) throws PermissionBackendException {
+      return test(EnumSet.of(perm)).contains(perm);
+    }
+
+    public boolean testOrFalse(ProjectPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    public BooleanCondition testCond(ProjectPermission perm) {
+      return new PermissionBackendCondition.ForProject(this, perm);
+    }
+  }
+
+  /** PermissionBackend scoped to a user, project and reference. */
+  public abstract static class ForRef extends AcceptsReviewDb<ForRef> {
+    /** @return new instance rescoped to same reference, but different {@code user}. */
+    public abstract ForRef user(CurrentUser user);
+
+    /** @return instance scoped to change. */
+    public abstract ForChange change(ChangeData cd);
+
+    /** @return instance scoped to change. */
+    public abstract ForChange change(ChangeNotes notes);
+
+    /**
+     * @return instance scoped to change loaded from index. This method should only be used when
+     *     database access is harmful and potentially stale data from the index is acceptable.
+     */
+    public abstract ForChange indexedChange(ChangeData cd, ChangeNotes notes);
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(RefPermission perm) throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(RefPermission perm) throws PermissionBackendException {
+      return test(EnumSet.of(perm)).contains(perm);
+    }
+
+    /**
+     * Test if user may be able to perform the permission.
+     *
+     * <p>Similar to {@link #test(RefPermission)} except this method returns {@code false} instead
+     * of throwing an exception.
+     *
+     * @param perm the permission to test.
+     * @return true if the user might be able to perform the permission; false if the user may be
+     *     missing the necessary grants or state, or if the backend threw an exception.
+     */
+    public boolean testOrFalse(RefPermission perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    public BooleanCondition testCond(RefPermission perm) {
+      return new PermissionBackendCondition.ForRef(this, perm);
+    }
+  }
+
+  /** PermissionBackend scoped to a user, project, reference and change. */
+  public abstract static class ForChange extends AcceptsReviewDb<ForChange> {
+    /** @return user this instance is scoped to. */
+    public abstract CurrentUser user();
+
+    /** @return new instance rescoped to same change, but different {@code user}. */
+    public abstract ForChange user(CurrentUser user);
+
+    /** Verify scoped user can {@code perm}, throwing if denied. */
+    public abstract void check(ChangePermissionOrLabel perm)
+        throws AuthException, PermissionBackendException;
+
+    /** Filter {@code permSet} to permissions scoped user might be able to perform. */
+    public abstract <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException;
+
+    public boolean test(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      return test(Collections.singleton(perm)).contains(perm);
+    }
+
+    /**
+     * Test if user may be able to perform the permission.
+     *
+     * <p>Similar to {@link #test(ChangePermissionOrLabel)} except this method returns {@code false}
+     * instead of throwing an exception.
+     *
+     * @param perm the permission to test.
+     * @return true if the user might be able to perform the permission; false if the user may be
+     *     missing the necessary grants or state, or if the backend threw an exception.
+     */
+    public boolean testOrFalse(ChangePermissionOrLabel perm) {
+      try {
+        return test(perm);
+      } catch (PermissionBackendException e) {
+        logger.warn("Cannot test " + perm + "; assuming false", e);
+        return false;
+      }
+    }
+
+    public BooleanCondition testCond(ChangePermissionOrLabel perm) {
+      return new PermissionBackendCondition.ForChange(this, perm);
+    }
+
+    /**
+     * Test which values of a label the user may be able to set.
+     *
+     * @param label definition of the label to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelPermission.WithValue> test(LabelType label) throws PermissionBackendException {
+      return test(valuesOf(checkNotNull(label, "LabelType")));
+    }
+
+    /**
+     * Test which values of a group of labels the user may be able to set.
+     *
+     * @param types definition of the labels to test values of.
+     * @return set containing values the user may be able to use; may be empty if none.
+     * @throws PermissionBackendException if failure consulting backend configuration.
+     */
+    public Set<LabelPermission.WithValue> testLabels(Collection<LabelType> types)
+        throws PermissionBackendException {
+      checkNotNull(types, "LabelType");
+      return test(types.stream().flatMap((t) -> valuesOf(t).stream()).collect(toSet()));
+    }
+
+    private static Set<LabelPermission.WithValue> valuesOf(LabelType label) {
+      return label
+          .getValues()
+          .stream()
+          .map((v) -> new LabelPermission.WithValue(label, v))
+          .collect(toSet());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendCondition.java
new file mode 100644
index 0000000..8d66e50
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendCondition.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.server.permissions;
+
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.conditions.BooleanCondition;
+import com.google.gerrit.extensions.conditions.PrivateInternals_BooleanCondition;
+
+/** {@link BooleanCondition} to evaluate a permission. */
+public abstract class PermissionBackendCondition
+    extends PrivateInternals_BooleanCondition.SubclassOnlyInCoreServer {
+  Boolean value;
+
+  /**
+   * Assign a specific {@code testOrFalse} result to this condition.
+   *
+   * <p>By setting the condition to a specific value the condition will bypass calling {@link
+   * PermissionBackend} during {@code value()}, and immediately return the set value instead.
+   *
+   * @param val value to return from {@code value()}.
+   */
+  public void set(boolean val) {
+    value = val;
+  }
+
+  @Override
+  public abstract String toString();
+
+  public static class WithUser extends PermissionBackendCondition {
+    private final PermissionBackend.WithUser impl;
+    private final GlobalOrPluginPermission perm;
+
+    WithUser(PermissionBackend.WithUser impl, GlobalOrPluginPermission perm) {
+      this.impl = impl;
+      this.perm = perm;
+    }
+
+    public PermissionBackend.WithUser withUser() {
+      return impl;
+    }
+
+    public GlobalOrPluginPermission permission() {
+      return perm;
+    }
+
+    @Override
+    public boolean value() {
+      return value != null ? value : impl.testOrFalse(perm);
+    }
+
+    @Override
+    public String toString() {
+      return "PermissionBackendCondition.WithUser(" + perm + ")";
+    }
+  }
+
+  public static class ForProject extends PermissionBackendCondition {
+    private final PermissionBackend.ForProject impl;
+    private final ProjectPermission perm;
+
+    ForProject(PermissionBackend.ForProject impl, ProjectPermission perm) {
+      this.impl = impl;
+      this.perm = perm;
+    }
+
+    public PermissionBackend.ForProject project() {
+      return impl;
+    }
+
+    public ProjectPermission permission() {
+      return perm;
+    }
+
+    @Override
+    public boolean value() {
+      return value != null ? value : impl.testOrFalse(perm);
+    }
+
+    @Override
+    public String toString() {
+      return "PermissionBackendCondition.ForProject(" + perm + ")";
+    }
+  }
+
+  public static class ForRef extends PermissionBackendCondition {
+    private final PermissionBackend.ForRef impl;
+    private final RefPermission perm;
+
+    ForRef(PermissionBackend.ForRef impl, RefPermission perm) {
+      this.impl = impl;
+      this.perm = perm;
+    }
+
+    public PermissionBackend.ForRef ref() {
+      return impl;
+    }
+
+    public RefPermission permission() {
+      return perm;
+    }
+
+    @Override
+    public boolean value() {
+      return value != null ? value : impl.testOrFalse(perm);
+    }
+
+    @Override
+    public String toString() {
+      return "PermissionBackendCondition.ForRef(" + perm + ")";
+    }
+  }
+
+  public static class ForChange extends PermissionBackendCondition {
+    private final PermissionBackend.ForChange impl;
+    private final ChangePermissionOrLabel perm;
+
+    ForChange(PermissionBackend.ForChange impl, ChangePermissionOrLabel perm) {
+      this.impl = impl;
+      this.perm = perm;
+    }
+
+    public PermissionBackend.ForChange change() {
+      return impl;
+    }
+
+    public ChangePermissionOrLabel permission() {
+      return perm;
+    }
+
+    @Override
+    public boolean value() {
+      return value != null ? value : impl.testOrFalse(perm);
+    }
+
+    @Override
+    public String toString() {
+      return "PermissionBackendCondition.ForChange(" + perm + ")";
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java
new file mode 100644
index 0000000..3634a45
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/PermissionBackendException.java
@@ -0,0 +1,39 @@
+// 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.permissions;
+
+import com.google.gerrit.common.Nullable;
+
+/**
+ * Thrown when {@link PermissionBackend} cannot compute the result.
+ *
+ * <p>This is typically a transient failure, such as a required group backend not responding to
+ * membership requests.
+ */
+public class PermissionBackendException extends Exception {
+  private static final long serialVersionUID = 1L;
+
+  public PermissionBackendException(String message) {
+    super(message);
+  }
+
+  public PermissionBackendException(@Nullable Throwable cause) {
+    super(cause);
+  }
+
+  public PermissionBackendException(String message, @Nullable Throwable cause) {
+    super(message, cause);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ProjectPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ProjectPermission.java
new file mode 100644
index 0000000..5e8bbc4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/ProjectPermission.java
@@ -0,0 +1,114 @@
+// 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.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum ProjectPermission {
+  /**
+   * Can access at least one reference or change within the repository.
+   *
+   * <p>Checking this permission instead of {@link #READ} may require filtering to hide specific
+   * references or changes, which can be expensive.
+   */
+  ACCESS,
+
+  /**
+   * Can read all references in the repository.
+   *
+   * <p>This is a stronger form of {@link #ACCESS} where no filtering is required.
+   */
+  READ(Permission.READ),
+
+  /**
+   * Can read all non-config references in the repository.
+   *
+   * <p>This is the same as {@code READ} but does not check if they user can see refs/meta/config.
+   * Therefore, callers should check {@code READ} before excluding config refs in a short-circuit.
+   */
+  READ_NO_CONFIG,
+
+  /**
+   * Can create at least one reference in the project.
+   *
+   * <p>This project level permission only validates the user may create some type of reference
+   * within the project. The exact reference name must be checked at creation:
+   *
+   * <pre>permissionBackend
+   *    .user(user)
+   *    .project(proj)
+   *    .ref(ref)
+   *    .check(RefPermission.CREATE);
+   * </pre>
+   */
+  CREATE_REF,
+
+  /**
+   * Can create at least one tag reference in the project.
+   *
+   * <p>This project level permission only validates the user may create some tag reference within
+   * the project. The exact reference name must be checked at creation:
+   *
+   * <pre>permissionBackend
+   *    .user(user)
+   *    .project(proj)
+   *    .ref(ref)
+   *    .check(RefPermission.CREATE);
+   * </pre>
+   */
+  CREATE_TAG_REF,
+
+  /**
+   * Can create at least one change in the project.
+   *
+   * <p>This project level permission only validates the user may create a change for some branch
+   * within the project. The exact reference name must be checked at creation:
+   *
+   * <pre>permissionBackend
+   *    .user(user)
+   *    .project(proj)
+   *    .ref(ref)
+   *    .check(RefPermission.CREATE_CHANGE);
+   * </pre>
+   */
+  CREATE_CHANGE,
+
+  /** Can run receive pack. */
+  RUN_RECEIVE_PACK,
+
+  /** Can run upload pack. */
+  RUN_UPLOAD_PACK;
+
+  private final String name;
+
+  ProjectPermission() {
+    name = null;
+  }
+
+  ProjectPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
new file mode 100644
index 0000000..8b5d8fb
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/permissions/RefPermission.java
@@ -0,0 +1,64 @@
+// 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.permissions;
+
+import com.google.gerrit.common.data.Permission;
+import java.util.Locale;
+import java.util.Optional;
+
+public enum RefPermission {
+  READ(Permission.READ),
+  CREATE(Permission.CREATE),
+  DELETE(Permission.DELETE),
+  UPDATE(Permission.PUSH),
+  FORCE_UPDATE,
+
+  FORGE_AUTHOR(Permission.FORGE_AUTHOR),
+  FORGE_COMMITTER(Permission.FORGE_COMMITTER),
+  FORGE_SERVER(Permission.FORGE_SERVER),
+  MERGE,
+  SKIP_VALIDATION,
+
+  /** Create a change to code review a commit. */
+  CREATE_CHANGE,
+
+  /**
+   * Creates changes, then also immediately submits them during {@code push}.
+   *
+   * <p>This is similar to {@link #UPDATE} except it constructs changes first, then submits them
+   * according to the submit strategy, which may include cherry-pick or rebase. By creating changes
+   * for each commit, automatic server side rebase, and post-update review are enabled.
+   */
+  UPDATE_BY_SUBMIT;
+
+  private final String name;
+
+  RefPermission() {
+    name = null;
+  }
+
+  RefPermission(String name) {
+    this.name = name;
+  }
+
+  /** @return name used in {@code project.config} permissions. */
+  public Optional<String> permissionName() {
+    return Optional.ofNullable(name);
+  }
+
+  public String describeForException() {
+    return toString().toLowerCase(Locale.US).replace('_', ' ');
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
new file mode 100644
index 0000000..32adb9c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DelegatingClassLoader.java
@@ -0,0 +1,75 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.plugins;
+
+import com.google.common.io.ByteStreams;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.Enumeration;
+
+public class DelegatingClassLoader extends ClassLoader {
+  private final ClassLoader target;
+
+  public DelegatingClassLoader(ClassLoader parent, ClassLoader target) {
+    super(parent);
+    this.target = target;
+  }
+
+  @Override
+  public Class<?> findClass(String name) throws ClassNotFoundException {
+    String path = name.replace('.', '/') + ".class";
+    try (InputStream resource = target.getResourceAsStream(path)) {
+      if (resource != null) {
+        try {
+          byte[] bytes = ByteStreams.toByteArray(resource);
+          return defineClass(name, bytes, 0, bytes.length);
+        } catch (IOException e) {
+          // throws ClassNotFoundException later
+        }
+      }
+    } catch (IOException e) {
+      // throws ClassNotFoundException later
+    }
+    throw new ClassNotFoundException(name);
+  }
+
+  @Override
+  public URL getResource(String name) {
+    URL rtn = getParent().getResource(name);
+    if (rtn == null) {
+      rtn = target.getResource(name);
+    }
+    return rtn;
+  }
+
+  @Override
+  public Enumeration<URL> getResources(String name) throws IOException {
+    Enumeration<URL> rtn = getParent().getResources(name);
+    if (rtn == null) {
+      rtn = target.getResources(name);
+    }
+    return rtn;
+  }
+
+  @Override
+  public InputStream getResourceAsStream(String name) {
+    InputStream rtn = getParent().getResourceAsStream(name);
+    if (rtn == null) {
+      rtn = target.getResourceAsStream(name);
+    }
+    return rtn;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
index b63c6c0..a2da580 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/DisablePlugin.java
@@ -17,17 +17,17 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.plugins.DisablePlugin.Input;
-import com.google.gerrit.server.plugins.ListPlugins.PluginInfo;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
-class DisablePlugin implements RestModifyView<PluginResource, Input> {
-  static class Input {}
+public class DisablePlugin implements RestModifyView<PluginResource, Input> {
+  public static class Input {}
 
   private final PluginLoader loader;
 
@@ -43,6 +43,6 @@
     }
     String name = resource.getName();
     loader.disablePlugins(ImmutableSet.of(name));
-    return new PluginInfo(loader.get(name));
+    return ListPlugins.toPluginInfo(loader.get(name));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
index c3e52cb..f29e36b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/EnablePlugin.java
@@ -17,11 +17,11 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.server.plugins.EnablePlugin.Input;
-import com.google.gerrit.server.plugins.ListPlugins.PluginInfo;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.PrintWriter;
@@ -29,8 +29,8 @@
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
-class EnablePlugin implements RestModifyView<PluginResource, Input> {
-  static class Input {}
+public class EnablePlugin implements RestModifyView<PluginResource, Input> {
+  public static class Input {}
 
   private final PluginLoader loader;
 
@@ -56,6 +56,6 @@
       pw.flush();
       throw new ResourceConflictException(buf.toString());
     }
-    return new PluginInfo(loader.get(name));
+    return ListPlugins.toPluginInfo(loader.get(name));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/GetStatus.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/GetStatus.java
index 47f7985..cbd864a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/GetStatus.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/GetStatus.java
@@ -14,14 +14,14 @@
 
 package com.google.gerrit.server.plugins;
 
+import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.server.plugins.ListPlugins.PluginInfo;
 import com.google.inject.Singleton;
 
 @Singleton
-class GetStatus implements RestReadView<PluginResource> {
+public class GetStatus implements RestReadView<PluginResource> {
   @Override
   public PluginInfo apply(PluginResource resource) {
-    return new PluginInfo(resource.getPlugin());
+    return ListPlugins.toPluginInfo(resource.getPlugin());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
index 3882008..531e9ac 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/InstallPlugin.java
@@ -16,16 +16,15 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+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.DefaultInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
-import com.google.gerrit.server.plugins.InstallPlugin.Input;
-import com.google.gerrit.server.plugins.ListPlugins.PluginInfo;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.PrintWriter;
@@ -34,24 +33,29 @@
 import java.util.zip.ZipException;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-class InstallPlugin implements RestModifyView<TopLevelResource, Input> {
-  static class Input {
-    @DefaultInput String url;
-    RawInput raw;
+public class InstallPlugin implements RestModifyView<TopLevelResource, InstallPluginInput> {
+  private final PluginLoader loader;
+
+  private String name;
+  private boolean created;
+
+  @Inject
+  InstallPlugin(PluginLoader loader) {
+    this.loader = loader;
   }
 
-  private final PluginLoader loader;
-  private final String name;
-  private final boolean created;
-
-  InstallPlugin(PluginLoader loader, String name, boolean created) {
-    this.loader = loader;
+  public InstallPlugin setName(String name) {
     this.name = name;
+    return this;
+  }
+
+  public InstallPlugin setCreated(boolean created) {
     this.created = created;
+    return this;
   }
 
   @Override
-  public Response<PluginInfo> apply(TopLevelResource resource, Input input)
+  public Response<PluginInfo> apply(TopLevelResource resource, InstallPluginInput input)
       throws BadRequestException, MethodNotAllowedException, IOException {
     if (!loader.isRemoteAdminEnabled()) {
       throw new MethodNotAllowedException("remote installation is disabled");
@@ -59,7 +63,7 @@
     try {
       try (InputStream in = openStream(input)) {
         String pluginName = loader.installPluginFromStream(name, in);
-        ListPlugins.PluginInfo info = new ListPlugins.PluginInfo(loader.get(pluginName));
+        PluginInfo info = ListPlugins.toPluginInfo(loader.get(pluginName));
         return created ? Response.created(info) : Response.ok(info);
       }
     } catch (PluginInstallException e) {
@@ -78,7 +82,7 @@
     }
   }
 
-  private InputStream openStream(Input input) throws IOException, BadRequestException {
+  private InputStream openStream(InstallPluginInput input) throws IOException, BadRequestException {
     if (input.raw != null) {
       return input.raw.getInputStream();
     }
@@ -90,19 +94,18 @@
   }
 
   @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
-  static class Overwrite implements RestModifyView<PluginResource, Input> {
-    private final PluginLoader loader;
+  static class Overwrite implements RestModifyView<PluginResource, InstallPluginInput> {
+    private final Provider<InstallPlugin> install;
 
     @Inject
-    Overwrite(PluginLoader loader) {
-      this.loader = loader;
+    Overwrite(Provider<InstallPlugin> install) {
+      this.install = install;
     }
 
     @Override
-    public Response<PluginInfo> apply(PluginResource resource, Input input)
+    public Response<PluginInfo> apply(PluginResource resource, InstallPluginInput input)
         throws BadRequestException, MethodNotAllowedException, IOException {
-      return new InstallPlugin(loader, resource.getName(), false)
-          .apply(TopLevelResource.INSTANCE, input);
+      return install.get().setName(resource.getName()).apply(TopLevelResource.INSTANCE, input);
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java
index b10d8ab..f147154 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarPluginProvider.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.server.plugins;
 
-import static com.google.gerrit.server.plugins.PluginLoader.asTemp;
-
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
@@ -62,7 +60,7 @@
   @Override
   public String getPluginName(Path srcPath) {
     try {
-      return MoreObjects.firstNonNull(getJarPluginName(srcPath), PluginLoader.nameOf(srcPath));
+      return MoreObjects.firstNonNull(getJarPluginName(srcPath), PluginUtil.nameOf(srcPath));
     } catch (IOException e) {
       throw new IllegalArgumentException(
           "Invalid plugin file " + srcPath + ": cannot get plugin name", e);
@@ -82,7 +80,7 @@
       String name = getPluginName(srcPath);
       String extension = getExtension(srcPath);
       try (InputStream in = Files.newInputStream(srcPath)) {
-        Path tmp = asTemp(in, tempNameFor(name), extension, tmpDir);
+        Path tmp = PluginUtil.asTemp(in, tempNameFor(name), extension, tmpDir);
         return loadJarPlugin(name, srcPath, snapshot, tmp, description);
       }
     } catch (IOException e) {
@@ -114,7 +112,7 @@
     if (!Files.exists(sitePaths.tmp_dir)) {
       Files.createDirectories(sitePaths.tmp_dir);
     }
-    return asTemp(in, tempNameFor(pluginName), ".jar", sitePaths.tmp_dir);
+    return PluginUtil.asTemp(in, tempNameFor(pluginName), ".jar", sitePaths.tmp_dir);
   }
 
   private ServerPlugin loadJarPlugin(
@@ -138,7 +136,7 @@
       urls.add(tmp.toUri().toURL());
 
       ClassLoader pluginLoader =
-          new URLClassLoader(urls.toArray(new URL[urls.size()]), PluginLoader.parentFor(type));
+          new URLClassLoader(urls.toArray(new URL[urls.size()]), PluginUtil.parentFor(type));
 
       JarScanner jarScanner = createJarScanner(tmp);
       PluginConfig pluginConfig = configFactory.getFromGerritConfig(name);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
index 625bf9e..12028b60 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JsPlugin.java
@@ -40,7 +40,11 @@
     String fileName = getSrcFile().getFileName().toString();
     int firstDash = fileName.indexOf("-");
     if (firstDash > 0) {
-      return fileName.substring(firstDash + 1, fileName.lastIndexOf(".js"));
+      int extension =
+          fileName.endsWith(".js") ? fileName.lastIndexOf(".js") : fileName.lastIndexOf(".html");
+      if (extension > 0) {
+        return fileName.substring(firstDash + 1, extension);
+      }
     }
     return "";
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
index c95ae85..ee7cd5c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ListPlugins.java
@@ -14,24 +14,24 @@
 
 package com.google.gerrit.server.plugins;
 
-import com.google.common.base.Strings;
-import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.collect.Streams;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.plugins.Plugins;
+import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.OutputFormat;
-import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
-import java.io.PrintWriter;
-import java.util.Collections;
-import java.util.Comparator;
-import java.util.List;
-import java.util.Map;
+import java.util.Locale;
 import java.util.SortedMap;
 import java.util.TreeMap;
+import java.util.regex.Pattern;
+import java.util.stream.Stream;
 import org.kohsuke.args4j.Option;
 
 /** List the installed plugins. */
@@ -39,94 +39,126 @@
 public class ListPlugins implements RestReadView<TopLevelResource> {
   private final PluginLoader pluginLoader;
 
-  @Deprecated
-  @Option(name = "--format", usage = "(deprecated) output format")
-  private OutputFormat format = OutputFormat.TEXT;
+  private boolean all;
+  private int limit;
+  private int start;
+  private String matchPrefix;
+  private String matchSubstring;
+  private String matchRegex;
 
   @Option(
       name = "--all",
       aliases = {"-a"},
       usage = "List all plugins, including disabled plugins")
-  private boolean all;
+  public void setAll(boolean all) {
+    this.all = all;
+  }
+
+  @Option(
+      name = "--limit",
+      aliases = {"-n"},
+      metaVar = "CNT",
+      usage = "maximum number of plugins to list")
+  public void setLimit(int limit) {
+    this.limit = limit;
+  }
+
+  @Option(
+      name = "--start",
+      aliases = {"-S"},
+      metaVar = "CNT",
+      usage = "number of plugins to skip")
+  public void setStart(int start) {
+    this.start = start;
+  }
+
+  @Option(
+      name = "--prefix",
+      aliases = {"-p"},
+      metaVar = "PREFIX",
+      usage = "match plugin prefix")
+  public void setMatchPrefix(String matchPrefix) {
+    this.matchPrefix = matchPrefix;
+  }
+
+  @Option(
+      name = "--match",
+      aliases = {"-m"},
+      metaVar = "MATCH",
+      usage = "match plugin substring")
+  public void setMatchSubstring(String matchSubstring) {
+    this.matchSubstring = matchSubstring;
+  }
+
+  @Option(name = "-r", metaVar = "REGEX", usage = "match plugin regex")
+  public void setMatchRegex(String matchRegex) {
+    this.matchRegex = matchRegex;
+  }
 
   @Inject
   protected ListPlugins(PluginLoader pluginLoader) {
     this.pluginLoader = pluginLoader;
   }
 
-  public OutputFormat getFormat() {
-    return format;
-  }
-
-  public ListPlugins setFormat(OutputFormat fmt) {
-    this.format = fmt;
+  public ListPlugins request(Plugins.ListRequest request) {
+    this.setAll(request.getAll());
+    this.setStart(request.getStart());
+    this.setLimit(request.getLimit());
+    this.setMatchPrefix(request.getPrefix());
+    this.setMatchSubstring(request.getSubstring());
+    this.setMatchRegex(request.getRegex());
     return this;
   }
 
   @Override
-  public Object apply(TopLevelResource resource) {
-    format = OutputFormat.JSON;
-    return display(null);
+  public SortedMap<String, PluginInfo> apply(TopLevelResource resource) throws BadRequestException {
+    Stream<Plugin> s = Streams.stream(pluginLoader.getPlugins(all));
+    if (matchPrefix != null) {
+      checkMatchOptions(matchSubstring == null && matchRegex == null);
+      s = s.filter(p -> p.getName().startsWith(matchPrefix));
+    } else if (matchSubstring != null) {
+      checkMatchOptions(matchPrefix == null && matchRegex == null);
+      String substring = matchSubstring.toLowerCase(Locale.US);
+      s = s.filter(p -> p.getName().toLowerCase(Locale.US).contains(substring));
+    } else if (matchRegex != null) {
+      checkMatchOptions(matchPrefix == null && matchSubstring == null);
+      Pattern pattern = Pattern.compile(matchRegex);
+      s = s.filter(p -> pattern.matcher(p.getName()).matches());
+    }
+    s = s.sorted(comparing(Plugin::getName));
+    if (start > 0) {
+      s = s.skip(start);
+    }
+    if (limit > 0) {
+      s = s.limit(limit);
+    }
+    return new TreeMap<>(s.collect(toMap(p -> p.getName(), p -> toPluginInfo(p))));
   }
 
-  public SortedMap<String, PluginInfo> display(@Nullable PrintWriter stdout) {
-    SortedMap<String, PluginInfo> output = new TreeMap<>();
-    List<Plugin> plugins = Lists.newArrayList(pluginLoader.getPlugins(all));
-    Collections.sort(
-        plugins,
-        new Comparator<Plugin>() {
-          @Override
-          public int compare(Plugin a, Plugin b) {
-            return a.getName().compareTo(b.getName());
-          }
-        });
-
-    if (!format.isJson()) {
-      stdout.format("%-30s %-10s %-8s %s\n", "Name", "Version", "Status", "File");
-      stdout.print(
-          "-------------------------------------------------------------------------------\n");
+  private void checkMatchOptions(boolean cond) throws BadRequestException {
+    if (!cond) {
+      throw new BadRequestException("specify exactly one of p/m/r");
     }
-
-    for (Plugin p : plugins) {
-      PluginInfo info = new PluginInfo(p);
-      if (format.isJson()) {
-        output.put(p.getName(), info);
-      } else {
-        stdout.format(
-            "%-30s %-10s %-8s %s\n",
-            p.getName(),
-            Strings.nullToEmpty(info.version),
-            p.isDisabled() ? "DISABLED" : "ENABLED",
-            p.getSrcFile().getFileName());
-      }
-    }
-
-    if (stdout == null) {
-      return output;
-    } else if (format.isJson()) {
-      format
-          .newGson()
-          .toJson(output, new TypeToken<Map<String, PluginInfo>>() {}.getType(), stdout);
-      stdout.print('\n');
-    }
-    stdout.flush();
-    return null;
   }
 
-  static class PluginInfo {
+  public static PluginInfo toPluginInfo(Plugin p) {
     String id;
     String version;
     String indexUrl;
+    String filename;
     Boolean disabled;
 
-    PluginInfo(Plugin p) {
-      id = Url.encode(p.getName());
-      version = p.getVersion();
-      disabled = p.isDisabled() ? true : null;
-
-      if (p.getSrcFile() != null) {
-        indexUrl = String.format("plugins/%s/", p.getName());
-      }
+    id = Url.encode(p.getName());
+    version = p.getVersion();
+    disabled = p.isDisabled() ? true : null;
+    if (p.getSrcFile() != null) {
+      indexUrl = String.format("plugins/%s/", p.getName());
+      filename = p.getSrcFile().getFileName().toString();
+    } else {
+      indexUrl = null;
+      filename = null;
     }
+
+    return new PluginInfo(id, version, indexUrl, filename, disabled);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
index 1453854..4bf5aa3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/MultipleProvidersForPluginException.java
@@ -16,8 +16,8 @@
 
 import static java.util.stream.Collectors.joining;
 
+import com.google.common.collect.Streams;
 import java.nio.file.Path;
-import java.util.stream.StreamSupport;
 
 class MultipleProvidersForPluginException extends IllegalArgumentException {
   private static final long serialVersionUID = 1L;
@@ -31,7 +31,7 @@
   }
 
   private static String providersListToString(Iterable<ServerPluginProvider> providersHandlers) {
-    return StreamSupport.stream(providersHandlers.spliterator(), false)
+    return Streams.stream(providersHandlers)
         .map(ServerPluginProvider::getProviderPluginName)
         .collect(joining(", "));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
index 740e8d3..effd51a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginGuiceEnvironment.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.extensions.registration.ReloadableRegistrationHandle;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.extensions.webui.WebUiPlugin;
+import com.google.gerrit.index.IndexCollection;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.util.PluginRequestContext;
 import com.google.gerrit.server.util.RequestContext;
@@ -568,7 +569,7 @@
     Class<?> type = key.getTypeLiteral().getRawType();
     if (LifecycleListener.class.isAssignableFrom(type)
         // This is needed for secondary index to work from plugin listeners
-        && !is("com.google.gerrit.server.index.IndexCollection", type)) {
+        && !IndexCollection.class.isAssignableFrom(type)) {
       return false;
     }
     if (StartPluginListener.class.isAssignableFrom(type)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
index 42998c5..d366dbf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginLoader.java
@@ -24,14 +24,10 @@
 import com.google.common.collect.LinkedHashMultimap;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
-import com.google.common.collect.Ordering;
 import com.google.common.collect.SetMultimap;
 import com.google.common.collect.Sets;
-import com.google.common.io.ByteStreams;
-import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.systemstatus.ServerInformation;
-import com.google.gerrit.extensions.webui.JavaScriptPlugin;
 import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.cache.PersistentCacheFactory;
 import com.google.gerrit.server.config.CanonicalWebUrl;
@@ -44,7 +40,6 @@
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.io.InputStream;
-import java.io.OutputStream;
 import java.nio.file.DirectoryStream;
 import java.nio.file.Files;
 import java.nio.file.Path;
@@ -74,7 +69,7 @@
   private static final Logger log = LoggerFactory.getLogger(PluginLoader.class);
 
   public String getPluginName(Path srcPath) {
-    return MoreObjects.firstNonNull(getGerritPluginName(srcPath), nameOf(srcPath));
+    return MoreObjects.firstNonNull(getGerritPluginName(srcPath), PluginUtil.nameOf(srcPath));
   }
 
   private final Path pluginsDir;
@@ -139,32 +134,6 @@
     }
   }
 
-  public static List<Path> listPlugins(Path pluginsDir, final String suffix) throws IOException {
-    if (pluginsDir == null || !Files.exists(pluginsDir)) {
-      return ImmutableList.of();
-    }
-    DirectoryStream.Filter<Path> filter =
-        new DirectoryStream.Filter<Path>() {
-          @Override
-          public boolean accept(Path entry) throws IOException {
-            String n = entry.getFileName().toString();
-            boolean accept =
-                !n.startsWith(".last_") && !n.startsWith(".next_") && Files.isRegularFile(entry);
-            if (!Strings.isNullOrEmpty(suffix)) {
-              accept &= n.endsWith(suffix);
-            }
-            return accept;
-          }
-        };
-    try (DirectoryStream<Path> files = Files.newDirectoryStream(pluginsDir, filter)) {
-      return Ordering.natural().sortedCopy(files);
-    }
-  }
-
-  public static List<Path> listPlugins(Path pluginsDir) throws IOException {
-    return listPlugins(pluginsDir, null);
-  }
-
   public boolean isRemoteAdminEnabled() {
     return remoteAdmin;
   }
@@ -191,8 +160,8 @@
     checkRemoteInstall();
 
     String fileName = originalName;
-    Path tmp = asTemp(in, ".next_" + fileName + "_", ".tmp", pluginsDir);
-    String name = MoreObjects.firstNonNull(getGerritPluginName(tmp), nameOf(fileName));
+    Path tmp = PluginUtil.asTemp(in, ".next_" + fileName + "_", ".tmp", pluginsDir);
+    String name = MoreObjects.firstNonNull(getGerritPluginName(tmp), PluginUtil.nameOf(fileName));
     if (!originalName.equals(name)) {
       log.warn(
           "Plugin provides its own name: <{}>, use it instead of the input name: <{}>",
@@ -230,20 +199,6 @@
     return name;
   }
 
-  static Path asTemp(InputStream in, String prefix, String suffix, Path dir) throws IOException {
-    Path tmp = Files.createTempFile(dir, prefix, suffix);
-    boolean keep = false;
-    try (OutputStream out = Files.newOutputStream(tmp)) {
-      ByteStreams.copy(in, out);
-      keep = true;
-      return tmp;
-    } finally {
-      if (!keep) {
-        Files.delete(tmp);
-      }
-    }
-  }
-
   private synchronized void unloadPlugin(Plugin plugin) {
     persistentCacheFactory.onStop(plugin);
     String name = plugin.getName();
@@ -353,7 +308,16 @@
   @Override
   public synchronized void start() {
     removeStalePluginFiles();
-    log.info("Loading plugins from {}", pluginsDir.toAbsolutePath());
+    Path absolutePath = pluginsDir.toAbsolutePath();
+    if (!Files.exists(absolutePath)) {
+      log.info("{} does not exist; creating", absolutePath);
+      try {
+        Files.createDirectories(absolutePath);
+      } catch (IOException e) {
+        log.error("Failed to create {}: {}", absolutePath, e.getMessage());
+      }
+    }
+    log.info("Loading plugins from {}", absolutePath);
     srvInfoImpl.state = ServerInformation.State.STARTUP;
     rescan();
     srvInfoImpl.state = ServerInformation.State.RUNNING;
@@ -428,7 +392,7 @@
       String name = entry.getKey();
       Path path = entry.getValue();
       String fileName = path.getFileName().toString();
-      if (!isJsPlugin(fileName) && !serverPluginFactory.handles(path)) {
+      if (!isUiPlugin(fileName) && !serverPluginFactory.handles(path)) {
         log.warn("No Plugin provider was found that handles this file format: {}", fileName);
         continue;
       }
@@ -589,19 +553,7 @@
     }
   }
 
-  public static String nameOf(Path plugin) {
-    return nameOf(plugin.getFileName().toString());
-  }
-
-  private static String nameOf(String name) {
-    if (name.endsWith(".disabled")) {
-      name = name.substring(0, name.lastIndexOf('.'));
-    }
-    int ext = name.lastIndexOf('.');
-    return 0 < ext ? name.substring(0, ext) : name;
-  }
-
-  private static String getExtension(String name) {
+  private String getExtension(String name) {
     int ext = name.lastIndexOf('.');
     return 0 < ext ? name.substring(ext) : "";
   }
@@ -609,7 +561,7 @@
   private Plugin loadPlugin(String name, Path srcPlugin, FileSnapshot snapshot)
       throws InvalidPluginException {
     String pluginName = srcPlugin.getFileName().toString();
-    if (isJsPlugin(pluginName)) {
+    if (isUiPlugin(pluginName)) {
       return loadJsPlugin(name, srcPlugin, snapshot);
     } else if (serverPluginFactory.handles(srcPlugin)) {
       return loadServerPlugin(srcPlugin, snapshot);
@@ -651,22 +603,9 @@
             getPluginDataDir(name)));
   }
 
-  static ClassLoader parentFor(Plugin.ApiType type) throws InvalidPluginException {
-    switch (type) {
-      case EXTENSION:
-        return PluginName.class.getClassLoader();
-      case PLUGIN:
-        return PluginLoader.class.getClassLoader();
-      case JS:
-        return JavaScriptPlugin.class.getClassLoader();
-      default:
-        throw new InvalidPluginException("Unsupported ApiType " + type);
-    }
-  }
-
   // Only one active plugin per plugin name can exist for each plugin name.
   // Filter out disabled plugins and transform the multimap to a map
-  private static Map<String, Path> filterDisabled(SetMultimap<String, Path> pluginPaths) {
+  private Map<String, Path> filterDisabled(SetMultimap<String, Path> pluginPaths) {
     Map<String, Path> activePlugins = Maps.newHashMapWithExpectedSize(pluginPaths.keys().size());
     for (String name : pluginPaths.keys()) {
       for (Path pluginPath : pluginPaths.asMap().get(name)) {
@@ -734,21 +673,21 @@
 
   private List<Path> scanPathsInPluginsDirectory(Path pluginsDir) {
     try {
-      return listPlugins(pluginsDir);
+      return PluginUtil.listPlugins(pluginsDir);
     } catch (IOException e) {
       log.error("Cannot list {}", pluginsDir.toAbsolutePath(), e);
       return ImmutableList.of();
     }
   }
 
-  private static Iterable<Path> filterDisabledPlugins(Collection<Path> paths) {
+  private Iterable<Path> filterDisabledPlugins(Collection<Path> paths) {
     return Iterables.filter(paths, p -> !p.getFileName().toString().endsWith(".disabled"));
   }
 
   public String getGerritPluginName(Path srcPath) {
     String fileName = srcPath.getFileName().toString();
-    if (isJsPlugin(fileName)) {
-      return fileName.substring(0, fileName.length() - 3);
+    if (isUiPlugin(fileName)) {
+      return fileName.substring(0, fileName.lastIndexOf('.'));
     }
     if (serverPluginFactory.handles(srcPath)) {
       return serverPluginFactory.getPluginName(srcPath);
@@ -764,11 +703,11 @@
     return map;
   }
 
-  private static boolean isJsPlugin(String name) {
-    return isPlugin(name, "js");
+  private boolean isUiPlugin(String name) {
+    return isPlugin(name, "js") || isPlugin(name, "html");
   }
 
-  private static boolean isPlugin(String fileName, String ext) {
+  private boolean isPlugin(String fileName, String ext) {
     String fullExt = "." + ext;
     return fileName.endsWith(fullExt) || fileName.endsWith(fullExt + ".disabled");
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
index 8e162ba..5f97134 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginRestApiModule.java
@@ -16,8 +16,11 @@
 
 import static com.google.gerrit.server.plugins.PluginResource.PLUGIN_KIND;
 
+import com.google.gerrit.extensions.api.plugins.Plugins;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.api.plugins.PluginApiImpl;
+import com.google.gerrit.server.api.plugins.PluginsImpl;
 
 public class PluginRestApiModule extends RestApiModule {
   @Override
@@ -30,5 +33,7 @@
     post(PLUGIN_KIND, "disable").to(DisablePlugin.class);
     post(PLUGIN_KIND, "enable").to(EnablePlugin.class);
     post(PLUGIN_KIND, "reload").to(ReloadPlugin.class);
+    bind(Plugins.class).to(PluginsImpl.class);
+    factory(PluginApiImpl.Factory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginUtil.java
new file mode 100644
index 0000000..5e67e2c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginUtil.java
@@ -0,0 +1,96 @@
+// 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.plugins;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Ordering;
+import com.google.common.io.ByteStreams;
+import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.webui.JavaScriptPlugin;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+public class PluginUtil {
+  public static List<Path> listPlugins(Path pluginsDir, String suffix) throws IOException {
+    if (pluginsDir == null || !Files.exists(pluginsDir)) {
+      return ImmutableList.of();
+    }
+    DirectoryStream.Filter<Path> filter =
+        new DirectoryStream.Filter<Path>() {
+          @Override
+          public boolean accept(Path entry) throws IOException {
+            String n = entry.getFileName().toString();
+            boolean accept =
+                !n.startsWith(".last_") && !n.startsWith(".next_") && Files.isRegularFile(entry);
+            if (!Strings.isNullOrEmpty(suffix)) {
+              accept &= n.endsWith(suffix);
+            }
+            return accept;
+          }
+        };
+    try (DirectoryStream<Path> files = Files.newDirectoryStream(pluginsDir, filter)) {
+      return Ordering.natural().sortedCopy(files);
+    }
+  }
+
+  static List<Path> listPlugins(Path pluginsDir) throws IOException {
+    return listPlugins(pluginsDir, null);
+  }
+
+  static Path asTemp(InputStream in, String prefix, String suffix, Path dir) throws IOException {
+    Path tmp = Files.createTempFile(dir, prefix, suffix);
+    boolean keep = false;
+    try (OutputStream out = Files.newOutputStream(tmp)) {
+      ByteStreams.copy(in, out);
+      keep = true;
+      return tmp;
+    } finally {
+      if (!keep) {
+        Files.delete(tmp);
+      }
+    }
+  }
+
+  public static String nameOf(Path plugin) {
+    return nameOf(plugin.getFileName().toString());
+  }
+
+  static String nameOf(String name) {
+    if (name.endsWith(".disabled")) {
+      name = name.substring(0, name.lastIndexOf('.'));
+    }
+    int ext = name.lastIndexOf('.');
+    return 0 < ext ? name.substring(0, ext) : name;
+  }
+
+  static ClassLoader parentFor(Plugin.ApiType type) throws InvalidPluginException {
+    switch (type) {
+      case EXTENSION:
+        return PluginName.class.getClassLoader();
+      case PLUGIN:
+        return PluginLoader.class.getClassLoader();
+      case JS:
+        return JavaScriptPlugin.class.getClassLoader();
+      default:
+        throw new InvalidPluginException("Unsupported ApiType " + type);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
index b8a9a9e..768aa86 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/PluginsCollection.java
@@ -33,13 +33,18 @@
   private final DynamicMap<RestView<PluginResource>> views;
   private final PluginLoader loader;
   private final Provider<ListPlugins> list;
+  private final Provider<InstallPlugin> install;
 
   @Inject
   PluginsCollection(
-      DynamicMap<RestView<PluginResource>> views, PluginLoader loader, Provider<ListPlugins> list) {
+      DynamicMap<RestView<PluginResource>> views,
+      PluginLoader loader,
+      Provider<ListPlugins> list,
+      Provider<InstallPlugin> install) {
     this.views = views;
     this.loader = loader;
     this.list = list;
+    this.install = install;
   }
 
   @Override
@@ -50,21 +55,24 @@
   @Override
   public PluginResource parse(TopLevelResource parent, IdString id)
       throws ResourceNotFoundException {
-    Plugin p = loader.get(id.get());
+    return parse(id.get());
+  }
+
+  public PluginResource parse(String id) throws ResourceNotFoundException {
+    Plugin p = loader.get(id);
     if (p == null) {
       throw new ResourceNotFoundException(id);
     }
     return new PluginResource(p);
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public InstallPlugin create(TopLevelResource parent, IdString id)
       throws ResourceNotFoundException, MethodNotAllowedException {
     if (!loader.isRemoteAdminEnabled()) {
       throw new MethodNotAllowedException("remote installation is disabled");
     }
-    return new InstallPlugin(loader, id.get(), true /* created */);
+    return install.get().setName(id.get()).setCreated(true);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java
index 13a1179..7b464bb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ReloadPlugin.java
@@ -17,9 +17,9 @@
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.PluginInfo;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.plugins.ListPlugins.PluginInfo;
 import com.google.gerrit.server.plugins.ReloadPlugin.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -28,8 +28,8 @@
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
-class ReloadPlugin implements RestModifyView<PluginResource, Input> {
-  static class Input {}
+public class ReloadPlugin implements RestModifyView<PluginResource, Input> {
+  public static class Input {}
 
   private final PluginLoader loader;
 
@@ -53,6 +53,6 @@
       pw.flush();
       throw new ResourceConflictException(buf.toString());
     }
-    return new PluginInfo(loader.get(name));
+    return ListPlugins.toPluginInfo(loader.get(name));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerInformationImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerInformationImpl.java
index c4a8900..139cc23 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerInformationImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerInformationImpl.java
@@ -18,7 +18,7 @@
 import com.google.inject.Singleton;
 
 @Singleton
-class ServerInformationImpl implements ServerInformation {
+public class ServerInformationImpl implements ServerInformation {
   volatile State state = State.STARTUP;
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
index 2d37505..232a3ee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/ServerPlugin.java
@@ -249,7 +249,7 @@
     serverManager.start();
   }
 
-  private Injector newRootInjector(final PluginGuiceEnvironment env) {
+  private Injector newRootInjector(PluginGuiceEnvironment env) {
     List<Module> modules = Lists.newArrayListWithCapacity(2);
     if (getApiType() == ApiType.PLUGIN) {
       modules.add(env.getSysModule());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
index 91441d8..50b8752 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/UniversalServerPluginProvider.java
@@ -78,7 +78,7 @@
     }
   }
 
-  private List<ServerPluginProvider> providersForHandlingPlugin(final Path srcPath) {
+  private List<ServerPluginProvider> providersForHandlingPlugin(Path srcPath) {
     List<ServerPluginProvider> providers = new ArrayList<>();
     for (ServerPluginProvider serverPluginProvider : serverPluginProviders) {
       boolean handles = serverPluginProvider.handles(srcPath);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
index 5c0d8d7..6d77267 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/AccessControlModule.java
@@ -46,8 +46,5 @@
         .annotatedWith(GitReceivePackGroups.class)
         .toProvider(GitReceivePackGroupsProvider.class)
         .in(SINGLETON);
-
-    bind(ChangeControl.Factory.class);
-    factory(ProjectControl.AssistedFactory.class);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java
index 41e8fbc..278b2af 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/BanCommit.java
@@ -17,21 +17,24 @@
 import com.google.common.collect.Lists;
 import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.git.BanCommitResult;
+import com.google.gerrit.server.project.BanCommit.BanResultInfo;
 import com.google.gerrit.server.project.BanCommit.Input;
+import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.update.RetryHelper;
+import com.google.gerrit.server.update.RetryingRestModifyView;
+import com.google.gerrit.server.update.UpdateException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
-import org.eclipse.jgit.api.errors.ConcurrentRefUpdateException;
 import org.eclipse.jgit.lib.ObjectId;
 
 @Singleton
-public class BanCommit implements RestModifyView<ProjectResource, Input> {
+public class BanCommit extends RetryingRestModifyView<ProjectResource, Input, BanResultInfo> {
   public static class Input {
     public List<String> commits;
     public String reason;
@@ -50,13 +53,15 @@
   private final com.google.gerrit.server.git.BanCommit banCommit;
 
   @Inject
-  BanCommit(com.google.gerrit.server.git.BanCommit banCommit) {
+  BanCommit(RetryHelper retryHelper, com.google.gerrit.server.git.BanCommit banCommit) {
+    super(retryHelper);
     this.banCommit = banCommit;
   }
 
   @Override
-  public BanResultInfo apply(ProjectResource rsrc, Input input)
-      throws UnprocessableEntityException, AuthException, ResourceConflictException, IOException {
+  protected BanResultInfo applyImpl(
+      BatchUpdate.Factory updateFactory, ProjectResource rsrc, Input input)
+      throws RestApiException, UpdateException, IOException {
     BanResultInfo r = new BanResultInfo();
     if (input != null && input.commits != null && !input.commits.isEmpty()) {
       List<ObjectId> commitsToBan = new ArrayList<>(input.commits.size());
@@ -75,8 +80,6 @@
         r.ignored = transformCommits(result.getIgnoredObjectIds());
       } catch (PermissionDeniedException e) {
         throw new AuthException(e.getMessage());
-      } catch (ConcurrentRefUpdateException e) {
-        throw new ResourceConflictException(e.getMessage(), e);
       }
     }
     return r;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchResource.java
index db23967..2e81af3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchResource.java
@@ -14,37 +14,35 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.inject.TypeLiteral;
+import org.eclipse.jgit.lib.Ref;
 
 public class BranchResource extends RefResource {
   public static final TypeLiteral<RestView<BranchResource>> BRANCH_KIND =
       new TypeLiteral<RestView<BranchResource>>() {};
 
-  private final BranchInfo branchInfo;
+  private final String refName;
+  private final String revision;
 
-  public BranchResource(ProjectControl control, BranchInfo branchInfo) {
+  public BranchResource(ProjectControl control, Ref ref) {
     super(control);
-    this.branchInfo = branchInfo;
-  }
-
-  public BranchInfo getBranchInfo() {
-    return branchInfo;
+    this.refName = ref.getName();
+    this.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
   }
 
   public Branch.NameKey getBranchKey() {
-    return new Branch.NameKey(getNameKey(), branchInfo.ref);
+    return new Branch.NameKey(getNameKey(), refName);
   }
 
   @Override
   public String getRef() {
-    return branchInfo.ref;
+    return refName;
   }
 
   @Override
   public String getRevision() {
-    return branchInfo.revision;
+    return revision;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.java
index 6867dce..a40eabb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/BranchesCollection.java
@@ -14,36 +14,51 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.extensions.api.projects.BranchInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
-import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
 
 @Singleton
 public class BranchesCollection
     implements ChildCollection<ProjectResource, BranchResource>, AcceptsCreate<ProjectResource> {
   private final DynamicMap<RestView<BranchResource>> views;
   private final Provider<ListBranches> list;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final GitRepositoryManager repoManager;
   private final CreateBranch.Factory createBranchFactory;
 
   @Inject
   BranchesCollection(
       DynamicMap<RestView<BranchResource>> views,
       Provider<ListBranches> list,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      GitRepositoryManager repoManager,
       CreateBranch.Factory createBranchFactory) {
     this.views = views;
     this.list = list;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.repoManager = repoManager;
     this.createBranchFactory = createBranchFactory;
   }
 
@@ -54,18 +69,28 @@
 
   @Override
   public BranchResource parse(ProjectResource parent, IdString id)
-      throws ResourceNotFoundException, IOException, BadRequestException {
-    String branchName = id.get();
-    if (!branchName.equals(Constants.HEAD)) {
-      branchName = RefNames.fullName(branchName);
-    }
-    List<BranchInfo> branches = list.get().apply(parent);
-    for (BranchInfo b : branches) {
-      if (branchName.equals(b.ref)) {
-        return new BranchResource(parent.getControl(), b);
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    Project.NameKey project = parent.getNameKey();
+    try (Repository repo = repoManager.openRepository(project)) {
+      Ref ref = repo.exactRef(RefNames.fullName(id.get()));
+      if (ref == null) {
+        throw new ResourceNotFoundException(id);
       }
+
+      // ListBranches checks the target of a symbolic reference to determine access
+      // rights on the symbolic reference itself. This check prevents seeing a hidden
+      // branch simply because the symbolic reference name was visible.
+      permissionBackend
+          .user(user)
+          .project(project)
+          .ref(ref.isSymbolic() ? ref.getTarget().getName() : ref.getName())
+          .check(RefPermission.READ);
+      return new BranchResource(parent.getControl(), ref);
+    } catch (AuthException notAllowed) {
+      throw new ResourceNotFoundException(id);
+    } catch (RepositoryNotFoundException noRepo) {
+      throw new ResourceNotFoundException();
     }
-    throw new ResourceNotFoundException();
   }
 
   @Override
@@ -73,7 +98,6 @@
     return views;
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public CreateBranch create(ProjectResource parent, IdString name) {
     return createBranchFactory.create(name.get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
index 40be5f5..1582d43 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChangeControl.java
@@ -14,17 +14,18 @@
 
 package com.google.gerrit.server.project;
 
-import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.gerrit.server.permissions.LabelPermission.ForUser.ON_BEHALF_OF;
 
-import com.google.common.collect.Lists;
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.PermissionRange;
-import com.google.gerrit.common.data.RefConfigSection;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -32,72 +33,26 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.ChangePermissionOrLabel;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.IOException;
 import java.util.Collection;
-import java.util.List;
+import java.util.EnumSet;
+import java.util.Map;
+import java.util.Set;
 
 /** Access control management for a user accessing a single change. */
-public class ChangeControl {
+class ChangeControl {
   @Singleton
-  public static class GenericFactory {
-    private final ProjectControl.GenericFactory projectControl;
-    private final ChangeNotes.Factory notesFactory;
-
-    @Inject
-    GenericFactory(ProjectControl.GenericFactory p, ChangeNotes.Factory n) {
-      projectControl = p;
-      notesFactory = n;
-    }
-
-    public ChangeControl controlFor(
-        ReviewDb db, Project.NameKey project, Change.Id changeId, CurrentUser user)
-        throws OrmException {
-      return controlFor(notesFactory.create(db, project, changeId), user);
-    }
-
-    public ChangeControl controlFor(ReviewDb db, Change change, CurrentUser user)
-        throws OrmException {
-      final Project.NameKey projectKey = change.getProject();
-      try {
-        return projectControl.controlFor(projectKey, user).controlFor(db, change);
-      } catch (NoSuchProjectException e) {
-        throw new NoSuchChangeException(change.getId(), e);
-      } catch (IOException e) {
-        // TODO: propagate this exception
-        throw new NoSuchChangeException(change.getId(), e);
-      }
-    }
-
-    public ChangeControl controlFor(ChangeNotes notes, CurrentUser user)
-        throws NoSuchChangeException {
-      try {
-        return projectControl.controlFor(notes.getProjectName(), user).controlFor(notes);
-      } catch (NoSuchProjectException | IOException e) {
-        throw new NoSuchChangeException(notes.getChangeId(), e);
-      }
-    }
-
-    public ChangeControl validateFor(ReviewDb db, Change.Id changeId, CurrentUser user)
-        throws OrmException {
-      return validateFor(db, notesFactory.createChecked(changeId), user);
-    }
-
-    public ChangeControl validateFor(ReviewDb db, ChangeNotes notes, CurrentUser user)
-        throws OrmException {
-      ChangeControl c = controlFor(notes, user);
-      if (!c.isVisible(db)) {
-        throw new NoSuchChangeException(c.getId());
-      }
-      return c;
-    }
-  }
-
-  @Singleton
-  public static class Factory {
+  static class Factory {
     private final ChangeData.Factory changeDataFactory;
     private final ChangeNotes.Factory notesFactory;
     private final ApprovalsUtil approvalsUtil;
@@ -121,18 +76,6 @@
       return create(refControl, notesFactory.create(db, project, changeId));
     }
 
-    /**
-     * Create a change control for a change that was loaded from index. This method should only be
-     * used when database access is harmful and potentially stale data from the index is acceptable.
-     *
-     * @param refControl ref control
-     * @param change change loaded from secondary index
-     * @return change control
-     */
-    ChangeControl createForIndexedChange(RefControl refControl, Change change) {
-      return create(refControl, notesFactory.createFromIndexedChange(change));
-    }
-
     ChangeControl create(RefControl refControl, ChangeNotes notes) {
       return new ChangeControl(changeDataFactory, approvalsUtil, refControl, notes, patchSetUtil);
     }
@@ -157,7 +100,7 @@
     this.patchSetUtil = patchSetUtil;
   }
 
-  public ChangeControl forUser(final CurrentUser who) {
+  ChangeControl forUser(CurrentUser who) {
     if (getUser().equals(who)) {
       return this;
     }
@@ -165,103 +108,55 @@
         changeDataFactory, approvalsUtil, getRefControl().forUser(who), notes, patchSetUtil);
   }
 
-  public RefControl getRefControl() {
+  private RefControl getRefControl() {
     return refControl;
   }
 
-  public CurrentUser getUser() {
+  private CurrentUser getUser() {
     return getRefControl().getUser();
   }
 
-  public ProjectControl getProjectControl() {
+  private ProjectControl getProjectControl() {
     return getRefControl().getProjectControl();
   }
 
-  public Project getProject() {
-    return getProjectControl().getProject();
-  }
-
-  public Change.Id getId() {
-    return notes.getChangeId();
-  }
-
-  public Change getChange() {
+  private Change getChange() {
     return notes.getChange();
   }
 
-  public ChangeNotes getNotes() {
+  private ChangeNotes getNotes() {
     return notes;
   }
 
   /** Can this user see this change? */
-  public boolean isVisible(ReviewDb db) throws OrmException {
-    return isVisible(db, null);
-  }
-
-  /** Can this user see this change? */
-  public boolean isVisible(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
-    if (getChange().getStatus() == Change.Status.DRAFT && !isDraftVisible(db, cd)) {
+  private boolean isVisible(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+    if (getChange().isPrivate() && !isPrivateVisible(db, cd)) {
       return false;
     }
     return isRefVisible();
   }
 
   /** Can the user see this change? Does not account for draft status */
-  public boolean isRefVisible() {
+  private boolean isRefVisible() {
     return getRefControl().isVisible();
   }
 
-  /** Can this user see the given patchset? */
-  public boolean isPatchVisible(PatchSet ps, ReviewDb db) throws OrmException {
-    if (ps != null && ps.isDraft() && !isDraftVisible(db, null)) {
-      return false;
-    }
-    return isVisible(db);
-  }
-
-  /** Can this user see the given patchset? */
-  public boolean isPatchVisible(PatchSet ps, ChangeData cd) throws OrmException {
-    checkArgument(
-        cd.getId().equals(ps.getId().getParentKey()), "%s not for change %s", ps, cd.getId());
-    if (ps.isDraft() && !isDraftVisible(cd.db(), cd)) {
-      return false;
-    }
-    return isVisible(cd.db());
-  }
-
   /** Can this user abandon this change? */
-  public boolean canAbandon(ReviewDb db) throws OrmException {
+  private boolean canAbandon(ReviewDb db) throws OrmException {
     return (isOwner() // owner (aka creator) of the change can abandon
             || getRefControl().isOwner() // branch owner can abandon
             || getProjectControl().isOwner() // project owner can abandon
-            || getUser().getCapabilities().canAdministrateServer() // site administers are god
             || getRefControl().canAbandon() // user can abandon a specific ref
-        )
+            || getProjectControl().isAdmin())
         && !isPatchSetLocked(db);
   }
 
-  /** Can this user change the destination branch of this change to the new ref? */
-  public boolean canMoveTo(String ref, ReviewDb db) throws OrmException {
-    return getProjectControl().controlForRef(ref).canUpload() && canAbandon(db);
-  }
-
-  /** Can this user publish this draft change or any draft patch set of this change? */
-  public boolean canPublish(final ReviewDb db) throws OrmException {
-    return (isOwner() || getRefControl().canPublishDrafts()) && isVisible(db);
-  }
-
-  /** Can this user delete this change or any patch set of this change? */
-  public boolean canDelete(ReviewDb db, Change.Status status) throws OrmException {
-    if (!isVisible(db)) {
-      return false;
-    }
-
+  /** Can this user delete this change? */
+  private boolean canDelete(Change.Status status) {
     switch (status) {
-      case DRAFT:
-        return (isOwner() || getRefControl().canDeleteDrafts());
       case NEW:
       case ABANDONED:
-        return (isAdmin() || getRefControl().canDeleteChanges(isOwner()));
+        return (getRefControl().canDeleteChanges(isOwner()) || getProjectControl().isAdmin());
       case MERGED:
       default:
         return false;
@@ -269,55 +164,26 @@
   }
 
   /** Can this user rebase this change? */
-  public boolean canRebase(ReviewDb db) throws OrmException {
+  private boolean canRebase(ReviewDb db) throws OrmException {
     return (isOwner() || getRefControl().canSubmit(isOwner()) || getRefControl().canRebase())
+        && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE)
         && !isPatchSetLocked(db);
   }
 
   /** Can this user restore this change? */
-  public boolean canRestore(ReviewDb db) throws OrmException {
-    return canAbandon(db) // Anyone who can abandon the change can restore it back
-        && getRefControl().canUpload(); // as long as you can upload too
-  }
-
-  /** All available label types for this change. */
-  public LabelTypes getLabelTypes() {
-    String destBranch = getChange().getDest().get();
-    List<LabelType> all = getProjectControl().getLabelTypes().getLabelTypes();
-
-    List<LabelType> r = Lists.newArrayListWithCapacity(all.size());
-    for (LabelType l : all) {
-      List<String> refs = l.getRefPatterns();
-      if (refs == null) {
-        r.add(l);
-      } else {
-        for (String refPattern : refs) {
-          if (RefConfigSection.isValid(refPattern) && match(destBranch, refPattern)) {
-            r.add(l);
-            break;
-          }
-        }
-      }
-    }
-
-    return new LabelTypes(r);
-  }
-
-  /** All value ranges of any allowed label permission. */
-  public List<PermissionRange> getLabelRanges() {
-    return getRefControl().getLabelRanges(isOwner());
+  private boolean canRestore(ReviewDb db) throws OrmException {
+    // Anyone who can abandon the change can restore it, as long as they can create changes.
+    return canAbandon(db) && refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE);
   }
 
   /** The range of permitted values associated with a label permission. */
-  public PermissionRange getRange(String permission) {
+  private PermissionRange getRange(String permission) {
     return getRefControl().getRange(permission, isOwner());
   }
 
   /** Can this user add a patch set to this change? */
-  public boolean canAddPatchSet(ReviewDb db) throws OrmException {
-    if (!getRefControl().canUpload()
-        || isPatchSetLocked(db)
-        || !isPatchVisible(patchSetUtil.current(db, notes), db)) {
+  private boolean canAddPatchSet(ReviewDb db) throws OrmException {
+    if (!refControl.asForRef().testOrFalse(RefPermission.CREATE_CHANGE) || isPatchSetLocked(db)) {
       return false;
     }
     if (isOwner()) {
@@ -327,17 +193,22 @@
   }
 
   /** Is the current patch set locked against state changes? */
-  public boolean isPatchSetLocked(ReviewDb db) throws OrmException {
+  private boolean isPatchSetLocked(ReviewDb db) throws OrmException {
     if (getChange().getStatus() == Change.Status.MERGED) {
       return false;
     }
 
     for (PatchSetApproval ap :
-        approvalsUtil.byPatchSet(db, this, getChange().currentPatchSetId())) {
-      LabelType type = getLabelTypes().byLabel(ap.getLabel());
+        approvalsUtil.byPatchSet(
+            db, getNotes(), getUser(), getChange().currentPatchSetId(), null, null)) {
+      LabelType type =
+          getProjectControl()
+              .getProjectState()
+              .getLabelTypes(getNotes(), getUser())
+              .byLabel(ap.getLabel());
       if (type != null
           && ap.getValue() == 1
-          && type.getFunctionName().equalsIgnoreCase("PatchSetLock")) {
+          && type.getFunction() == LabelFunction.PATCH_SET_LOCK) {
         return true;
       }
     }
@@ -345,7 +216,7 @@
   }
 
   /** Is this user the owner of the change? */
-  public boolean isOwner() {
+  private boolean isOwner() {
     if (getUser().isIdentifiedUser()) {
       Account.Id id = getUser().asIdentifiedUser().getAccountId();
       return id.equals(getChange().getOwner());
@@ -354,7 +225,7 @@
   }
 
   /** Is this user assigned to this change? */
-  public boolean isAssignee() {
+  private boolean isAssignee() {
     Account.Id currentAssignee = notes.getChange().getAssignee();
     if (currentAssignee != null && getUser().isIdentifiedUser()) {
       Account.Id id = getUser().getAccountId();
@@ -364,12 +235,7 @@
   }
 
   /** Is this user a reviewer for the change? */
-  public boolean isReviewer(ReviewDb db) throws OrmException {
-    return isReviewer(db, null);
-  }
-
-  /** Is this user a reviewer for the change? */
-  public boolean isReviewer(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
+  private boolean isReviewer(ReviewDb db, @Nullable ChangeData cd) throws OrmException {
     if (getUser().isIdentifiedUser()) {
       Collection<Account.Id> results = changeData(db, cd).reviewers().all();
       return results.contains(getUser().getAccountId());
@@ -377,70 +243,30 @@
     return false;
   }
 
-  public boolean isAdmin() {
-    return getUser().getCapabilities().canAdministrateServer();
-  }
-
-  /** @return true if the user is allowed to remove this reviewer. */
-  public boolean canRemoveReviewer(PatchSetApproval approval) {
-    return canRemoveReviewer(approval.getAccountId(), approval.getValue());
-  }
-
-  public boolean canRemoveReviewer(Account.Id reviewer, int value) {
-    if (getChange().getStatus().isOpen()) {
-      // A user can always remove themselves.
-      //
-      if (getUser().isIdentifiedUser()) {
-        if (getUser().getAccountId().equals(reviewer)) {
-          return true; // can remove self
-        }
-      }
-
-      // The change owner may remove any zero or positive score.
-      //
-      if (isOwner() && 0 <= value) {
-        return true;
-      }
-
-      // Users with the remove reviewer permission, the branch owner, project
-      // owner and site admin can remove anyone
-      if (getRefControl().canRemoveReviewer() // has removal permissions
-          || getRefControl().isOwner() // branch owner
-          || getProjectControl().isOwner() // project owner
-          || getUser().getCapabilities().canAdministrateServer()) {
-        return true;
-      }
-    }
-
-    return false;
-  }
-
   /** Can this user edit the topic name? */
-  public boolean canEditTopicName() {
+  private boolean canEditTopicName() {
     if (getChange().getStatus().isOpen()) {
       return isOwner() // owner (aka creator) of the change can edit topic
           || getRefControl().isOwner() // branch owner can edit topic
           || getProjectControl().isOwner() // project owner can edit topic
-          || getUser().getCapabilities().canAdministrateServer() // site administers are god
           || getRefControl().canEditTopicName() // user can edit topic on a specific ref
-      ;
+          || getProjectControl().isAdmin();
     }
     return getRefControl().canForceEditTopicName();
   }
 
   /** Can this user edit the description? */
-  public boolean canEditDescription() {
+  private boolean canEditDescription() {
     if (getChange().getStatus().isOpen()) {
       return isOwner() // owner (aka creator) of the change can edit desc
           || getRefControl().isOwner() // branch owner can edit desc
           || getProjectControl().isOwner() // project owner can edit desc
-          || getUser().getCapabilities().canAdministrateServer() // site administers are god
-      ;
+          || getProjectControl().isAdmin();
     }
     return false;
   }
 
-  public boolean canEditAssignee() {
+  private boolean canEditAssignee() {
     return isOwner()
         || getProjectControl().isOwner()
         || getRefControl().canEditAssignee()
@@ -448,34 +274,166 @@
   }
 
   /** Can this user edit the hashtag name? */
-  public boolean canEditHashtags() {
+  private boolean canEditHashtags() {
     return isOwner() // owner (aka creator) of the change can edit hashtags
         || getRefControl().isOwner() // branch owner can edit hashtags
         || getProjectControl().isOwner() // project owner can edit hashtags
-        || getUser().getCapabilities().canAdministrateServer() // site administers are god
-        || getRefControl().canEditHashtags(); // user can edit hashtag on a specific ref
-  }
-
-  public boolean canSubmit() {
-    return getRefControl().canSubmit(isOwner());
-  }
-
-  public boolean canSubmitAs() {
-    return getRefControl().canSubmitAs();
-  }
-
-  private boolean match(String destBranch, String refPattern) {
-    return RefPatternMatcher.getMatcher(refPattern).match(destBranch, getUser());
+        || getRefControl().canEditHashtags() // user can edit hashtag on a specific ref
+        || getProjectControl().isAdmin();
   }
 
   private ChangeData changeData(ReviewDb db, @Nullable ChangeData cd) {
-    return cd != null ? cd : changeDataFactory.create(db, this);
+    return cd != null ? cd : changeDataFactory.create(db, getNotes());
   }
 
-  public boolean isDraftVisible(ReviewDb db, ChangeData cd) throws OrmException {
+  private boolean isPrivateVisible(ReviewDb db, ChangeData cd) throws OrmException {
     return isOwner()
         || isReviewer(db, cd)
-        || getRefControl().canViewDrafts()
+        || getRefControl().canViewPrivateChanges()
         || getUser().isInternalUser();
   }
+
+  ForChange asForChange(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
+    return new ForChangeImpl(cd, db);
+  }
+
+  private class ForChangeImpl extends ForChange {
+    private ChangeData cd;
+    private Map<String, PermissionRange> labels;
+
+    ForChangeImpl(@Nullable ChangeData cd, @Nullable Provider<ReviewDb> db) {
+      this.cd = cd;
+      this.db = db;
+    }
+
+    private ReviewDb db() {
+      if (db != null) {
+        return db.get();
+      } else if (cd != null) {
+        return cd.db();
+      } else {
+        return null;
+      }
+    }
+
+    private ChangeData changeData() {
+      if (cd == null) {
+        ReviewDb reviewDb = db();
+        checkState(reviewDb != null, "need ReviewDb");
+        cd = changeDataFactory.create(reviewDb, getNotes());
+      }
+      return cd;
+    }
+
+    @Override
+    public CurrentUser user() {
+      return getUser();
+    }
+
+    @Override
+    public ForChange user(CurrentUser user) {
+      return user().equals(user) ? this : forUser(user).asForChange(cd, db);
+    }
+
+    @Override
+    public void check(ChangePermissionOrLabel perm)
+        throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public <T extends ChangePermissionOrLabel> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      Set<T> ok = newSet(permSet);
+      for (T perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(ChangePermissionOrLabel perm) throws PermissionBackendException {
+      if (perm instanceof ChangePermission) {
+        return can((ChangePermission) perm);
+      } else if (perm instanceof LabelPermission) {
+        return can((LabelPermission) perm);
+      } else if (perm instanceof LabelPermission.WithValue) {
+        return can((LabelPermission.WithValue) perm);
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean can(ChangePermission perm) throws PermissionBackendException {
+      try {
+        switch (perm) {
+          case READ:
+            return isVisible(db(), changeData());
+          case ABANDON:
+            return canAbandon(db());
+          case DELETE:
+            return canDelete(getChange().getStatus());
+          case ADD_PATCH_SET:
+            return canAddPatchSet(db());
+          case EDIT_ASSIGNEE:
+            return canEditAssignee();
+          case EDIT_DESCRIPTION:
+            return canEditDescription();
+          case EDIT_HASHTAGS:
+            return canEditHashtags();
+          case EDIT_TOPIC_NAME:
+            return canEditTopicName();
+          case REBASE:
+            return canRebase(db());
+          case RESTORE:
+            return canRestore(db());
+          case SUBMIT:
+            return getRefControl().canSubmit(isOwner());
+
+          case REMOVE_REVIEWER:
+          case SUBMIT_AS:
+            return getRefControl().canPerform(perm.permissionName().get());
+        }
+      } catch (OrmException e) {
+        throw new PermissionBackendException("unavailable", e);
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean can(LabelPermission perm) {
+      return !label(perm.permissionName().get()).isEmpty();
+    }
+
+    private boolean can(LabelPermission.WithValue perm) {
+      PermissionRange r = label(perm.permissionName().get());
+      if (perm.forUser() == ON_BEHALF_OF && r.isEmpty()) {
+        return false;
+      }
+      return r.contains(perm.value());
+    }
+
+    private PermissionRange label(String permission) {
+      if (labels == null) {
+        labels = Maps.newHashMapWithExpectedSize(4);
+      }
+      PermissionRange r = labels.get(permission);
+      if (r == null) {
+        r = getRange(permission);
+        labels.put(permission, r);
+      }
+      return r;
+    }
+  }
+
+  static <T extends ChangePermissionOrLabel> Set<T> newSet(Collection<T> permSet) {
+    if (permSet instanceof EnumSet) {
+      @SuppressWarnings({"unchecked", "rawtypes"})
+      Set<T> s = ((EnumSet) permSet).clone();
+      s.clear();
+      return s;
+    }
+    return Sets.newHashSetWithExpectedSize(permSet.size());
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckAccess.java
new file mode 100644
index 0000000..b8d3fbc
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckAccess.java
@@ -0,0 +1,109 @@
+// 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.project;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.config.AccessCheckInfo;
+import com.google.gerrit.extensions.api.config.AccessCheckInput;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountResolver;
+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.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import javax.servlet.http.HttpServletResponse;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+@Singleton
+public class CheckAccess implements RestModifyView<ProjectResource, AccessCheckInput> {
+  private final AccountResolver accountResolver;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final PermissionBackend permissionBackend;
+
+  @Inject
+  CheckAccess(
+      AccountResolver resolver,
+      IdentifiedUser.GenericFactory userFactory,
+      PermissionBackend permissionBackend) {
+    this.accountResolver = resolver;
+    this.userFactory = userFactory;
+    this.permissionBackend = permissionBackend;
+  }
+
+  @Override
+  public AccessCheckInfo apply(ProjectResource rsrc, AccessCheckInput input)
+      throws OrmException, PermissionBackendException, RestApiException, IOException,
+          ConfigInvalidException {
+    permissionBackend.user(rsrc.getUser()).check(GlobalPermission.ADMINISTRATE_SERVER);
+
+    if (input == null) {
+      throw new BadRequestException("input is required");
+    }
+    if (Strings.isNullOrEmpty(input.account)) {
+      throw new BadRequestException("input requires 'account'");
+    }
+
+    Account match = accountResolver.find(input.account);
+    if (match == null) {
+      throw new UnprocessableEntityException(
+          String.format("cannot find account %s", input.account));
+    }
+
+    AccessCheckInfo info = new AccessCheckInfo();
+
+    IdentifiedUser user = userFactory.create(match.getId());
+    try {
+      permissionBackend.user(user).project(rsrc.getNameKey()).check(ProjectPermission.ACCESS);
+    } catch (AuthException | PermissionBackendException e) {
+      info.message =
+          String.format(
+              "user %s (%s) cannot see project %s",
+              user.getNameEmail(), user.getAccount().getId(), rsrc.getName());
+      info.status = HttpServletResponse.SC_FORBIDDEN;
+      return info;
+    }
+
+    if (!Strings.isNullOrEmpty(input.ref)) {
+      try {
+        permissionBackend
+            .user(user)
+            .ref(new Branch.NameKey(rsrc.getNameKey(), input.ref))
+            .check(RefPermission.READ);
+      } catch (AuthException | PermissionBackendException e) {
+        info.status = HttpServletResponse.SC_FORBIDDEN;
+        info.message =
+            String.format(
+                "user %s (%s) cannot see ref %s in project %s",
+                user.getNameEmail(), user.getAccount().getId(), input.ref, rsrc.getName());
+        return info;
+      }
+    }
+
+    info.status = HttpServletResponse.SC_OK;
+    return info;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
index 72ebd62..ab48143 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CheckMergeability.java
@@ -19,13 +19,11 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import java.io.IOException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -39,11 +37,9 @@
 
 /** Check the mergeability at current branch for a git object references expression. */
 public class CheckMergeability implements RestReadView<BranchResource> {
-
   private String source;
   private String strategy;
   private SubmitType submitType;
-  private final Provider<ReviewDb> db;
 
   @Option(
       name = "--source",
@@ -66,14 +62,15 @@
   }
 
   private final GitRepositoryManager gitManager;
+  private final CommitsCollection commits;
 
   @Inject
   CheckMergeability(
-      GitRepositoryManager gitManager, @GerritServerConfig Config cfg, Provider<ReviewDb> db) {
+      GitRepositoryManager gitManager, CommitsCollection commits, @GerritServerConfig Config cfg) {
     this.gitManager = gitManager;
+    this.commits = commits;
     this.strategy = MergeUtil.getMergeStrategy(cfg).getName();
     this.submitType = cfg.getEnum("project", null, "submitType", SubmitType.MERGE_IF_NECESSARY);
-    this.db = db;
   }
 
   @Override
@@ -90,7 +87,7 @@
     try (Repository git = gitManager.openRepository(resource.getNameKey());
         RevWalk rw = new RevWalk(git);
         ObjectInserter inserter = new InMemoryInserter(git)) {
-      Merger m = MergeUtil.newMerger(git, inserter, strategy);
+      Merger m = MergeUtil.newMerger(inserter, git.getConfig(), strategy);
 
       Ref destRef = git.getRefDatabase().exactRef(resource.getRef());
       if (destRef == null) {
@@ -100,7 +97,7 @@
       RevCommit targetCommit = rw.parseCommit(destRef.getObjectId());
       RevCommit sourceCommit = MergeUtil.resolveCommit(git, rw, source);
 
-      if (!resource.getControl().canReadCommit(db.get(), git, sourceCommit)) {
+      if (!commits.canRead(resource.getProjectState(), git, sourceCommit)) {
         throw new BadRequestException("do not have read permission for: " + source);
       }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
index f0d127d..b372b38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectResource.java
@@ -24,9 +24,9 @@
       new TypeLiteral<RestView<ChildProjectResource>>() {};
 
   private final ProjectResource parent;
-  private final ProjectControl child;
+  private final ProjectState child;
 
-  public ChildProjectResource(ProjectResource parent, ProjectControl child) {
+  public ChildProjectResource(ProjectResource parent, ProjectState child) {
     this.parent = parent;
     this.child = child;
   }
@@ -35,12 +35,12 @@
     return parent;
   }
 
-  public ProjectControl getChild() {
+  public ProjectState getChild() {
     return child;
   }
 
   public boolean isDirectChild() {
-    ProjectState firstParent = Iterables.getFirst(child.getProjectState().parents(), null);
-    return firstParent != null && parent.getNameKey().equals(firstParent.getProject().getNameKey());
+    ProjectState firstParent = Iterables.getFirst(child.parents(), null);
+    return firstParent != null && parent.getNameKey().equals(firstParent.getNameKey());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
index 7aa5f68..0cd7d19 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ChildProjectsCollection.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -50,11 +51,11 @@
 
   @Override
   public ChildProjectResource parse(ProjectResource parent, IdString id)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
     ProjectResource p = projectsCollection.parse(TopLevelResource.INSTANCE, id);
     for (ProjectState pp : p.getControl().getProjectState().parents()) {
       if (parent.getNameKey().equals(pp.getProject().getNameKey())) {
-        return new ChildProjectResource(parent, p.getControl());
+        return new ChildProjectResource(parent, p.getProjectState());
       }
     }
     throw new ResourceNotFoundException(id);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java
index 8d9127b..3c9b4bd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitIncludedIn.java
@@ -26,7 +26,7 @@
 import org.eclipse.jgit.revwalk.RevCommit;
 
 @Singleton
-class CommitIncludedIn implements RestReadView<CommitResource> {
+public class CommitIncludedIn implements RestReadView<CommitResource> {
   private IncludedIn includedIn;
 
   @Inject
@@ -38,7 +38,7 @@
   public IncludedInInfo apply(CommitResource rsrc)
       throws RestApiException, OrmException, IOException {
     RevCommit commit = rsrc.getCommit();
-    Project.NameKey project = rsrc.getProject().getProject().getNameKey();
+    Project.NameKey project = rsrc.getProjectState().getNameKey();
     return includedIn.apply(project, commit.getId().getName());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java
index 8065e0f..0925524 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitResource.java
@@ -31,8 +31,8 @@
     this.commit = commit;
   }
 
-  public ProjectControl getProject() {
-    return project.getControl();
+  public ProjectState getProjectState() {
+    return project.getProjectState();
   }
 
   public RevCommit getCommit() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
index d481c014..11ed75a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CommitsCollection.java
@@ -19,33 +19,52 @@
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.change.IncludedInResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.index.change.ChangeIndexCollection;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
+import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class CommitsCollection implements ChildCollection<ProjectResource, CommitResource> {
+  private static final Logger log = LoggerFactory.getLogger(CommitsCollection.class);
+
   private final DynamicMap<RestView<CommitResource>> views;
   private final GitRepositoryManager repoManager;
-  private final Provider<ReviewDb> db;
+  private final VisibleRefFilter.Factory refFilter;
+  private final ChangeIndexCollection indexes;
+  private final Provider<InternalChangeQuery> queryProvider;
 
   @Inject
   public CommitsCollection(
       DynamicMap<RestView<CommitResource>> views,
       GitRepositoryManager repoManager,
-      Provider<ReviewDb> db) {
+      VisibleRefFilter.Factory refFilter,
+      ChangeIndexCollection indexes,
+      Provider<InternalChangeQuery> queryProvider) {
     this.views = views;
     this.repoManager = repoManager;
-    this.db = db;
+    this.refFilter = refFilter;
+    this.indexes = indexes;
+    this.queryProvider = queryProvider;
   }
 
   @Override
@@ -67,7 +86,7 @@
         RevWalk rw = new RevWalk(repo)) {
       RevCommit commit = rw.parseCommit(objectId);
       rw.parseBody(commit);
-      if (!parent.getControl().canReadCommit(db.get(), repo, commit)) {
+      if (!canRead(parent.getProjectState(), repo, commit)) {
         throw new ResourceNotFoundException(id);
       }
       for (int i = 0; i < commit.getParentCount(); i++) {
@@ -83,4 +102,39 @@
   public DynamicMap<RestView<CommitResource>> views() {
     return views;
   }
+
+  /** @return true if {@code commit} is visible to the caller. */
+  public boolean canRead(ProjectState state, Repository repo, RevCommit commit) {
+    Project.NameKey project = state.getNameKey();
+
+    // Look for changes associated with the commit.
+    if (indexes.getSearchIndex() != null) {
+      try {
+        List<ChangeData> changes =
+            queryProvider.get().enforceVisibility(true).byProjectCommit(project, commit);
+        if (!changes.isEmpty()) {
+          return true;
+        }
+      } catch (OrmException e) {
+        log.error("Cannot look up change for commit {} in {}", commit.name(), project, e);
+      }
+    }
+
+    return isReachableFrom(state, repo, commit, repo.getAllRefs());
+  }
+
+  public boolean isReachableFrom(
+      ProjectState state, Repository repo, RevCommit commit, Map<String, Ref> refs) {
+    try (RevWalk rw = new RevWalk(repo)) {
+      refs = refFilter.create(state, repo).filter(refs, true);
+      return IncludedInResolver.includedInAny(repo, rw, commit, refs.values());
+    } catch (IOException e) {
+      log.error(
+          "Cannot verify permissions to commit object {} in repository {}",
+          commit.name(),
+          state.getNameKey(),
+          e);
+      return false;
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
index 62b8c9d..9913693 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ConfigInfoImpl.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.server.config.ProjectConfigEntry;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.project.ProjectState.EffectiveMaxObjectSizeLimit;
-import com.google.inject.util.Providers;
 import java.util.Arrays;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -44,6 +43,7 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     ProjectState projectState = control.getProjectState();
     Project p = control.getProject();
@@ -57,6 +57,10 @@
     InheritedBooleanInfo enableSignedPush = new InheritedBooleanInfo();
     InheritedBooleanInfo requireSignedPush = new InheritedBooleanInfo();
     InheritedBooleanInfo rejectImplicitMerges = new InheritedBooleanInfo();
+    InheritedBooleanInfo privateByDefault = new InheritedBooleanInfo();
+    InheritedBooleanInfo workInProgressByDefault = new InheritedBooleanInfo();
+    InheritedBooleanInfo enableReviewerByEmail = new InheritedBooleanInfo();
+    InheritedBooleanInfo matchAuthorToCommitterDate = new InheritedBooleanInfo();
 
     useContributorAgreements.value = projectState.isUseContributorAgreements();
     useSignedOffBy.value = projectState.isUseSignedOffBy();
@@ -72,6 +76,10 @@
     enableSignedPush.configuredValue = p.getEnableSignedPush();
     requireSignedPush.configuredValue = p.getRequireSignedPush();
     rejectImplicitMerges.configuredValue = p.getRejectImplicitMerges();
+    privateByDefault.configuredValue = p.getPrivateByDefault();
+    workInProgressByDefault.configuredValue = p.getWorkInProgressByDefault();
+    enableReviewerByEmail.configuredValue = p.getEnableReviewerByEmail();
+    matchAuthorToCommitterDate.configuredValue = p.getMatchAuthorToCommitterDate();
 
     ProjectState parentState = Iterables.getFirst(projectState.parents(), null);
     if (parentState != null) {
@@ -83,7 +91,11 @@
           parentState.isCreateNewChangeForAllNotInTarget();
       enableSignedPush.inheritedValue = projectState.isEnableSignedPush();
       requireSignedPush.inheritedValue = projectState.isRequireSignedPush();
+      privateByDefault.inheritedValue = projectState.isPrivateByDefault();
+      workInProgressByDefault.inheritedValue = projectState.isWorkInProgressByDefault();
       rejectImplicitMerges.inheritedValue = projectState.isRejectImplicitMerges();
+      enableReviewerByEmail.inheritedValue = projectState.isEnableReviewerByEmail();
+      matchAuthorToCommitterDate.inheritedValue = projectState.isMatchAuthorToCommitterDate();
     }
 
     this.useContributorAgreements = useContributorAgreements;
@@ -92,10 +104,14 @@
     this.requireChangeId = requireChangeId;
     this.rejectImplicitMerges = rejectImplicitMerges;
     this.createNewChangeForAllNotInTarget = createNewChangeForAllNotInTarget;
+    this.enableReviewerByEmail = enableReviewerByEmail;
+    this.matchAuthorToCommitterDate = matchAuthorToCommitterDate;
     if (serverEnableSignedPush) {
       this.enableSignedPush = enableSignedPush;
       this.requireSignedPush = requireSignedPush;
     }
+    this.privateByDefault = privateByDefault;
+    this.workInProgressByDefault = workInProgressByDefault;
 
     this.maxObjectSizeLimit = getMaxObjectSizeLimit(projectState, p);
 
@@ -114,11 +130,12 @@
         getPluginConfig(control.getProjectState(), pluginConfigEntries, cfgFactory, allProjects);
 
     actions = new TreeMap<>();
-    for (UiAction.Description d :
-        UiActions.from(views, new ProjectResource(control), Providers.of(control.getUser()))) {
+    for (UiAction.Description d : uiActions.from(views, new ProjectResource(control))) {
       actions.put(d.getId(), new ActionInfo(d));
     }
     this.theme = projectState.getTheme();
+
+    this.extensionPanelNames = projectState.getConfig().getExtensionPanelSections();
   }
 
   private MaxObjectSizeLimitInfo getMaxObjectSizeLimit(ProjectState projectState, Project p) {
@@ -148,7 +165,7 @@
       p.type = configEntry.getType();
       p.permittedValues = configEntry.getPermittedValues();
       p.editable = configEntry.isEditable(project) ? true : null;
-      if (configEntry.isInheritable() && !allProjects.equals(project.getProject().getNameKey())) {
+      if (configEntry.isInheritable() && !allProjects.equals(project.getNameKey())) {
         PluginConfig cfgWithInheritance =
             cfgFactory.getFromProjectConfigWithInheritance(project, e.getPluginName());
         p.inheritable = true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
new file mode 100644
index 0000000..0033b12
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ContributorAgreementsChecker.java
@@ -0,0 +1,108 @@
+// 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.project;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.PageLinks;
+import com.google.gerrit.common.data.ContributorAgreement;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroup.UUID;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.CanonicalWebUrl;
+import com.google.gerrit.server.project.ProjectControl.Metrics;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@Singleton
+public class ContributorAgreementsChecker {
+
+  private final String canonicalWebUrl;
+  private final ProjectCache projectCache;
+  private final Metrics metrics;
+
+  @Inject
+  ContributorAgreementsChecker(
+      @CanonicalWebUrl @Nullable String canonicalWebUrl,
+      ProjectCache projectCache,
+      Metrics metrics) {
+    this.canonicalWebUrl = canonicalWebUrl;
+    this.projectCache = projectCache;
+    this.metrics = metrics;
+  }
+
+  /**
+   * Checks if the user has signed a contributor agreement for the project.
+   *
+   * @throws AuthException if the user has not signed a contributor agreement for the project
+   * @throws IOException if project states could not be loaded
+   */
+  public void check(Project.NameKey project, CurrentUser user) throws IOException, AuthException {
+    metrics.claCheckCount.increment();
+
+    ProjectState projectState = projectCache.checkedGet(project);
+    if (projectState == null) {
+      throw new IOException("Can't load All-Projects");
+    }
+
+    if (!projectState.isUseContributorAgreements()) {
+      return;
+    }
+
+    if (!user.isIdentifiedUser()) {
+      throw new AuthException("Must be logged in to verify Contributor Agreement");
+    }
+
+    IdentifiedUser iUser = user.asIdentifiedUser();
+    Collection<ContributorAgreement> contributorAgreements =
+        projectCache.getAllProjects().getConfig().getContributorAgreements();
+    List<UUID> okGroupIds = new ArrayList<>();
+    for (ContributorAgreement ca : contributorAgreements) {
+      List<AccountGroup.UUID> groupIds;
+      groupIds = okGroupIds;
+
+      for (PermissionRule rule : ca.getAccepted()) {
+        if ((rule.getAction() == Action.ALLOW)
+            && (rule.getGroup() != null)
+            && (rule.getGroup().getUUID() != null)) {
+          groupIds.add(new AccountGroup.UUID(rule.getGroup().getUUID().get()));
+        }
+      }
+    }
+
+    if (!iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) {
+      final StringBuilder msg = new StringBuilder();
+      msg.append("A Contributor Agreement must be completed before uploading");
+      if (canonicalWebUrl != null) {
+        msg.append(":\n\n  ");
+        msg.append(canonicalWebUrl);
+        msg.append("#");
+        msg.append(PageLinks.SETTINGS_AGREEMENTS);
+        msg.append("\n");
+      } else {
+        msg.append(".");
+      }
+      throw new AuthException(msg.toString());
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateAccessChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateAccessChange.java
new file mode 100644
index 0000000..459a413
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateAccessChange.java
@@ -0,0 +1,165 @@
+// 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.project;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.common.errors.PermissionDeniedException;
+import com.google.gerrit.extensions.api.access.ProjectAccessInput;
+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.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.extensions.restapi.RestModifyView;
+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.ApprovalsUtil;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.change.ChangeInserter;
+import com.google.gerrit.server.change.ChangeJson;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+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.update.BatchUpdate;
+import com.google.gerrit.server.update.UpdateException;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import java.util.List;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+@Singleton
+public class CreateAccessChange implements RestModifyView<ProjectResource, ProjectAccessInput> {
+  private final PermissionBackend permissionBackend;
+  private final Sequences seq;
+  private final ChangeInserter.Factory changeInserterFactory;
+  private final BatchUpdate.Factory updateFactory;
+  private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
+  private final Provider<ReviewDb> db;
+  private final SetAccessUtil setAccess;
+  private final ChangeJson.Factory jsonFactory;
+
+  @Inject
+  CreateAccessChange(
+      PermissionBackend permissionBackend,
+      ChangeInserter.Factory changeInserterFactory,
+      BatchUpdate.Factory updateFactory,
+      Sequences seq,
+      Provider<MetaDataUpdate.User> metaDataUpdateFactory,
+      Provider<ReviewDb> db,
+      SetAccessUtil accessUtil,
+      ChangeJson.Factory jsonFactory) {
+    this.permissionBackend = permissionBackend;
+    this.seq = seq;
+    this.changeInserterFactory = changeInserterFactory;
+    this.updateFactory = updateFactory;
+    this.metaDataUpdateFactory = metaDataUpdateFactory;
+    this.db = db;
+    this.setAccess = accessUtil;
+    this.jsonFactory = jsonFactory;
+  }
+
+  @Override
+  public Response<ChangeInfo> apply(ProjectResource rsrc, ProjectAccessInput input)
+      throws PermissionBackendException, PermissionDeniedException, IOException,
+          ConfigInvalidException, OrmException, InvalidNameException, UpdateException,
+          RestApiException {
+    MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
+    List<AccessSection> removals = setAccess.getAccessSections(input.remove);
+    List<AccessSection> additions = setAccess.getAccessSections(input.add);
+
+    PermissionBackend.ForRef metaRef =
+        permissionBackend.user(rsrc.getUser()).project(rsrc.getNameKey()).ref(RefNames.REFS_CONFIG);
+    try {
+      metaRef.check(RefPermission.READ);
+    } catch (AuthException denied) {
+      throw new PermissionDeniedException(RefNames.REFS_CONFIG + " not visible");
+    }
+    if (!rsrc.getControl().isOwner()) {
+      try {
+        metaRef.check(RefPermission.CREATE_CHANGE);
+      } catch (AuthException denied) {
+        throw new PermissionDeniedException("cannot create change for " + RefNames.REFS_CONFIG);
+      }
+    }
+
+    Project.NameKey newParentProjectName =
+        input.parent == null ? null : new Project.NameKey(input.parent);
+
+    try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
+      ProjectConfig config = ProjectConfig.read(md);
+
+      setAccess.validateChanges(config, removals, additions);
+      setAccess.applyChanges(config, removals, additions);
+      try {
+        setAccess.setParentName(
+            rsrc.getUser().asIdentifiedUser(),
+            config,
+            rsrc.getNameKey(),
+            newParentProjectName,
+            false);
+      } catch (AuthException e) {
+        throw new IllegalStateException(e);
+      }
+
+      md.setMessage("Review access change");
+      md.setInsertChangeId(true);
+      Change.Id changeId = new Change.Id(seq.nextChangeId());
+      RevCommit commit =
+          config.commitToNewRef(
+              md, new PatchSet.Id(changeId, Change.INITIAL_PATCH_SET_ID).toRefName());
+
+      try (ObjectInserter objInserter = md.getRepository().newObjectInserter();
+          ObjectReader objReader = objInserter.newReader();
+          RevWalk rw = new RevWalk(objReader);
+          BatchUpdate bu =
+              updateFactory.create(db.get(), rsrc.getNameKey(), rsrc.getUser(), TimeUtil.nowTs())) {
+        bu.setRepository(md.getRepository(), rw, objInserter);
+        ChangeInserter ins = newInserter(changeId, commit);
+        bu.insertChange(ins);
+        bu.execute();
+        return Response.created(jsonFactory.noOptions().format(ins.getChange()));
+      }
+    } catch (InvalidNameException e) {
+      throw new BadRequestException(e.toString());
+    }
+  }
+
+  // ProjectConfig doesn't currently support fusing into a BatchUpdate.
+  @SuppressWarnings("deprecation")
+  private ChangeInserter newInserter(Change.Id changeId, RevCommit commit) {
+    return changeInserterFactory
+        .create(changeId, commit, RefNames.REFS_CONFIG)
+        .setMessage(
+            // Same message as in ReceiveCommits.CreateRequest.
+            ApprovalsUtil.renderMessageWithApprovals(1, ImmutableMap.of(), ImmutableMap.of()))
+        .setValidate(false)
+        .setUpdateRef(false);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
index 422607b..0a648d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateBranch.java
@@ -22,10 +22,12 @@
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.util.MagicBranch;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -50,31 +52,35 @@
   }
 
   private final Provider<IdentifiedUser> identifiedUser;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
-  private final Provider<ReviewDb> db;
   private final GitReferenceUpdated referenceUpdated;
   private final RefValidationHelper refCreationValidator;
+  private final CreateRefControl createRefControl;
   private String ref;
 
   @Inject
   CreateBranch(
       Provider<IdentifiedUser> identifiedUser,
+      PermissionBackend permissionBackend,
       GitRepositoryManager repoManager,
-      Provider<ReviewDb> db,
       GitReferenceUpdated referenceUpdated,
       RefValidationHelper.Factory refHelperFactory,
+      CreateRefControl createRefControl,
       @Assisted String ref) {
     this.identifiedUser = identifiedUser;
+    this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
-    this.db = db;
     this.referenceUpdated = referenceUpdated;
     this.refCreationValidator = refHelperFactory.create(ReceiveCommand.Type.CREATE);
+    this.createRefControl = createRefControl;
     this.ref = ref;
   }
 
   @Override
   public BranchInfo apply(ProjectResource rsrc, BranchInput input)
-      throws BadRequestException, AuthException, ResourceConflictException, IOException {
+      throws BadRequestException, AuthException, ResourceConflictException, IOException,
+          PermissionBackendException, NoSuchProjectException {
     if (input == null) {
       input = new BranchInput();
     }
@@ -99,7 +105,6 @@
     }
 
     final Branch.NameKey name = new Branch.NameKey(rsrc.getNameKey(), ref);
-    final RefControl refControl = rsrc.getControl().controlForRef(name);
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, rsrc.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
@@ -116,9 +121,7 @@
         }
       }
 
-      if (!refControl.canCreate(db.get(), repo, object)) {
-        throw new AuthException("Cannot create \"" + ref + "\"");
-      }
+      createRefControl.checkCreateRef(identifiedUser, repo, name, object);
 
       try {
         final RefUpdate u = repo.updateRef(ref);
@@ -161,6 +164,8 @@
           case REJECTED:
           case REJECTED_CURRENT_BRANCH:
           case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
           default:
             {
               throw new IOException(result.name());
@@ -170,7 +175,10 @@
         BranchInfo info = new BranchInfo();
         info.ref = ref;
         info.revision = revid.getName();
-        info.canDelete = refControl.canDelete() ? true : null;
+        info.canDelete =
+            permissionBackend.user(identifiedUser).ref(name).testOrFalse(RefPermission.DELETE)
+                ? true
+                : null;
         return info;
       } catch (IOException err) {
         log.error("Cannot create branch \"" + name + "\"", err);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
index ff7e31e..bd8f11e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
@@ -55,6 +55,7 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
 import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.validators.ProjectCreationValidationListener;
 import com.google.gerrit.server.validators.ValidationException;
 import com.google.inject.Inject;
@@ -90,7 +91,6 @@
   private final Provider<GroupsCollection> groupsCollection;
   private final DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners;
   private final ProjectJson json;
-  private final ProjectControl.GenericFactory projectControlFactory;
   private final GitRepositoryManager repoManager;
   private final DynamicSet<NewProjectCreatedListener> createdListeners;
   private final ProjectCache projectCache;
@@ -111,7 +111,6 @@
       Provider<GroupsCollection> groupsCollection,
       ProjectJson json,
       DynamicSet<ProjectCreationValidationListener> projectCreationValidationListeners,
-      ProjectControl.GenericFactory projectControlFactory,
       GitRepositoryManager repoManager,
       DynamicSet<NewProjectCreatedListener> createdListeners,
       ProjectCache projectCache,
@@ -129,7 +128,6 @@
     this.groupsCollection = groupsCollection;
     this.projectCreationValidationListeners = projectCreationValidationListeners;
     this.json = json;
-    this.projectControlFactory = projectControlFactory;
     this.repoManager = repoManager;
     this.createdListeners = createdListeners;
     this.projectCache = projectCache;
@@ -148,7 +146,8 @@
   @Override
   public Response<ProjectInfo> apply(TopLevelResource resource, ProjectInput input)
       throws BadRequestException, UnprocessableEntityException, ResourceConflictException,
-          ResourceNotFoundException, IOException, ConfigInvalidException {
+          ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (input == null) {
       input = new ProjectInput();
     }
@@ -161,7 +160,7 @@
 
     String parentName =
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
-    args.newParent = projectsCollection.get().parse(parentName, false).getControl();
+    args.newParent = projectsCollection.get().parse(parentName, false).getNameKey();
     args.createEmptyCommit = input.createEmptyCommit;
     args.permissionsOnly = input.permissionsOnly;
     args.projectDescription = Strings.emptyToNull(input.description);
@@ -187,6 +186,10 @@
             input.createNewChangeForAllNotInTarget, InheritableBoolean.INHERIT);
     args.changeIdRequired =
         MoreObjects.firstNonNull(input.requireChangeId, InheritableBoolean.INHERIT);
+    args.enableSignedPush =
+        MoreObjects.firstNonNull(input.enableSignedPush, InheritableBoolean.INHERIT);
+    args.requireSignedPush =
+        MoreObjects.firstNonNull(input.requireSignedPush, InheritableBoolean.INHERIT);
     try {
       args.maxObjectSizeLimit = ProjectConfig.validMaxObjectSizeLimit(input.maxObjectSizeLimit);
     } catch (ConfigInvalidException e) {
@@ -201,24 +204,17 @@
       }
     }
 
-    Project p = createProject(args);
-
+    ProjectState projectState = createProject(args);
     if (input.pluginConfigValues != null) {
-      try {
-        ProjectControl projectControl =
-            projectControlFactory.controlFor(p.getNameKey(), identifiedUser.get());
-        ConfigInput in = new ConfigInput();
-        in.pluginConfigValues = input.pluginConfigValues;
-        putConfig.get().apply(projectControl, in);
-      } catch (NoSuchProjectException e) {
-        throw new ResourceNotFoundException(p.getName());
-      }
+      ConfigInput in = new ConfigInput();
+      in.pluginConfigValues = input.pluginConfigValues;
+      putConfig.get().apply(projectState, in);
     }
 
-    return Response.created(json.format(p));
+    return Response.created(json.format(projectState));
   }
 
-  private Project createProject(CreateProjectArgs args)
+  private ProjectState createProject(CreateProjectArgs args)
       throws BadRequestException, ResourceConflictException, IOException, ConfigInvalidException {
     final Project.NameKey nameKey = args.getProject();
     try {
@@ -243,7 +239,7 @@
 
         fire(nameKey, head);
 
-        return projectCache.get(nameKey).getProject();
+        return projectCache.get(nameKey);
       }
     } catch (RepositoryCaseMismatchException e) {
       throw new ResourceConflictException(
@@ -277,8 +273,10 @@
       newProject.setCreateNewChangeForAllNotInTarget(args.newChangeForAllNotInTarget);
       newProject.setRequireChangeID(args.changeIdRequired);
       newProject.setMaxObjectSizeLimit(args.maxObjectSizeLimit);
+      newProject.setEnableSignedPush(args.enableSignedPush);
+      newProject.setRequireSignedPush(args.requireSignedPush);
       if (args.newParent != null) {
-        newProject.setParentName(args.newParent.getProject().getNameKey());
+        newProject.setParentName(args.newParent);
       }
 
       if (!args.ownerIds.isEmpty()) {
@@ -350,6 +348,8 @@
           case REJECTED:
           case REJECTED_CURRENT_BRANCH:
           case RENAMED:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
           default:
             {
               throw new IOException(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java
index 5642721..01f456e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProjectArgs.java
@@ -24,7 +24,7 @@
 
   private Project.NameKey projectName;
   public List<AccountGroup.UUID> ownerIds;
-  public ProjectControl newParent;
+  public Project.NameKey newParent;
   public String projectDescription;
   public SubmitType submitType;
   public InheritableBoolean contributorAgreements;
@@ -34,6 +34,8 @@
   public InheritableBoolean contentMerge;
   public InheritableBoolean newChangeForAllNotInTarget;
   public InheritableBoolean changeIdRequired;
+  public InheritableBoolean enableSignedPush;
+  public InheritableBoolean requireSignedPush;
   public boolean createEmptyCommit;
   public String maxObjectSizeLimit;
 
@@ -43,6 +45,8 @@
     contentMerge = InheritableBoolean.INHERIT;
     changeIdRequired = InheritableBoolean.INHERIT;
     newChangeForAllNotInTarget = InheritableBoolean.INHERIT;
+    enableSignedPush = InheritableBoolean.INHERIT;
+    requireSignedPush = InheritableBoolean.INHERIT;
     submitType = SubmitType.MERGE_IF_NECESSARY;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.java
new file mode 100644
index 0000000..3be260e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateRefControl.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.server.project;
+
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Branch;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevTag;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Manages access control for creating Git references (aka branches, tags). */
+@Singleton
+public class CreateRefControl {
+  private static final Logger log = LoggerFactory.getLogger(CreateRefControl.class);
+
+  private final PermissionBackend permissionBackend;
+  private final ProjectCache projectCache;
+
+  @Inject
+  CreateRefControl(PermissionBackend permissionBackend, ProjectCache projectCache) {
+    this.permissionBackend = permissionBackend;
+    this.projectCache = projectCache;
+  }
+
+  /**
+   * Checks whether the {@link CurrentUser} can create a new Git ref.
+   *
+   * @param user the user performing the operation
+   * @param repo repository on which user want to create
+   * @param branch the branch the new {@link RevObject} should be created on
+   * @param object the object the user will start the reference with
+   * @throws AuthException if creation is denied; the message explains the denial.
+   * @throws PermissionBackendException on failure of permission checks.
+   */
+  public void checkCreateRef(
+      Provider<? extends CurrentUser> user,
+      Repository repo,
+      Branch.NameKey branch,
+      RevObject object)
+      throws AuthException, PermissionBackendException, NoSuchProjectException, IOException {
+    ProjectState ps = projectCache.checkedGet(branch.getParentKey());
+    if (ps == null) {
+      throw new NoSuchProjectException(branch.getParentKey());
+    }
+    if (!ps.getProject().getState().permitsWrite()) {
+      throw new AuthException("project state does not permit write");
+    }
+
+    PermissionBackend.ForRef perm = permissionBackend.user(user).ref(branch);
+    if (object instanceof RevCommit) {
+      perm.check(RefPermission.CREATE);
+      checkCreateCommit(user, repo, (RevCommit) object, ps, perm);
+    } else if (object instanceof RevTag) {
+      RevTag tag = (RevTag) object;
+      try (RevWalk rw = new RevWalk(repo)) {
+        rw.parseBody(tag);
+      } catch (IOException e) {
+        log.error("RevWalk({}) parsing {}:", branch.getParentKey(), tag.name(), e);
+        throw e;
+      }
+
+      // If tagger is present, require it matches the user's email.
+      PersonIdent tagger = tag.getTaggerIdent();
+      if (tagger != null
+          && (!user.get().isIdentifiedUser()
+              || !user.get().asIdentifiedUser().hasEmailAddress(tagger.getEmailAddress()))) {
+        perm.check(RefPermission.FORGE_COMMITTER);
+      }
+
+      RevObject target = tag.getObject();
+      if (target instanceof RevCommit) {
+        checkCreateCommit(user, repo, (RevCommit) target, ps, perm);
+      } else {
+        checkCreateRef(user, repo, branch, target);
+      }
+
+      // If the tag has a PGP signature, allow a lower level of permission
+      // than if it doesn't have a PGP signature.
+      RefControl refControl = ps.controlFor(user.get()).controlForRef(branch);
+      if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
+        if (!refControl.canPerform(Permission.CREATE_SIGNED_TAG)) {
+          throw new AuthException(Permission.CREATE_SIGNED_TAG + " not permitted");
+        }
+      } else if (!refControl.canPerform(Permission.CREATE_TAG)) {
+        throw new AuthException(Permission.CREATE_TAG + " not permitted");
+      }
+    }
+  }
+
+  /**
+   * Check if the user is allowed to create a new commit object if this creation would introduce a
+   * new commit to the repository.
+   */
+  private void checkCreateCommit(
+      Provider<? extends CurrentUser> user,
+      Repository repo,
+      RevCommit commit,
+      ProjectState projectState,
+      PermissionBackend.ForRef forRef)
+      throws AuthException, PermissionBackendException {
+    try {
+      // If the user has update (push) permission, they can create the ref regardless
+      // of whether they are pushing any new objects along with the create.
+      forRef.check(RefPermission.UPDATE);
+      return;
+    } catch (AuthException denied) {
+      // Fall through to check reachability.
+    }
+
+    if (projectState.controlFor(user.get()).isReachableFromHeadsOrTags(repo, commit)) {
+      // If the user has no push permissions, check whether the object is
+      // merged into a branch or tag readable by this user. If so, they are
+      // not effectively "pushing" more objects, so they can create the ref
+      // even if they don't have push permission.
+      return;
+    }
+
+    throw new AuthException(
+        String.format(
+            "%s for creating new commit object not permitted",
+            RefPermission.UPDATE.describeForException()));
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
index 3a6db40..61548c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateTag.java
@@ -32,6 +32,9 @@
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.project.RefUtil.InvalidRevisionException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -57,6 +60,7 @@
     CreateTag create(String ref);
   }
 
+  private final PermissionBackend permissionBackend;
   private final Provider<IdentifiedUser> identifiedUser;
   private final GitRepositoryManager repoManager;
   private final TagCache tagCache;
@@ -66,12 +70,14 @@
 
   @Inject
   CreateTag(
+      PermissionBackend permissionBackend,
       Provider<IdentifiedUser> identifiedUser,
       GitRepositoryManager repoManager,
       TagCache tagCache,
       GitReferenceUpdated referenceUpdated,
       WebLinks webLinks,
       @Assisted String ref) {
+    this.permissionBackend = permissionBackend;
     this.identifiedUser = identifiedUser;
     this.repoManager = repoManager;
     this.tagCache = tagCache;
@@ -82,7 +88,7 @@
 
   @Override
   public TagInfo apply(ProjectResource resource, TagInput input)
-      throws RestApiException, IOException {
+      throws RestApiException, IOException, PermissionBackendException {
     if (input == null) {
       input = new TagInput();
     }
@@ -96,6 +102,9 @@
     ref = RefUtil.normalizeTagRef(ref);
 
     RefControl refControl = resource.getControl().controlForRef(ref);
+    PermissionBackend.ForRef perm =
+        permissionBackend.user(identifiedUser).project(resource.getNameKey()).ref(ref);
+
     try (Repository repo = repoManager.openRepository(resource.getNameKey())) {
       ObjectId revid = RefUtil.parseBaseRevision(repo, resource.getNameKey(), input.revision);
       RevWalk rw = RefUtil.verifyConnected(repo, revid);
@@ -107,8 +116,8 @@
         throw new MethodNotAllowedException("Cannot create signed tag \"" + ref + "\"");
       } else if (isAnnotated && !refControl.canPerform(Permission.CREATE_TAG)) {
         throw new AuthException("Cannot create annotated tag \"" + ref + "\"");
-      } else if (!refControl.canPerform(Permission.CREATE)) {
-        throw new AuthException("Cannot create tag \"" + ref + "\"");
+      } else {
+        perm.check(RefPermission.CREATE);
       }
       if (repo.getRefDatabase().exactRef(ref) != null) {
         throw new ResourceConflictException("tag \"" + ref + "\" already exists");
@@ -138,8 +147,7 @@
             result.getObjectId(),
             identifiedUser.get().getAccount());
         try (RevWalk w = new RevWalk(repo)) {
-          ProjectControl pctl = resource.getControl();
-          return ListTags.createTagInfo(result, w, refControl, pctl, links);
+          return ListTags.createTagInfo(perm, result, w, resource.getNameKey(), links);
         }
       }
     } catch (InvalidRevisionException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
index 2747e68..d43a066 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DashboardsCollection.java
@@ -21,8 +21,12 @@
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.api.projects.DashboardSectionInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ChildCollection;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
@@ -34,12 +38,13 @@
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.UrlEncoded;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gson.annotations.SerializedName;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jgit.errors.AmbiguousObjectException;
 import org.eclipse.jgit.errors.ConfigInvalidException;
@@ -51,23 +56,36 @@
 import org.eclipse.jgit.lib.Repository;
 
 @Singleton
-class DashboardsCollection
+public class DashboardsCollection
     implements ChildCollection<ProjectResource, DashboardResource>, AcceptsCreate<ProjectResource> {
+  public static final String DEFAULT_DASHBOARD_NAME = "default";
+
   private final GitRepositoryManager gitManager;
   private final DynamicMap<RestView<DashboardResource>> views;
   private final Provider<ListDashboards> list;
   private final Provider<SetDefaultDashboard.CreateDefault> createDefault;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   DashboardsCollection(
       GitRepositoryManager gitManager,
       DynamicMap<RestView<DashboardResource>> views,
       Provider<ListDashboards> list,
-      Provider<SetDefaultDashboard.CreateDefault> createDefault) {
+      Provider<SetDefaultDashboard.CreateDefault> createDefault,
+      PermissionBackend permissionBackend) {
     this.gitManager = gitManager;
     this.views = views;
     this.list = list;
     this.createDefault = createDefault;
+    this.permissionBackend = permissionBackend;
+  }
+
+  public static boolean isDefaultDashboard(@Nullable String id) {
+    return DEFAULT_DASHBOARD_NAME.equals(id);
+  }
+
+  public static boolean isDefaultDashboard(@Nullable IdString id) {
+    return id != null && isDefaultDashboard(id.toString());
   }
 
   @Override
@@ -75,11 +93,10 @@
     return list.get();
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public RestModifyView<ProjectResource, ?> create(ProjectResource parent, IdString id)
       throws RestApiException {
-    if (id.toString().equals("default")) {
+    if (isDefaultDashboard(id)) {
       return createDefault.get();
     }
     throw new ResourceNotFoundException(id);
@@ -87,23 +104,24 @@
 
   @Override
   public DashboardResource parse(ProjectResource parent, IdString id)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException {
+      throws ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     ProjectControl myCtl = parent.getControl();
-    if (id.toString().equals("default")) {
+    if (isDefaultDashboard(id)) {
       return DashboardResource.projectDefault(myCtl);
     }
 
-    List<String> parts = Lists.newArrayList(Splitter.on(':').limit(2).split(id.get()));
-    if (parts.size() != 2) {
+    DashboardInfo info;
+    try {
+      info = newDashboardInfo(id.get());
+    } catch (InvalidDashboardId e) {
       throw new ResourceNotFoundException(id);
     }
 
     CurrentUser user = myCtl.getUser();
-    String ref = parts.get(0);
-    String path = parts.get(1);
     for (ProjectState ps : myCtl.getProjectState().tree()) {
       try {
-        return parse(ps.controlFor(user), ref, path, myCtl);
+        return parse(ps.controlFor(user), info, myCtl);
       } catch (AmbiguousObjectException | ConfigInvalidException | IncorrectObjectTypeException e) {
         throw new ResourceNotFoundException(id);
       } catch (ResourceNotFoundException e) {
@@ -113,26 +131,40 @@
     throw new ResourceNotFoundException(id);
   }
 
-  private DashboardResource parse(ProjectControl ctl, String ref, String path, ProjectControl myCtl)
-      throws ResourceNotFoundException, IOException, AmbiguousObjectException,
-          IncorrectObjectTypeException, ConfigInvalidException {
-    String id = ref + ":" + path;
+  public static String normalizeDashboardRef(String ref) {
     if (!ref.startsWith(REFS_DASHBOARDS)) {
-      ref = REFS_DASHBOARDS + ref;
+      return REFS_DASHBOARDS + ref;
     }
-    if (!Repository.isValidRefName(ref) || !ctl.controlForRef(ref).isVisible()) {
-      throw new ResourceNotFoundException(id);
+    return ref;
+  }
+
+  private DashboardResource parse(ProjectControl ctl, DashboardInfo info, ProjectControl myCtl)
+      throws ResourceNotFoundException, IOException, AmbiguousObjectException,
+          IncorrectObjectTypeException, ConfigInvalidException, PermissionBackendException {
+    String ref = normalizeDashboardRef(info.ref);
+    try {
+      permissionBackend
+          .user(ctl.getUser())
+          .project(ctl.getProject().getNameKey())
+          .ref(ref)
+          .check(RefPermission.READ);
+    } catch (AuthException e) {
+      // Don't leak the project's existence
+      throw new ResourceNotFoundException(info.id);
+    }
+    if (!Repository.isValidRefName(ref)) {
+      throw new ResourceNotFoundException(info.id);
     }
 
     try (Repository git = gitManager.openRepository(ctl.getProject().getNameKey())) {
-      ObjectId objId = git.resolve(ref + ":" + path);
+      ObjectId objId = git.resolve(ref + ":" + info.path);
       if (objId == null) {
-        throw new ResourceNotFoundException(id);
+        throw new ResourceNotFoundException(info.id);
       }
       BlobBasedConfig cfg = new BlobBasedConfig(null, git, objId);
-      return new DashboardResource(myCtl, ref, path, cfg, false);
+      return new DashboardResource(myCtl, ref, info.path, cfg, false);
     } catch (RepositoryNotFoundException e) {
-      throw new ResourceNotFoundException(id);
+      throw new ResourceNotFoundException(info.id);
     }
   }
 
@@ -141,6 +173,34 @@
     return views;
   }
 
+  public static DashboardInfo newDashboardInfo(String ref, String path) {
+    DashboardInfo info = new DashboardInfo();
+    info.ref = ref;
+    info.path = path;
+    info.id = Joiner.on(':').join(Url.encode(ref), Url.encode(path));
+    return info;
+  }
+
+  public static class InvalidDashboardId extends Exception {
+    private static final long serialVersionUID = 1L;
+
+    public InvalidDashboardId(String id) {
+      super(id);
+    }
+  }
+
+  static DashboardInfo newDashboardInfo(String id) throws InvalidDashboardId {
+    DashboardInfo info = new DashboardInfo();
+    List<String> parts = Lists.newArrayList(Splitter.on(':').limit(2).split(id));
+    if (parts.size() != 2) {
+      throw new InvalidDashboardId(id);
+    }
+    info.id = id;
+    info.ref = parts.get(0);
+    info.path = parts.get(1);
+    return info;
+  }
+
   static DashboardInfo parse(
       Project definingProject,
       String refName,
@@ -148,7 +208,7 @@
       Config config,
       String project,
       boolean setDefault) {
-    DashboardInfo info = new DashboardInfo(refName, path);
+    DashboardInfo info = newDashboardInfo(refName, path);
     info.project = project;
     info.definingProject = definingProject.getName();
     String query = config.getString("dashboard", null, "title");
@@ -167,7 +227,7 @@
       u.put("foreach", replace(project, info.foreach));
     }
     for (String name : config.getSubsections("section")) {
-      Section s = new Section();
+      DashboardSectionInfo s = new DashboardSectionInfo();
       s.name = name;
       s.query = config.getString("section", name, "query");
       u.put(s.name, replace(project, s.query));
@@ -191,32 +251,4 @@
     }
     return defaultId;
   }
-
-  static class DashboardInfo {
-    String id;
-    String project;
-    String definingProject;
-    String ref;
-    String path;
-    String description;
-    String foreach;
-    String url;
-
-    @SerializedName("default")
-    Boolean isDefault;
-
-    String title;
-    List<Section> sections = new ArrayList<>();
-
-    DashboardInfo(String ref, String name) {
-      this.ref = ref;
-      this.path = name;
-      this.id = Joiner.on(':').join(Url.encode(ref), Url.encode(path));
-    }
-  }
-
-  static class Section {
-    String name;
-    String query;
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackend.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackend.java
new file mode 100644
index 0000000..3f4cbb9
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackend.java
@@ -0,0 +1,205 @@
+// 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.project;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static java.util.stream.Collectors.toSet;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.api.access.PluginPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.PeerDaemonUser;
+import com.google.gerrit.server.account.CapabilityCollection;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.List;
+import java.util.Set;
+
+@Singleton
+public class DefaultPermissionBackend extends PermissionBackend {
+  private static final CurrentUser.PropertyKey<Boolean> IS_ADMIN = CurrentUser.PropertyKey.create();
+
+  private final ProjectCache projectCache;
+
+  @Inject
+  DefaultPermissionBackend(ProjectCache projectCache) {
+    this.projectCache = projectCache;
+  }
+
+  private CapabilityCollection capabilities() {
+    return projectCache.getAllProjects().getCapabilityCollection();
+  }
+
+  @Override
+  public WithUser user(CurrentUser user) {
+    return new WithUserImpl(checkNotNull(user, "user"));
+  }
+
+  class WithUserImpl extends WithUser {
+    private final CurrentUser user;
+    private Boolean admin;
+
+    WithUserImpl(CurrentUser user) {
+      this.user = checkNotNull(user, "user");
+    }
+
+    @Override
+    public ForProject project(Project.NameKey project) {
+      try {
+        return projectCache.checkedGet(project, true).controlFor(user).asForProject().database(db);
+      } catch (Exception e) {
+        Throwable cause = e.getCause() != null ? e.getCause() : e;
+        return FailedPermissionBackend.project(
+            "project '" + project.get() + "' is unavailable", cause);
+      }
+    }
+
+    @Override
+    public void check(GlobalOrPluginPermission perm)
+        throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public <T extends GlobalOrPluginPermission> Set<T> test(Collection<T> permSet)
+        throws PermissionBackendException {
+      Set<T> ok = newSet(permSet);
+      for (T perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(GlobalOrPluginPermission perm) throws PermissionBackendException {
+      if (perm instanceof GlobalPermission) {
+        return can((GlobalPermission) perm);
+      } else if (perm instanceof PluginPermission) {
+        PluginPermission pluginPermission = (PluginPermission) perm;
+        return has(pluginPermission.permissionName())
+            || (pluginPermission.fallBackToAdmin() && isAdmin());
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean can(GlobalPermission perm) throws PermissionBackendException {
+      switch (perm) {
+        case ADMINISTRATE_SERVER:
+          return isAdmin();
+        case EMAIL_REVIEWERS:
+          return canEmailReviewers();
+
+        case FLUSH_CACHES:
+        case KILL_TASK:
+        case RUN_GC:
+        case VIEW_CACHES:
+        case VIEW_QUEUE:
+          return has(perm.permissionName()) || can(GlobalPermission.MAINTAIN_SERVER);
+
+        case CREATE_ACCOUNT:
+        case CREATE_GROUP:
+        case CREATE_PROJECT:
+        case MAINTAIN_SERVER:
+        case MODIFY_ACCOUNT:
+        case STREAM_EVENTS:
+        case VIEW_ALL_ACCOUNTS:
+        case VIEW_CONNECTIONS:
+        case VIEW_PLUGINS:
+          return has(perm.permissionName()) || isAdmin();
+
+        case ACCESS_DATABASE:
+        case RUN_AS:
+          return has(perm.permissionName());
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+
+    private boolean isAdmin() {
+      if (admin == null) {
+        admin = computeAdmin();
+      }
+      return admin;
+    }
+
+    private Boolean computeAdmin() {
+      Boolean r = user.get(IS_ADMIN);
+      if (r == null) {
+        if (user.isImpersonating()) {
+          r = false;
+        } else if (user instanceof PeerDaemonUser) {
+          r = true;
+        } else {
+          r = allow(capabilities().administrateServer);
+        }
+        user.put(IS_ADMIN, r);
+      }
+      return r;
+    }
+
+    private boolean canEmailReviewers() {
+      List<PermissionRule> email = capabilities().emailReviewers;
+      return allow(email) || notDenied(email);
+    }
+
+    private boolean has(String permissionName) {
+      return allow(capabilities().getPermission(permissionName));
+    }
+
+    private boolean allow(Collection<PermissionRule> rules) {
+      return user.getEffectiveGroups()
+          .containsAnyOf(
+              rules
+                  .stream()
+                  .filter(r -> r.getAction() == Action.ALLOW)
+                  .map(r -> r.getGroup().getUUID())
+                  .collect(toSet()));
+    }
+
+    private boolean notDenied(Collection<PermissionRule> rules) {
+      Set<AccountGroup.UUID> denied =
+          rules
+              .stream()
+              .filter(r -> r.getAction() != Action.ALLOW)
+              .map(r -> r.getGroup().getUUID())
+              .collect(toSet());
+      return denied.isEmpty() || !user.getEffectiveGroups().containsAnyOf(denied);
+    }
+  }
+
+  private static <T extends GlobalOrPluginPermission> Set<T> newSet(Collection<T> permSet) {
+    if (permSet instanceof EnumSet) {
+      @SuppressWarnings({"unchecked", "rawtypes"})
+      Set<T> s = ((EnumSet) permSet).clone();
+      s.clear();
+      return s;
+    }
+    return Sets.newHashSetWithExpectedSize(permSet.size());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java
new file mode 100644
index 0000000..bdfc67f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DefaultPermissionBackendModule.java
@@ -0,0 +1,38 @@
+// 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.project;
+
+import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.inject.AbstractModule;
+
+/** Binds the default {@link PermissionBackend}. */
+public class DefaultPermissionBackendModule extends AbstractModule {
+  @Override
+  protected void configure() {
+    install(new LegacyControlsModule());
+  }
+
+  /** Binds legacy ProjectControl, RefControl, ChangeControl. */
+  public static class LegacyControlsModule extends FactoryModule {
+    @Override
+    protected void configure() {
+      // TODO(sop) Hide ProjectControl, RefControl, ChangeControl related bindings.
+      bind(ProjectControl.GenericFactory.class);
+      factory(ProjectControl.AssistedFactory.class);
+      bind(ChangeControl.Factory.class);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
index 049e2e3..8cd44d1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranch.java
@@ -16,11 +16,14 @@
 
 import static org.eclipse.jgit.lib.Constants.R_HEADS;
 
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+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.DeleteBranch.Input;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
@@ -35,19 +38,25 @@
 
   private final Provider<InternalChangeQuery> queryProvider;
   private final DeleteRef.Factory deleteRefFactory;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
 
   @Inject
-  DeleteBranch(Provider<InternalChangeQuery> queryProvider, DeleteRef.Factory deleteRefFactory) {
+  DeleteBranch(
+      Provider<InternalChangeQuery> queryProvider,
+      DeleteRef.Factory deleteRefFactory,
+      Provider<CurrentUser> user,
+      PermissionBackend permissionBackend) {
     this.queryProvider = queryProvider;
     this.deleteRefFactory = deleteRefFactory;
+    this.user = user;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
   public Response<?> apply(BranchResource rsrc, Input input)
-      throws RestApiException, OrmException, IOException {
-    if (!rsrc.getControl().controlForRef(rsrc.getBranchKey()).canDelete()) {
-      throw new AuthException("Cannot delete branch");
-    }
+      throws RestApiException, OrmException, IOException, PermissionBackendException {
+    permissionBackend.user(user).ref(rsrc.getBranchKey()).check(RefPermission.DELETE);
 
     if (!queryProvider.get().setLimit(1).byBranchOpen(rsrc.getBranchKey()).isEmpty()) {
       throw new ResourceConflictException("branch " + rsrc.getBranchKey() + " has open changes");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
index 4c54423..fa7e917 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteBranches.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -37,12 +38,10 @@
 
   @Override
   public Response<?> apply(ProjectResource project, DeleteBranchesInput input)
-      throws OrmException, IOException, RestApiException {
-
+      throws OrmException, IOException, RestApiException, PermissionBackendException {
     if (input == null || input.branches == null || input.branches.isEmpty()) {
       throw new BadRequestException("branches must be specified");
     }
-
     deleteRefFactory.create(project).refs(input.branches).prefix(R_HEADS).delete();
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java
index a9dd253..958de55 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteDashboard.java
@@ -14,21 +14,20 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.common.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 
 @Singleton
-class DeleteDashboard implements RestModifyView<DashboardResource, SetDashboard.Input> {
+class DeleteDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
   private final Provider<SetDefaultDashboard> defaultSetter;
 
   @Inject
@@ -37,11 +36,10 @@
   }
 
   @Override
-  public Response<DashboardInfo> apply(DashboardResource resource, SetDashboard.Input input)
-      throws AuthException, BadRequestException, ResourceConflictException,
-          ResourceNotFoundException, MethodNotAllowedException, IOException {
+  public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
+      throws RestApiException, IOException, PermissionBackendException {
     if (resource.isProjectDefault()) {
-      SetDashboard.Input in = new SetDashboard.Input();
+      SetDashboardInput in = new SetDashboardInput();
       in.commitMessage = input != null ? input.commitMessage : null;
       return defaultSetter.get().apply(resource, in);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
index e950cc8..57b44c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteRef.java
@@ -16,6 +16,7 @@
 
 import static java.lang.String.format;
 import static java.util.stream.Collectors.toList;
+import static org.eclipse.jgit.lib.Constants.R_REFS;
 import static org.eclipse.jgit.lib.Constants.R_TAGS;
 import static org.eclipse.jgit.transport.ReceiveCommand.Type.DELETE;
 
@@ -25,11 +26,14 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
@@ -53,6 +57,7 @@
   private static final long SLEEP_ON_LOCK_FAILURE_MS = 15;
 
   private final Provider<IdentifiedUser> identifiedUser;
+  private final PermissionBackend permissionBackend;
   private final GitRepositoryManager repoManager;
   private final GitReferenceUpdated referenceUpdated;
   private final RefValidationHelper refDeletionValidator;
@@ -65,15 +70,17 @@
     DeleteRef create(ProjectResource r);
   }
 
-  @AssistedInject
+  @Inject
   DeleteRef(
       Provider<IdentifiedUser> identifiedUser,
+      PermissionBackend permissionBackend,
       GitRepositoryManager repoManager,
       GitReferenceUpdated referenceUpdated,
       RefValidationHelper.Factory refDeletionValidatorFactory,
       Provider<InternalChangeQuery> queryProvider,
       @Assisted ProjectResource resource) {
     this.identifiedUser = identifiedUser;
+    this.permissionBackend = permissionBackend;
     this.repoManager = repoManager;
     this.referenceUpdated = referenceUpdated;
     this.refDeletionValidator = refDeletionValidatorFactory.create(DELETE);
@@ -97,7 +104,9 @@
     return this;
   }
 
-  public void delete() throws OrmException, IOException, ResourceConflictException, AuthException {
+  public void delete()
+      throws OrmException, IOException, ResourceConflictException, AuthException,
+          PermissionBackendException {
     if (!refsToDelete.isEmpty()) {
       try (Repository r = repoManager.openRepository(resource.getNameKey())) {
         if (refsToDelete.size() == 1) {
@@ -110,15 +119,17 @@
   }
 
   private void deleteSingleRef(Repository r)
-      throws IOException, ResourceConflictException, AuthException {
+      throws IOException, ResourceConflictException, AuthException, PermissionBackendException {
     String ref = refsToDelete.get(0);
-    if (prefix != null && !ref.startsWith(prefix)) {
+    if (prefix != null && !ref.startsWith(R_REFS)) {
       ref = prefix + ref;
     }
 
-    if (!resource.getControl().controlForRef(ref).canDelete()) {
-      throw new AuthException("delete not permitted for " + ref);
-    }
+    permissionBackend
+        .user(identifiedUser)
+        .project(resource.getNameKey())
+        .ref(ref)
+        .check(RefPermission.DELETE);
 
     RefUpdate.Result result;
     RefUpdate u = r.updateRef(ref);
@@ -168,6 +179,8 @@
       case NOT_ATTEMPTED:
       case REJECTED:
       case RENAMED:
+      case REJECTED_MISSING_OBJECT:
+      case REJECTED_OTHER_REASON:
       default:
         log.error("Cannot delete " + ref + ": " + result.name());
         throw new ResourceConflictException("cannot delete: " + result.name());
@@ -175,14 +188,15 @@
   }
 
   private void deleteMultipleRefs(Repository r)
-      throws OrmException, IOException, ResourceConflictException {
+      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
     BatchRefUpdate batchUpdate = r.getRefDatabase().newBatchUpdate();
+    batchUpdate.setAtomic(false);
     List<String> refs =
         prefix == null
             ? refsToDelete
             : refsToDelete
                 .stream()
-                .map(ref -> ref.startsWith(prefix) ? ref : prefix + ref)
+                .map(ref -> ref.startsWith(R_REFS) ? ref : prefix + ref)
                 .collect(toList());
     for (String ref : refs) {
       batchUpdate.addCommand(createDeleteCommand(resource, r, ref));
@@ -204,7 +218,7 @@
   }
 
   private ReceiveCommand createDeleteCommand(ProjectResource project, Repository r, String refName)
-      throws OrmException, IOException, ResourceConflictException {
+      throws OrmException, IOException, ResourceConflictException, PermissionBackendException {
     Ref ref = r.getRefDatabase().getRef(refName);
     ReceiveCommand command;
     if (ref == null) {
@@ -216,7 +230,13 @@
     }
     command = new ReceiveCommand(ref.getObjectId(), ObjectId.zeroId(), ref.getName());
 
-    if (!project.getControl().controlForRef(refName).canDelete()) {
+    try {
+      permissionBackend
+          .user(identifiedUser)
+          .project(project.getNameKey())
+          .ref(refName)
+          .check(RefPermission.DELETE);
+    } catch (AuthException denied) {
       command.setResult(
           Result.REJECTED_OTHER_REASON,
           "it doesn't exist or you do not have permission to delete it");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
index f26d40f..a05fa2e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTag.java
@@ -14,36 +14,46 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 
 @Singleton
 public class DeleteTag implements RestModifyView<TagResource, DeleteTag.Input> {
-  private final DeleteRef.Factory deleteRefFactory;
-
   public static class Input {}
 
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final DeleteRef.Factory deleteRefFactory;
+
   @Inject
-  DeleteTag(DeleteRef.Factory deleteRefFactory) {
+  DeleteTag(
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      DeleteRef.Factory deleteRefFactory) {
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.deleteRefFactory = deleteRefFactory;
   }
 
   @Override
   public Response<?> apply(TagResource resource, Input input)
-      throws OrmException, RestApiException, IOException {
+      throws OrmException, RestApiException, IOException, PermissionBackendException {
     String tag = RefUtil.normalizeTagRef(resource.getTagInfo().ref);
-    RefControl refControl = resource.getControl().controlForRef(tag);
-
-    if (!refControl.canDelete()) {
-      throw new AuthException("Cannot delete tag");
-    }
-
+    permissionBackend
+        .user(user)
+        .project(resource.getNameKey())
+        .ref(tag)
+        .check(RefPermission.DELETE);
     deleteRefFactory.create(resource).ref(tag).delete();
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java
index 75cf03f..c020351 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/DeleteTags.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -37,12 +38,10 @@
 
   @Override
   public Response<?> apply(ProjectResource project, DeleteTagsInput input)
-      throws OrmException, RestApiException, IOException {
-
+      throws OrmException, RestApiException, IOException, PermissionBackendException {
     if (input == null || input.tags == null || input.tags.isEmpty()) {
       throw new BadRequestException("tags must be specified");
     }
-
     deleteRefFactory.create(project).refs(input.tags).prefix(R_TAGS).delete();
     return Response.none();
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
index 43b849f..82462b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/FileResource.java
@@ -32,30 +32,30 @@
       new TypeLiteral<RestView<FileResource>>() {};
 
   public static FileResource create(
-      GitRepositoryManager repoManager, ProjectControl project, ObjectId rev, String path)
+      GitRepositoryManager repoManager, ProjectState projectState, ObjectId rev, String path)
       throws ResourceNotFoundException, IOException {
-    try (Repository repo = repoManager.openRepository(project.getProject().getNameKey());
+    try (Repository repo = repoManager.openRepository(projectState.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
       RevTree tree = rw.parseTree(rev);
       if (TreeWalk.forPath(repo, path, tree) != null) {
-        return new FileResource(project, rev, path);
+        return new FileResource(projectState, rev, path);
       }
     }
     throw new ResourceNotFoundException(IdString.fromDecoded(path));
   }
 
-  private final ProjectControl project;
+  private final ProjectState projectState;
   private final ObjectId rev;
   private final String path;
 
-  public FileResource(ProjectControl project, ObjectId rev, String path) {
-    this.project = project;
+  public FileResource(ProjectState projectState, ObjectId rev, String path) {
+    this.projectState = projectState;
     this.rev = rev;
     this.path = path;
   }
 
-  public ProjectControl getProject() {
-    return project;
+  public ProjectState getProjectState() {
+    return projectState;
   }
 
   public ObjectId getRev() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
index 8d462a1..dd32f85 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesCollection.java
@@ -45,7 +45,7 @@
   public FileResource parse(BranchResource parent, IdString id)
       throws ResourceNotFoundException, IOException {
     return FileResource.create(
-        repoManager, parent.getControl(), ObjectId.fromString(parent.getRevision()), id.get());
+        repoManager, parent.getProjectState(), ObjectId.fromString(parent.getRevision()), id.get());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
index 807ac53..7144099 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/FilesInCommitCollection.java
@@ -46,9 +46,9 @@
   public FileResource parse(CommitResource parent, IdString id)
       throws ResourceNotFoundException, IOException {
     if (Patch.isMagic(id.get())) {
-      return new FileResource(parent.getProject(), parent.getCommit(), id.get());
+      return new FileResource(parent.getProjectState(), parent.getCommit(), id.get());
     }
-    return FileResource.create(repoManager, parent.getProject(), parent.getCommit(), id.get());
+    return FileResource.create(repoManager, parent.getProjectState(), parent.getCommit(), id.get());
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
index 654ce69..f81a0f3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GarbageCollect.java
@@ -76,7 +76,7 @@
     return applySync(project, input);
   }
 
-  private Response.Accepted applyAsync(final Project.NameKey project, final Input input) {
+  private Response.Accepted applyAsync(Project.NameKey project, Input input) {
     Runnable job =
         new Runnable() {
           @Override
@@ -103,7 +103,7 @@
   }
 
   @SuppressWarnings("resource")
-  private BinaryResult applySync(final Project.NameKey project, final Input input) {
+  private BinaryResult applySync(Project.NameKey project, Input input) {
     return new BinaryResult() {
       @Override
       public void writeTo(OutputStream out) throws IOException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
index 7989455..caa7606 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
@@ -14,6 +14,13 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.server.permissions.GlobalPermission.ADMINISTRATE_SERVER;
+import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_REF;
+import static com.google.gerrit.server.permissions.ProjectPermission.CREATE_TAG_REF;
+import static com.google.gerrit.server.permissions.RefPermission.CREATE_CHANGE;
+import static com.google.gerrit.server.permissions.RefPermission.READ;
+import static java.util.stream.Collectors.toMap;
+
 import com.google.common.collect.ImmutableBiMap;
 import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.AccessSection;
@@ -25,6 +32,8 @@
 import com.google.gerrit.extensions.api.access.PermissionInfo;
 import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -37,6 +46,12 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.GroupJson;
+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.RefPermission;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -46,9 +61,15 @@
 import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
 @Singleton
 public class GetAccess implements RestReadView<ProjectResource> {
+  private static final Logger LOG = LoggerFactory.getLogger(GetAccess.class);
+
+  /** Marker value used in {@code Map<?, GroupInfo>} for groups not visible to current user. */
+  private static final GroupInfo INVISIBLE_SENTINEL = new GroupInfo();
 
   public static final ImmutableBiMap<PermissionRule.Action, PermissionRuleInfo.Action> ACTION_TYPE =
       ImmutableBiMap.of(
@@ -63,7 +84,8 @@
           PermissionRule.Action.INTERACTIVE,
           PermissionRuleInfo.Action.INTERACTIVE);
 
-  private final Provider<CurrentUser> self;
+  private final Provider<CurrentUser> user;
+  private final PermissionBackend permissionBackend;
   private final GroupControl.Factory groupControlFactory;
   private final AllProjectsName allProjectsName;
   private final ProjectJson projectJson;
@@ -71,18 +93,22 @@
   private final MetaDataUpdate.Server metaDataUpdateFactory;
   private final ProjectControl.GenericFactory projectControlFactory;
   private final GroupBackend groupBackend;
+  private final GroupJson groupJson;
 
   @Inject
   public GetAccess(
       Provider<CurrentUser> self,
+      PermissionBackend permissionBackend,
       GroupControl.Factory groupControlFactory,
       AllProjectsName allProjectsName,
       ProjectCache projectCache,
       MetaDataUpdate.Server metaDataUpdateFactory,
       ProjectJson projectJson,
       ProjectControl.GenericFactory projectControlFactory,
-      GroupBackend groupBackend) {
-    this.self = self;
+      GroupBackend groupBackend,
+      GroupJson groupJson) {
+    this.user = self;
+    this.permissionBackend = permissionBackend;
     this.groupControlFactory = groupControlFactory;
     this.allProjectsName = allProjectsName;
     this.projectJson = projectJson;
@@ -90,12 +116,14 @@
     this.projectControlFactory = projectControlFactory;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
     this.groupBackend = groupBackend;
+    this.groupJson = groupJson;
   }
 
   public ProjectAccessInfo apply(Project.NameKey nameKey)
-      throws ResourceNotFoundException, ResourceConflictException, IOException {
+      throws ResourceNotFoundException, ResourceConflictException, IOException,
+          PermissionBackendException, OrmException {
     try {
-      return this.apply(new ProjectResource(projectControlFactory.controlFor(nameKey, self.get())));
+      return apply(new ProjectResource(projectControlFactory.controlFor(nameKey, user.get())));
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(nameKey.get());
     }
@@ -103,16 +131,18 @@
 
   @Override
   public ProjectAccessInfo apply(ProjectResource rsrc)
-      throws ResourceNotFoundException, ResourceConflictException, IOException {
+      throws ResourceNotFoundException, ResourceConflictException, IOException,
+          PermissionBackendException, OrmException {
     // Load the current configuration from the repository, ensuring it's the most
     // recent version available. If it differs from what was in the project
     // state, force a cache flush now.
-    //
+
     Project.NameKey projectName = rsrc.getNameKey();
     ProjectAccessInfo info = new ProjectAccessInfo();
+    ProjectControl pc = createProjectControl(projectName);
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(projectName);
+
     ProjectConfig config;
-    ProjectControl pc = open(projectName);
-    RefControl metaConfigControl = pc.controlForRef(RefNames.REFS_CONFIG);
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
       config = ProjectConfig.read(md);
 
@@ -120,11 +150,13 @@
         md.setMessage("Update group names\n");
         config.commit(md);
         projectCache.evict(config.getProject());
-        pc = open(projectName);
+        pc = createProjectControl(projectName);
+        perm = permissionBackend.user(user).project(projectName);
       } else if (config.getRevision() != null
           && !config.getRevision().equals(pc.getProjectState().getConfig().getRevision())) {
         projectCache.evict(config.getProject());
-        pc = open(projectName);
+        pc = createProjectControl(projectName);
+        perm = permissionBackend.user(user).project(projectName);
       }
     } catch (ConfigInvalidException e) {
       throw new ResourceConflictException(e.getMessage());
@@ -134,29 +166,29 @@
 
     info.local = new HashMap<>();
     info.ownerOf = new HashSet<>();
-    Map<AccountGroup.UUID, Boolean> visibleGroups = new HashMap<>();
+    Map<AccountGroup.UUID, GroupInfo> visibleGroups = new HashMap<>();
+    boolean checkReadConfig = check(perm, RefNames.REFS_CONFIG, READ);
 
     for (AccessSection section : config.getAccessSections()) {
       String name = section.getName();
       if (AccessSection.GLOBAL_CAPABILITIES.equals(name)) {
         if (pc.isOwner()) {
-          info.local.put(name, createAccessSection(section));
+          info.local.put(name, createAccessSection(visibleGroups, section));
           info.ownerOf.add(name);
 
-        } else if (metaConfigControl.isVisible()) {
-          info.local.put(section.getName(), createAccessSection(section));
+        } else if (checkReadConfig) {
+          info.local.put(section.getName(), createAccessSection(visibleGroups, section));
         }
 
       } else if (RefConfigSection.isValid(name)) {
-        RefControl rc = pc.controlForRef(name);
-        if (rc.isOwner()) {
-          info.local.put(name, createAccessSection(section));
+        if (pc.controlForRef(name).isOwner()) {
+          info.local.put(name, createAccessSection(visibleGroups, section));
           info.ownerOf.add(name);
 
-        } else if (metaConfigControl.isVisible()) {
-          info.local.put(name, createAccessSection(section));
+        } else if (checkReadConfig) {
+          info.local.put(name, createAccessSection(visibleGroups, 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
@@ -167,26 +199,18 @@
             Permission dstPerm = null;
 
             for (PermissionRule srcRule : srcPerm.getRules()) {
-              AccountGroup.UUID group = srcRule.getGroup().getUUID();
-              if (group == null) {
+              AccountGroup.UUID groupId = srcRule.getGroup().getUUID();
+              if (groupId == null) {
                 continue;
               }
 
-              Boolean canSeeGroup = visibleGroups.get(group);
-              if (canSeeGroup == null) {
-                try {
-                  canSeeGroup = groupControlFactory.controlFor(group).isVisible();
-                } catch (NoSuchGroupException e) {
-                  canSeeGroup = Boolean.FALSE;
-                }
-                visibleGroups.put(group, canSeeGroup);
-              }
+              GroupInfo group = loadGroup(visibleGroups, groupId);
 
-              if (canSeeGroup) {
+              if (group != INVISIBLE_SENTINEL) {
                 if (dstPerm == null) {
                   if (dst == null) {
                     dst = new AccessSection(name);
-                    info.local.put(name, createAccessSection(dst));
+                    info.local.put(name, createAccessSection(visibleGroups, dst));
                   }
                   dstPerm = dst.getPermission(srcPerm.getName(), true);
                 }
@@ -198,10 +222,10 @@
       }
     }
 
-    if (info.ownerOf.isEmpty() && pc.isOwnerAnyRef()) {
+    if (info.ownerOf.isEmpty()
+        && permissionBackend.user(user).test(GlobalPermission.ADMINISTRATE_SERVER)) {
       // 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.
       info.ownerOf.add(AccessSection.ALL);
     }
 
@@ -214,23 +238,63 @@
       info.inheritsFrom = projectJson.format(parent.getProject());
     }
 
-    if (pc.getProject().getNameKey().equals(allProjectsName)) {
-      if (pc.isOwner()) {
-        info.ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
-      }
+    if (projectName.equals(allProjectsName)
+        && permissionBackend.user(user).testOrFalse(ADMINISTRATE_SERVER)) {
+      info.ownerOf.add(AccessSection.GLOBAL_CAPABILITIES);
     }
 
     info.isOwner = toBoolean(pc.isOwner());
     info.canUpload =
-        toBoolean(pc.isOwner() || (metaConfigControl.isVisible() && metaConfigControl.canUpload()));
-    info.canAdd = toBoolean(pc.canAddRefs());
-    info.canAddTags = toBoolean(pc.canAddTagRefs());
-    info.configVisible = pc.isOwner() || metaConfigControl.isVisible();
+        toBoolean(
+            pc.isOwner()
+                || (checkReadConfig && perm.ref(RefNames.REFS_CONFIG).testOrFalse(CREATE_CHANGE)));
+    info.canAdd = toBoolean(perm.testOrFalse(CREATE_REF));
+    info.canAddTags = toBoolean(perm.testOrFalse(CREATE_TAG_REF));
+    info.configVisible = checkReadConfig || pc.isOwner();
+
+    info.groups =
+        visibleGroups
+            .entrySet()
+            .stream()
+            .filter(e -> e.getValue() != INVISIBLE_SENTINEL)
+            .collect(toMap(e -> e.getKey().get(), e -> e.getValue()));
 
     return info;
   }
 
-  private AccessSectionInfo createAccessSection(AccessSection section) {
+  private GroupInfo loadGroup(Map<AccountGroup.UUID, GroupInfo> visibleGroups, AccountGroup.UUID id)
+      throws OrmException {
+    GroupInfo group = visibleGroups.get(id);
+    if (group == null) {
+      try {
+        GroupControl control = groupControlFactory.controlFor(id);
+        group = INVISIBLE_SENTINEL;
+        if (control.isVisible()) {
+          group = groupJson.format(control.getGroup());
+          group.id = null;
+        }
+      } catch (NoSuchGroupException e) {
+        LOG.warn("NoSuchGroupException; ignoring group " + id, e);
+        group = INVISIBLE_SENTINEL;
+      }
+      visibleGroups.put(id, group);
+    }
+
+    return group;
+  }
+
+  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 AccessSectionInfo createAccessSection(
+      Map<AccountGroup.UUID, GroupInfo> groups, AccessSection section) throws OrmException {
     AccessSectionInfo accessSectionInfo = new AccessSectionInfo();
     accessSectionInfo.permissions = new HashMap<>();
     for (Permission p : section.getPermissions()) {
@@ -246,6 +310,7 @@
         AccountGroup.UUID group = r.getGroup().getUUID();
         if (group != null) {
           pInfo.rules.put(group.get(), info);
+          loadGroup(groups, group);
         }
       }
       accessSectionInfo.permissions.put(p.getName(), pInfo);
@@ -253,11 +318,10 @@
     return accessSectionInfo;
   }
 
-  private ProjectControl open(Project.NameKey projectName)
-      throws ResourceNotFoundException, IOException {
+  private ProjectControl createProjectControl(Project.NameKey projectName)
+      throws IOException, ResourceNotFoundException {
     try {
-      return projectControlFactory.validateFor(
-          projectName, ProjectControl.OWNER | ProjectControl.VISIBLE, self.get());
+      return projectControlFactory.controlFor(projectName, user.get());
     } catch (NoSuchProjectException e) {
       throw new ResourceNotFoundException(projectName.get());
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetBranch.java
index 78878a7..d312bde 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetBranch.java
@@ -15,14 +15,26 @@
 package com.google.gerrit.server.project;
 
 import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
+import java.io.IOException;
 
 @Singleton
 public class GetBranch implements RestReadView<BranchResource> {
+  private final Provider<ListBranches> list;
+
+  @Inject
+  GetBranch(Provider<ListBranches> list) {
+    this.list = list;
+  }
 
   @Override
-  public BranchInfo apply(BranchResource rsrc) {
-    return rsrc.getBranchInfo();
+  public BranchInfo apply(BranchResource rsrc)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    return list.get().toBranchInfo(rsrc);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
index 53e1baa..afffdfc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetChildProject.java
@@ -39,6 +39,6 @@
     if (recursive || rsrc.isDirectChild()) {
       return json.format(rsrc.getChild().getProject());
     }
-    throw new ResourceNotFoundException(rsrc.getChild().getProject().getName());
+    throw new ResourceNotFoundException(rsrc.getChild().getName());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
index 1bf001b..3995e1f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetConfig.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
@@ -31,6 +32,7 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
+  private final UiActions uiActions;
   private final DynamicMap<RestView<ProjectResource>> views;
 
   @Inject
@@ -39,11 +41,13 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views) {
     this.serverEnableSignedPush = serverEnableSignedPush;
     this.pluginConfigEntries = pluginConfigEntries;
     this.allProjects = allProjects;
     this.cfgFactory = cfgFactory;
+    this.uiActions = uiActions;
     this.views = views;
   }
 
@@ -55,6 +59,7 @@
         pluginConfigEntries,
         cfgFactory,
         allProjects,
+        uiActions,
         views);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
index 10da990f..b5294c4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetContent.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
@@ -32,8 +33,8 @@
   }
 
   @Override
-  public BinaryResult apply(FileResource rsrc) throws ResourceNotFoundException, IOException {
-    return fileContentUtil.getContent(
-        rsrc.getProject().getProjectState(), rsrc.getRev(), rsrc.getPath());
+  public BinaryResult apply(FileResource rsrc)
+      throws ResourceNotFoundException, BadRequestException, IOException {
+    return fileContentUtil.getContent(rsrc.getProjectState(), rsrc.getRev(), rsrc.getPath(), null);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java
index 1549658..cdf23bb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDashboard.java
@@ -15,23 +15,27 @@
 package com.google.gerrit.server.project;
 
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
+import static com.google.gerrit.server.project.DashboardsCollection.isDefaultDashboard;
 
 import com.google.common.base.Splitter;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.IdString;
 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.RestReadView;
 import com.google.gerrit.extensions.restapi.Url;
-import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.kohsuke.args4j.Option;
 
-class GetDashboard implements RestReadView<DashboardResource> {
+public class GetDashboard implements RestReadView<DashboardResource> {
   private final DashboardsCollection dashboards;
 
   @Option(name = "--inherited", usage = "include inherited dashboards")
@@ -42,12 +46,16 @@
     this.dashboards = dashboards;
   }
 
+  public GetDashboard setInherited(boolean inherited) {
+    this.inherited = inherited;
+    return this;
+  }
+
   @Override
   public DashboardInfo apply(DashboardResource resource)
-      throws ResourceNotFoundException, ResourceConflictException, IOException {
+      throws RestApiException, IOException, PermissionBackendException {
     if (inherited && !resource.isProjectDefault()) {
-      // inherited flag can only be used with default.
-      throw new ResourceNotFoundException("inherited");
+      throw new BadRequestException("inherited flag can only be used with default");
     }
 
     String project = resource.getControl().getProject().getName();
@@ -70,12 +78,13 @@
   }
 
   private DashboardResource defaultOf(ProjectControl ctl)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException {
+      throws ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     String id = ctl.getProject().getLocalDefaultDashboard();
     if (Strings.isNullOrEmpty(id)) {
       id = ctl.getProject().getDefaultDashboard();
     }
-    if ("default".equals(id)) {
+    if (isDefaultDashboard(id)) {
       throw new ResourceNotFoundException();
     } else if (!Strings.isNullOrEmpty(id)) {
       return parse(ctl, id);
@@ -85,7 +94,7 @@
 
     for (ProjectState ps : ctl.getProjectState().tree()) {
       id = ps.getProject().getDefaultDashboard();
-      if ("default".equals(id)) {
+      if (isDefaultDashboard(id)) {
         throw new ResourceNotFoundException();
       } else if (!Strings.isNullOrEmpty(id)) {
         ctl = ps.controlFor(ctl.getUser());
@@ -96,7 +105,8 @@
   }
 
   private DashboardResource parse(ProjectControl ctl, String id)
-      throws ResourceNotFoundException, IOException, ConfigInvalidException {
+      throws ResourceNotFoundException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     List<String> p = Lists.newArrayList(Splitter.on(':').limit(2).split(id));
     String ref = Url.encode(p.get(0));
     String path = Url.encode(p.get(1));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDescription.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDescription.java
index bace0a8..dd03e97 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDescription.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetDescription.java
@@ -16,14 +16,12 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.client.Project;
 import com.google.inject.Singleton;
 
 @Singleton
 public class GetDescription implements RestReadView<ProjectResource> {
   @Override
-  public String apply(ProjectResource resource) {
-    Project project = resource.getControl().getProject();
-    return Strings.nullToEmpty(project.getDescription());
+  public String apply(ProjectResource rsrc) {
+    return Strings.nullToEmpty(rsrc.getProjectState().getProject().getDescription());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
index 03db4f6..31dc7bf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetHead.java
@@ -17,10 +17,11 @@
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
@@ -34,32 +35,39 @@
 
 @Singleton
 public class GetHead implements RestReadView<ProjectResource> {
-  private GitRepositoryManager repoManager;
-  private Provider<ReviewDb> db;
+  private final GitRepositoryManager repoManager;
+  private final CommitsCollection commits;
+  private final PermissionBackend permissionBackend;
 
   @Inject
-  GetHead(GitRepositoryManager repoManager, Provider<ReviewDb> db) {
+  GetHead(
+      GitRepositoryManager repoManager,
+      CommitsCollection commits,
+      PermissionBackend permissionBackend) {
     this.repoManager = repoManager;
-    this.db = db;
+    this.commits = commits;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
   public String apply(ProjectResource rsrc)
-      throws AuthException, ResourceNotFoundException, IOException {
+      throws AuthException, ResourceNotFoundException, IOException, PermissionBackendException {
     try (Repository repo = repoManager.openRepository(rsrc.getNameKey())) {
       Ref head = repo.getRefDatabase().exactRef(Constants.HEAD);
       if (head == null) {
         throw new ResourceNotFoundException(Constants.HEAD);
       } else if (head.isSymbolic()) {
         String n = head.getTarget().getName();
-        if (rsrc.getControl().controlForRef(n).isVisible()) {
-          return n;
-        }
-        throw new AuthException("not allowed to see HEAD");
+        permissionBackend
+            .user(rsrc.getUser())
+            .project(rsrc.getNameKey())
+            .ref(n)
+            .check(RefPermission.READ);
+        return n;
       } else if (head.getObjectId() != null) {
         try (RevWalk rw = new RevWalk(repo)) {
           RevCommit commit = rw.parseCommit(head.getObjectId());
-          if (rsrc.getControl().canReadCommit(db.get(), repo, commit)) {
+          if (commits.canRead(rsrc.getProjectState(), repo, commit)) {
             return head.getObjectId().name();
           }
           throw new AuthException("not allowed to see HEAD");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetParent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetParent.java
index 8161cfd..8f0b6f0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetParent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetParent.java
@@ -31,7 +31,7 @@
 
   @Override
   public String apply(ProjectResource resource) {
-    Project project = resource.getControl().getProject();
+    Project project = resource.getProjectState().getProject();
     Project.NameKey parentName = project.getParent(allProjectsName);
     return parentName != null ? parentName.get() : "";
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetProject.java
index e782cb1..8288610 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetProject.java
@@ -31,6 +31,6 @@
 
   @Override
   public ProjectInfo apply(ProjectResource rsrc) {
-    return json.format(rsrc);
+    return json.format(rsrc.getProjectState());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java
index ceace1f..8c8314b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Index.java
@@ -17,7 +17,6 @@
 import static com.google.gerrit.server.git.QueueProvider.QueueType.BATCH;
 
 import com.google.common.io.ByteStreams;
-import com.google.common.io.CharStreams;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
@@ -31,24 +30,25 @@
 import com.google.gerrit.server.index.change.AllChangesIndexer;
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.io.PrintWriter;
 import java.util.concurrent.Future;
+import org.eclipse.jgit.util.io.NullOutputStream;
 
 @RequiresCapability(GlobalCapability.ADMINISTRATE_SERVER)
 @Singleton
 public class Index implements RestModifyView<ProjectResource, ProjectInput> {
 
-  private final AllChangesIndexer allChangesIndexer;
+  private final Provider<AllChangesIndexer> allChangesIndexerProvider;
   private final ChangeIndexer indexer;
   private final ListeningExecutorService executor;
 
   @Inject
   Index(
-      AllChangesIndexer allChangesIndexer,
+      Provider<AllChangesIndexer> allChangesIndexerProvider,
       ChangeIndexer indexer,
       @IndexExecutor(BATCH) ListeningExecutorService executor) {
-    this.allChangesIndexer = allChangesIndexer;
+    this.allChangesIndexerProvider = allChangesIndexerProvider;
     this.indexer = indexer;
     this.executor = executor;
   }
@@ -59,12 +59,13 @@
     Task mpt =
         new MultiProgressMonitor(ByteStreams.nullOutputStream(), "Reindexing project")
             .beginSubTask("", MultiProgressMonitor.UNKNOWN);
-    PrintWriter pw = new PrintWriter(CharStreams.nullWriter());
+    AllChangesIndexer allChangesIndexer = allChangesIndexerProvider.get();
+    allChangesIndexer.setVerboseOut(NullOutputStream.INSTANCE);
     // The REST call is just a trigger for async reindexing, so it is safe to ignore the future's
     // return value.
     @SuppressWarnings("unused")
     Future<Void> ignored =
-        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt, pw));
+        executor.submit(allChangesIndexer.reindexProject(indexer, project, mpt, mpt));
     return Response.accepted("Project " + project + " submitted for reindexing");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
index 9b46d94..9227f3dd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListBranches.java
@@ -15,8 +15,10 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ComparisonChain;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Sets;
 import com.google.gerrit.extensions.api.projects.BranchInfo;
+import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.common.ActionInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -26,11 +28,15 @@
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.extensions.webui.UiAction;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
-import com.google.inject.util.Providers;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collection;
@@ -47,7 +53,10 @@
 
 public class ListBranches implements RestReadView<ProjectResource> {
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final DynamicMap<RestView<BranchResource>> branchViews;
+  private final UiActions uiActions;
   private final WebLinks webLinks;
 
   @Option(
@@ -61,7 +70,7 @@
 
   @Option(
       name = "--start",
-      aliases = {"-S"},
+      aliases = {"-S", "-s"},
       metaVar = "CNT",
       usage = "number of branches to skip")
   public void setStart(int start) {
@@ -94,16 +103,31 @@
   @Inject
   public ListBranches(
       GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
       DynamicMap<RestView<BranchResource>> branchViews,
+      UiActions uiActions,
       WebLinks webLinks) {
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.branchViews = branchViews;
+    this.uiActions = uiActions;
     this.webLinks = webLinks;
   }
 
+  public ListBranches request(ListRefsRequest<BranchInfo> request) {
+    this.setLimit(request.getLimit());
+    this.setStart(request.getStart());
+    this.setMatchSubstring(request.getSubstring());
+    this.setMatchRegex(request.getRegex());
+    return this;
+  }
+
   @Override
   public List<BranchInfo> apply(ProjectResource rsrc)
-      throws ResourceNotFoundException, IOException, BadRequestException {
+      throws ResourceNotFoundException, IOException, BadRequestException,
+          PermissionBackendException {
     return new RefFilter<BranchInfo>(Constants.R_HEADS)
         .subString(matchSubstring)
         .regex(matchRegex)
@@ -112,8 +136,21 @@
         .filter(allBranches(rsrc));
   }
 
+  BranchInfo toBranchInfo(BranchResource rsrc)
+      throws IOException, ResourceNotFoundException, PermissionBackendException {
+    try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
+      Ref r = db.exactRef(rsrc.getRef());
+      if (r == null) {
+        throw new ResourceNotFoundException();
+      }
+      return toBranchInfo(rsrc, ImmutableList.of(r)).get(0);
+    } catch (RepositoryNotFoundException noRepo) {
+      throw new ResourceNotFoundException();
+    }
+  }
+
   private List<BranchInfo> allBranches(ProjectResource rsrc)
-      throws IOException, ResourceNotFoundException {
+      throws IOException, ResourceNotFoundException, PermissionBackendException {
     List<Ref> refs;
     try (Repository db = repoManager.openRepository(rsrc.getNameKey())) {
       Collection<Ref> heads = db.getRefDatabase().getRefs(Constants.R_HEADS).values();
@@ -126,7 +163,11 @@
     } catch (RepositoryNotFoundException noGitRepository) {
       throw new ResourceNotFoundException();
     }
+    return toBranchInfo(rsrc, refs);
+  }
 
+  private List<BranchInfo> toBranchInfo(ProjectResource rsrc, List<Ref> refs)
+      throws PermissionBackendException {
     Set<String> targets = Sets.newHashSetWithExpectedSize(1);
     for (Ref ref : refs) {
       if (ref.isSymbolic()) {
@@ -134,6 +175,8 @@
       }
     }
 
+    ProjectControl pctl = rsrc.getControl();
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(rsrc.getNameKey());
     List<BranchInfo> branches = new ArrayList<>(refs.size());
     for (Ref ref : refs) {
       if (ref.isSymbolic()) {
@@ -141,8 +184,7 @@
         // showing the resolved value, show the name it references.
         //
         String target = ref.getTarget().getName();
-        RefControl targetRefControl = rsrc.getControl().controlForRef(target);
-        if (!targetRefControl.isVisible()) {
+        if (!perm.ref(target).test(RefPermission.READ)) {
           continue;
         }
         if (target.startsWith(Constants.R_HEADS)) {
@@ -155,14 +197,13 @@
         branches.add(b);
 
         if (!Constants.HEAD.equals(ref.getName())) {
-          b.canDelete = targetRefControl.canDelete() ? true : null;
+          b.canDelete = perm.ref(ref.getName()).testOrFalse(RefPermission.DELETE) ? true : null;
         }
         continue;
       }
 
-      RefControl refControl = rsrc.getControl().controlForRef(ref.getName());
-      if (refControl.isVisible()) {
-        branches.add(createBranchInfo(ref, refControl, targets));
+      if (perm.ref(ref.getName()).test(RefPermission.READ)) {
+        branches.add(createBranchInfo(perm.ref(ref.getName()), ref, pctl, targets));
       }
     }
     Collections.sort(branches, new BranchComparator());
@@ -188,24 +229,23 @@
     }
   }
 
-  private BranchInfo createBranchInfo(Ref ref, RefControl refControl, Set<String> targets) {
+  private BranchInfo createBranchInfo(
+      PermissionBackend.ForRef perm, Ref ref, ProjectControl pctl, Set<String> targets) {
     BranchInfo info = new BranchInfo();
     info.ref = ref.getName();
     info.revision = ref.getObjectId() != null ? ref.getObjectId().name() : null;
-    info.canDelete = !targets.contains(ref.getName()) && refControl.canDelete() ? true : null;
-    for (UiAction.Description d :
-        UiActions.from(
-            branchViews,
-            new BranchResource(refControl.getProjectControl(), info),
-            Providers.of(refControl.getUser()))) {
+    info.canDelete =
+        !targets.contains(ref.getName()) && perm.testOrFalse(RefPermission.DELETE) ? true : null;
+
+    BranchResource rsrc = new BranchResource(pctl, ref);
+    for (UiAction.Description d : uiActions.from(branchViews, rsrc)) {
       if (info.actions == null) {
         info.actions = new TreeMap<>();
       }
       info.actions.put(d.getId(), new ActionInfo(d));
     }
-    List<WebLinkInfo> links =
-        webLinks.getBranchLinks(
-            refControl.getProjectControl().getProject().getName(), ref.getName());
+
+    List<WebLinkInfo> links = webLinks.getBranchLinks(pctl.getProject().getName(), ref.getName());
     info.webLinks = links.isEmpty() ? null : links;
     return info;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
index c14ade6..e5fe37d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListChildProjects.java
@@ -14,14 +14,21 @@
 
 package com.google.gerrit.server.project;
 
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.Multimap;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.util.ArrayList;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -33,20 +40,23 @@
   private boolean recursive;
 
   private final ProjectCache projectCache;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
   private final AllProjectsName allProjects;
   private final ProjectJson json;
-  private final ProjectNode.Factory projectNodeFactory;
 
   @Inject
   ListChildProjects(
       ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
       AllProjectsName allProjectsName,
-      ProjectJson json,
-      ProjectNode.Factory projectNodeFactory) {
+      ProjectJson json) {
     this.projectCache = projectCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
     this.allProjects = allProjectsName;
     this.json = json;
-    this.projectNodeFactory = projectNodeFactory;
   }
 
   public void setRecursive(boolean recursive) {
@@ -54,60 +64,82 @@
   }
 
   @Override
-  public List<ProjectInfo> apply(ProjectResource rsrc) {
+  public List<ProjectInfo> apply(ProjectResource rsrc) throws PermissionBackendException {
     if (recursive) {
-      return getChildProjectsRecursively(rsrc.getNameKey(), rsrc.getControl().getUser());
+      return recursiveChildProjects(rsrc.getNameKey());
     }
-    return getDirectChildProjects(rsrc.getNameKey());
+    return directChildProjects(rsrc.getNameKey());
   }
 
-  private List<ProjectInfo> getDirectChildProjects(Project.NameKey parent) {
-    List<ProjectInfo> childProjects = new ArrayList<>();
-    for (Project.NameKey projectName : projectCache.all()) {
-      ProjectState e = projectCache.get(projectName);
-      if (e == null) {
-        // If we can't get it from the cache, pretend it's not present.
-        continue;
-      }
-      if (parent.equals(e.getProject().getParent(allProjects))) {
-        childProjects.add(json.format(e.getProject()));
-      }
-    }
-    return childProjects;
-  }
-
-  private List<ProjectInfo> getChildProjectsRecursively(Project.NameKey parent, CurrentUser user) {
-    Map<Project.NameKey, ProjectNode> projects = new HashMap<>();
+  private List<ProjectInfo> directChildProjects(Project.NameKey parent)
+      throws PermissionBackendException {
+    Map<Project.NameKey, Project> children = new HashMap<>();
     for (Project.NameKey name : projectCache.all()) {
-      ProjectState p = projectCache.get(name);
-      if (p == null) {
-        // If we can't get it from the cache, pretend it's not present.
-        continue;
-      }
-      projects.put(name, projectNodeFactory.create(p.getProject(), p.controlFor(user).isVisible()));
-    }
-    for (ProjectNode key : projects.values()) {
-      ProjectNode node = projects.get(key.getParentName());
-      if (node != null) {
-        node.addChild(key);
+      ProjectState c = projectCache.get(name);
+      if (c != null && parent.equals(c.getProject().getParent(allProjects))) {
+        children.put(c.getNameKey(), c.getProject());
       }
     }
-
-    ProjectNode n = projects.get(parent);
-    if (n != null) {
-      return getChildProjectsRecursively(n);
-    }
-    return Collections.emptyList();
+    return permissionBackend
+        .user(user)
+        .filter(ProjectPermission.ACCESS, children.keySet())
+        .stream()
+        .sorted()
+        .map((p) -> json.format(children.get(p)))
+        .collect(toList());
   }
 
-  private List<ProjectInfo> getChildProjectsRecursively(ProjectNode p) {
-    List<ProjectInfo> allChildren = new ArrayList<>();
-    for (ProjectNode c : p.getChildren()) {
-      if (c.isVisible()) {
-        allChildren.add(json.format(c.getProject()));
-        allChildren.addAll(getChildProjectsRecursively(c));
+  private List<ProjectInfo> recursiveChildProjects(Project.NameKey parent)
+      throws PermissionBackendException {
+    Map<Project.NameKey, Project> projects = readAllProjects();
+    Multimap<Project.NameKey, Project.NameKey> children = parentToChildren(projects);
+    PermissionBackend.WithUser perm = permissionBackend.user(user);
+
+    List<ProjectInfo> results = new ArrayList<>();
+    depthFirstFormat(results, perm, projects, children, parent);
+    return results;
+  }
+
+  private Map<Project.NameKey, Project> readAllProjects() {
+    Map<Project.NameKey, Project> projects = new HashMap<>();
+    for (Project.NameKey name : projectCache.all()) {
+      ProjectState c = projectCache.get(name);
+      if (c != null) {
+        projects.put(c.getNameKey(), c.getProject());
       }
     }
-    return allChildren;
+    return projects;
+  }
+
+  /** Map of parent project to direct child. */
+  private Multimap<Project.NameKey, Project.NameKey> parentToChildren(
+      Map<Project.NameKey, Project> projects) {
+    Multimap<Project.NameKey, Project.NameKey> m = ArrayListMultimap.create();
+    for (Map.Entry<Project.NameKey, Project> e : projects.entrySet()) {
+      if (!allProjects.equals(e.getKey())) {
+        m.put(e.getValue().getParent(allProjects), e.getKey());
+      }
+    }
+    return m;
+  }
+
+  private void depthFirstFormat(
+      List<ProjectInfo> results,
+      PermissionBackend.WithUser perm,
+      Map<Project.NameKey, Project> projects,
+      Multimap<Project.NameKey, Project.NameKey> children,
+      Project.NameKey parent)
+      throws PermissionBackendException {
+    List<Project.NameKey> canSee =
+        perm.filter(ProjectPermission.ACCESS, children.get(parent))
+            .stream()
+            .sorted()
+            .collect(toList());
+    children.removeAll(parent); // removing all entries prevents cycles.
+
+    for (Project.NameKey c : canSee) {
+      results.add(json.format(projects.get(c)));
+      depthFirstFormat(results, perm, projects, children, c);
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
index 6dca4be..94f7af9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListDashboards.java
@@ -16,15 +16,24 @@
 
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_DASHBOARDS;
 
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
+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.inject.Inject;
+import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.BlobBasedConfig;
@@ -37,55 +46,69 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-class ListDashboards implements RestReadView<ProjectResource> {
+public class ListDashboards implements RestReadView<ProjectResource> {
   private static final Logger log = LoggerFactory.getLogger(ListDashboards.class);
 
   private final GitRepositoryManager gitManager;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
 
   @Option(name = "--inherited", usage = "include inherited dashboards")
   private boolean inherited;
 
   @Inject
-  ListDashboards(GitRepositoryManager gitManager) {
+  ListDashboards(
+      GitRepositoryManager gitManager,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user) {
     this.gitManager = gitManager;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
   }
 
   @Override
-  public List<?> apply(ProjectResource resource) throws ResourceNotFoundException, IOException {
-    ProjectControl ctl = resource.getControl();
-    String project = ctl.getProject().getName();
+  public List<?> apply(ProjectResource rsrc)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    String project = rsrc.getName();
     if (!inherited) {
-      return scan(resource.getControl(), project, true);
+      return scan(rsrc.getProjectState(), project, true);
     }
 
     List<List<DashboardInfo>> all = new ArrayList<>();
     boolean setDefault = true;
-    for (ProjectState ps : ctl.getProjectState().tree()) {
-      ctl = ps.controlFor(ctl.getUser());
-      if (ctl.isVisible()) {
-        List<DashboardInfo> list = scan(ctl, project, setDefault);
-        for (DashboardInfo d : list) {
-          if (d.isDefault != null && Boolean.TRUE.equals(d.isDefault)) {
-            setDefault = false;
-          }
+    for (ProjectState ps : tree(rsrc)) {
+      List<DashboardInfo> list = scan(ps, project, setDefault);
+      for (DashboardInfo d : list) {
+        if (d.isDefault != null && Boolean.TRUE.equals(d.isDefault)) {
+          setDefault = false;
         }
-        if (!list.isEmpty()) {
-          all.add(list);
-        }
+      }
+      if (!list.isEmpty()) {
+        all.add(list);
       }
     }
     return all;
   }
 
-  private List<DashboardInfo> scan(ProjectControl ctl, String project, boolean setDefault)
-      throws ResourceNotFoundException, IOException {
-    Project.NameKey projectName = ctl.getProject().getNameKey();
-    try (Repository git = gitManager.openRepository(projectName);
+  private Collection<ProjectState> tree(ProjectResource rsrc) throws PermissionBackendException {
+    Map<Project.NameKey, ProjectState> tree = new LinkedHashMap<>();
+    for (ProjectState ps : rsrc.getProjectState().tree()) {
+      tree.put(ps.getNameKey(), ps);
+    }
+    tree.keySet()
+        .retainAll(permissionBackend.user(user).filter(ProjectPermission.ACCESS, tree.keySet()));
+    return tree.values();
+  }
+
+  private List<DashboardInfo> scan(ProjectState state, String project, boolean setDefault)
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(state.getNameKey());
+    try (Repository git = gitManager.openRepository(state.getNameKey());
         RevWalk rw = new RevWalk(git)) {
       List<DashboardInfo> all = new ArrayList<>();
       for (Ref ref : git.getRefDatabase().getRefs(REFS_DASHBOARDS).values()) {
-        if (ctl.controlForRef(ref.getName()).isVisible()) {
-          all.addAll(scanDashboards(ctl.getProject(), git, rw, ref, project, setDefault));
+        if (perm.ref(ref.getName()).test(RefPermission.READ)) {
+          all.addAll(scanDashboards(state.getProject(), git, rw, ref, project, setDefault));
         }
       }
       return all;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
index eeabac8..887dfe3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListProjects.java
@@ -14,15 +14,20 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.gerrit.extensions.client.ProjectState.HIDDEN;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Lists;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
+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.RestReadView;
@@ -38,6 +43,10 @@
 import com.google.gerrit.server.account.GroupControl;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.GroupsCollection;
+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.util.RegexListSearcher;
 import com.google.gerrit.server.util.TreeFormatter;
 import com.google.gson.reflect.TypeToken;
@@ -50,6 +59,8 @@
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.LinkedHashMap;
@@ -79,12 +90,22 @@
       boolean matches(Repository git) throws IOException {
         return !PERMISSIONS.matches(git);
       }
+
+      @Override
+      boolean useMatch() {
+        return true;
+      }
     },
     PARENT_CANDIDATES {
       @Override
       boolean matches(Repository git) {
         return true;
       }
+
+      @Override
+      boolean useMatch() {
+        return false;
+      }
     },
     PERMISSIONS {
       @Override
@@ -94,15 +115,27 @@
             && head.isSymbolic()
             && RefNames.REFS_CONFIG.equals(head.getLeaf().getName());
       }
+
+      @Override
+      boolean useMatch() {
+        return true;
+      }
     },
     ALL {
       @Override
       boolean matches(Repository git) {
         return true;
       }
+
+      @Override
+      boolean useMatch() {
+        return false;
+      }
     };
 
     abstract boolean matches(Repository git) throws IOException;
+
+    abstract boolean useMatch();
   }
 
   private final CurrentUser currentUser;
@@ -110,6 +143,7 @@
   private final GroupsCollection groupsCollection;
   private final GroupControl.Factory groupControlFactory;
   private final GitRepositoryManager repoManager;
+  private final PermissionBackend permissionBackend;
   private final ProjectNode.Factory projectNodeFactory;
   private final WebLinks webLinks;
 
@@ -221,6 +255,7 @@
       GroupsCollection groupsCollection,
       GroupControl.Factory groupControlFactory,
       GitRepositoryManager repoManager,
+      PermissionBackend permissionBackend,
       ProjectNode.Factory projectNodeFactory,
       WebLinks webLinks) {
     this.currentUser = currentUser;
@@ -228,6 +263,7 @@
     this.groupsCollection = groupsCollection;
     this.groupControlFactory = groupControlFactory;
     this.repoManager = repoManager;
+    this.permissionBackend = permissionBackend;
     this.projectNodeFactory = projectNodeFactory;
     this.webLinks = webLinks;
   }
@@ -254,7 +290,8 @@
   }
 
   @Override
-  public Object apply(TopLevelResource resource) throws BadRequestException {
+  public Object apply(TopLevelResource resource)
+      throws BadRequestException, PermissionBackendException {
     if (format == OutputFormat.TEXT) {
       ByteArrayOutputStream buf = new ByteArrayOutputStream();
       display(buf);
@@ -265,141 +302,124 @@
     return apply();
   }
 
-  public SortedMap<String, ProjectInfo> apply() throws BadRequestException {
+  public SortedMap<String, ProjectInfo> apply()
+      throws BadRequestException, PermissionBackendException {
     format = OutputFormat.JSON;
     return display(null);
   }
 
-  public SortedMap<String, ProjectInfo> display(OutputStream displayOutputStream)
-      throws BadRequestException {
+  public SortedMap<String, ProjectInfo> display(@Nullable OutputStream displayOutputStream)
+      throws BadRequestException, PermissionBackendException {
+    if (groupUuid != null) {
+      try {
+        if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
+          return Collections.emptySortedMap();
+        }
+      } catch (NoSuchGroupException ex) {
+        return Collections.emptySortedMap();
+      }
+    }
+
     PrintWriter stdout = null;
     if (displayOutputStream != null) {
       stdout =
           new PrintWriter(new BufferedWriter(new OutputStreamWriter(displayOutputStream, UTF_8)));
     }
 
+    if (type == FilterType.PARENT_CANDIDATES) {
+      // Historically, PARENT_CANDIDATES implied showDescription.
+      showDescription = true;
+    }
+
     int foundIndex = 0;
     int found = 0;
     TreeMap<String, ProjectInfo> output = new TreeMap<>();
     Map<String, String> hiddenNames = new HashMap<>();
-    Set<String> rejected = new HashSet<>();
-
+    Map<Project.NameKey, Boolean> accessibleParents = new HashMap<>();
+    PermissionBackend.WithUser perm = permissionBackend.user(currentUser);
     final TreeMap<Project.NameKey, ProjectNode> treeMap = new TreeMap<>();
     try {
-      for (final Project.NameKey projectName : scan()) {
+      for (Project.NameKey projectName : filter(perm)) {
         final ProjectState e = projectCache.get(projectName);
-        if (e == null) {
+        if (e == null || (!all && e.getProject().getState() == HIDDEN)) {
           // If we can't get it from the cache, pretend its not present.
-          //
+          // If all wasn't selected, and its HIDDEN, pretend its not present.
           continue;
         }
 
         final ProjectControl pctl = e.controlFor(currentUser);
-        if (groupUuid != null) {
-          try {
-            if (!groupControlFactory.controlFor(groupUuid).isVisible()) {
-              break;
-            }
-          } catch (NoSuchGroupException ex) {
-            break;
-          }
-          if (!pctl.getLocalGroups()
-              .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
-            continue;
-          }
+        if (groupUuid != null
+            && !pctl.getProjectState()
+                .getLocalGroups()
+                .contains(GroupReference.forGroup(groupsCollection.parseId(groupUuid.get())))) {
+          continue;
         }
 
         ProjectInfo info = new ProjectInfo();
-        if (type == FilterType.PARENT_CANDIDATES) {
-          ProjectState parentState = Iterables.getFirst(e.parents(), null);
-          if (parentState != null
-              && !output.keySet().contains(parentState.getProject().getName())
-              && !rejected.contains(parentState.getProject().getName())) {
-            ProjectControl parentCtrl = parentState.controlFor(currentUser);
-            if (parentCtrl.isVisible() || parentCtrl.isOwner()) {
-              info.name = parentState.getProject().getName();
-              info.description = Strings.emptyToNull(parentState.getProject().getDescription());
-              info.state = parentState.getProject().getState();
+        if (showTree && !format.isJson()) {
+          treeMap.put(projectName, projectNodeFactory.create(pctl.getProject(), true));
+          continue;
+        }
+
+        info.name = projectName.get();
+        if (showTree && format.isJson()) {
+          ProjectState parent = Iterables.getFirst(e.parents(), null);
+          if (parent != null) {
+            if (isParentAccessible(accessibleParents, perm, parent)) {
+              info.parent = parent.getName();
             } else {
-              rejected.add(parentState.getProject().getName());
-              continue;
-            }
-          } else {
-            continue;
-          }
-
-        } else {
-          final boolean isVisible = pctl.isVisible() || (all && pctl.isOwner());
-          if (showTree && !format.isJson()) {
-            treeMap.put(projectName, projectNodeFactory.create(pctl.getProject(), isVisible));
-            continue;
-          }
-
-          if (!isVisible && !(showTree && pctl.isOwner())) {
-            // Require the project itself to be visible to the user.
-            //
-            continue;
-          }
-
-          info.name = projectName.get();
-          if (showTree && format.isJson()) {
-            ProjectState parent = Iterables.getFirst(e.parents(), null);
-            if (parent != null) {
-              ProjectControl parentCtrl = parent.controlFor(currentUser);
-              if (parentCtrl.isVisible() || parentCtrl.isOwner()) {
-                info.parent = parent.getProject().getName();
-              } else {
-                info.parent = hiddenNames.get(parent.getProject().getName());
-                if (info.parent == null) {
-                  info.parent = "?-" + (hiddenNames.size() + 1);
-                  hiddenNames.put(parent.getProject().getName(), info.parent);
-                }
+              info.parent = hiddenNames.get(parent.getName());
+              if (info.parent == null) {
+                info.parent = "?-" + (hiddenNames.size() + 1);
+                hiddenNames.put(parent.getName(), info.parent);
               }
             }
           }
-          if (showDescription) {
-            info.description = Strings.emptyToNull(e.getProject().getDescription());
-          }
+        }
 
-          info.state = e.getProject().getState();
+        if (showDescription) {
+          info.description = Strings.emptyToNull(e.getProject().getDescription());
+        }
+        info.state = e.getProject().getState();
 
-          try {
-            if (!showBranch.isEmpty()) {
-              try (Repository git = repoManager.openRepository(projectName)) {
-                if (!type.matches(git)) {
-                  continue;
-                }
+        try {
+          if (!showBranch.isEmpty()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
 
-                List<Ref> refs = getBranchRefs(projectName, pctl);
-                if (!hasValidRef(refs)) {
-                  continue;
-                }
+              List<Ref> refs = getBranchRefs(projectName, pctl);
+              if (!hasValidRef(refs)) {
+                continue;
+              }
 
-                for (int i = 0; i < showBranch.size(); i++) {
-                  Ref ref = refs.get(i);
-                  if (ref != null && ref.getObjectId() != null) {
-                    if (info.branches == null) {
-                      info.branches = new LinkedHashMap<>();
-                    }
-                    info.branches.put(showBranch.get(i), ref.getObjectId().name());
+              for (int i = 0; i < showBranch.size(); i++) {
+                Ref ref = refs.get(i);
+                if (ref != null && ref.getObjectId() != null) {
+                  if (info.branches == null) {
+                    info.branches = new LinkedHashMap<>();
                   }
-                }
-              }
-            } else if (!showTree && type != FilterType.ALL) {
-              try (Repository git = repoManager.openRepository(projectName)) {
-                if (!type.matches(git)) {
-                  continue;
+                  info.branches.put(showBranch.get(i), ref.getObjectId().name());
                 }
               }
             }
-
-          } catch (RepositoryNotFoundException err) {
-            // If the Git repository is gone, the project doesn't actually exist anymore.
-            continue;
-          } catch (IOException err) {
-            log.warn("Unexpected error reading " + projectName, err);
-            continue;
+          } else if (!showTree && type.useMatch()) {
+            try (Repository git = repoManager.openRepository(projectName)) {
+              if (!type.matches(git)) {
+                continue;
+              }
+            }
           }
+        } catch (RepositoryNotFoundException err) {
+          // If the Git repository is gone, the project doesn't actually exist anymore.
+          continue;
+        } catch (IOException err) {
+          log.warn("Unexpected error reading " + projectName, err);
+          continue;
+        }
+
+        if (type != FilterType.PARENT_CANDIDATES) {
           List<WebLinkInfo> links = webLinks.getProjectLinks(projectName.get());
           info.webLinks = links.isEmpty() ? null : links;
         }
@@ -407,7 +427,6 @@
         if (foundIndex++ < start) {
           continue;
         }
-
         if (limit > 0 && ++found > limit) {
           break;
         }
@@ -459,6 +478,50 @@
     }
   }
 
+  private Collection<Project.NameKey> filter(PermissionBackend.WithUser perm)
+      throws BadRequestException, PermissionBackendException {
+    Collection<Project.NameKey> matches = Lists.newArrayList(scan());
+    if (type == FilterType.PARENT_CANDIDATES) {
+      matches = parentsOf(matches);
+    }
+    return perm.filter(ProjectPermission.ACCESS, matches).stream().sorted().collect(toList());
+  }
+
+  private Collection<Project.NameKey> parentsOf(Collection<Project.NameKey> matches) {
+    Set<Project.NameKey> parents = new HashSet<>();
+    for (Project.NameKey p : matches) {
+      ProjectState ps = projectCache.get(p);
+      if (ps != null) {
+        Project.NameKey parent = ps.getProject().getParent();
+        if (parent != null) {
+          if (projectCache.get(parent) != null) {
+            parents.add(parent);
+          } else {
+            log.warn("parent project {} of project {} not found", parent.get(), ps.getName());
+          }
+        }
+      }
+    }
+    return parents;
+  }
+
+  private boolean isParentAccessible(
+      Map<Project.NameKey, Boolean> checked, PermissionBackend.WithUser perm, ProjectState p)
+      throws PermissionBackendException {
+    Project.NameKey name = p.getNameKey();
+    Boolean b = checked.get(name);
+    if (b == null) {
+      try {
+        perm.project(name).check(ProjectPermission.ACCESS);
+        b = true;
+      } catch (AuthException denied) {
+        b = false;
+      }
+      checked.put(name, b);
+    }
+    return b;
+  }
+
   private Iterable<Project.NameKey> scan() throws BadRequestException {
     if (matchPrefix != null) {
       checkMatchOptions(matchSubstring == null && matchRegex == null);
@@ -495,12 +558,12 @@
   }
 
   private void printProjectTree(
-      final PrintWriter stdout, final TreeMap<Project.NameKey, ProjectNode> treeMap) {
+      final PrintWriter stdout, TreeMap<Project.NameKey, ProjectNode> treeMap) {
     final SortedSet<ProjectNode> sortedNodes = new TreeSet<>();
 
     // Builds the inheritance tree using a list.
     //
-    for (final ProjectNode key : treeMap.values()) {
+    for (ProjectNode key : treeMap.values()) {
       if (key.isAllProjects()) {
         sortedNodes.add(key);
         continue;
@@ -522,16 +585,21 @@
   private List<Ref> getBranchRefs(Project.NameKey projectName, ProjectControl projectControl) {
     Ref[] result = new Ref[showBranch.size()];
     try (Repository git = repoManager.openRepository(projectName)) {
+      PermissionBackend.ForProject perm = permissionBackend.user(currentUser).project(projectName);
       for (int i = 0; i < showBranch.size(); i++) {
         Ref ref = git.findRef(showBranch.get(i));
-        if ((ref != null
-                && ref.getObjectId() != null
-                && (projectControl.controlForRef(ref.getLeaf().getName()).isVisible()))
-            || (all && projectControl.isOwner())) {
+        if (all && projectControl.isOwner()) {
           result[i] = ref;
+        } else if (ref != null && ref.getObjectId() != null) {
+          try {
+            perm.ref(ref.getLeaf().getName()).check(RefPermission.READ);
+            result[i] = ref;
+          } catch (AuthException e) {
+            continue;
+          }
         }
       }
-    } catch (IOException ioe) {
+    } catch (IOException | PermissionBackendException e) {
       // Fall through and return what is available.
     }
     return Arrays.asList(result);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
index 6090e53..58ee77d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ListTags.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.project;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.api.projects.ProjectApi.ListRefsRequest;
 import com.google.gerrit.extensions.api.projects.TagInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -23,14 +23,13 @@
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CommonConverters;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.WebLinks;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -52,10 +51,9 @@
 
 public class ListTags implements RestReadView<ProjectResource> {
   private final GitRepositoryManager repoManager;
-  private final Provider<ReviewDb> dbProvider;
-  private final TagCache tagCache;
-  private final ChangeNotes.Factory changeNotesFactory;
-  @Nullable private final SearchingChangeCacheImpl changeCache;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final VisibleRefFilter.Factory refFilterFactory;
   private final WebLinks links;
 
   @Option(
@@ -69,7 +67,7 @@
 
   @Option(
       name = "--start",
-      aliases = {"-S"},
+      aliases = {"-S", "-s"},
       metaVar = "CNT",
       usage = "number of tags to skip")
   public void setStart(int start) {
@@ -102,31 +100,38 @@
   @Inject
   public ListTags(
       GitRepositoryManager repoManager,
-      Provider<ReviewDb> dbProvider,
-      TagCache tagCache,
-      ChangeNotes.Factory changeNotesFactory,
-      @Nullable SearchingChangeCacheImpl changeCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      VisibleRefFilter.Factory refFilterFactory,
       WebLinks webLinks) {
     this.repoManager = repoManager;
-    this.dbProvider = dbProvider;
-    this.tagCache = tagCache;
-    this.changeNotesFactory = changeNotesFactory;
-    this.changeCache = changeCache;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.refFilterFactory = refFilterFactory;
     this.links = webLinks;
   }
 
+  public ListTags request(ListRefsRequest<TagInfo> request) {
+    this.setLimit(request.getLimit());
+    this.setStart(request.getStart());
+    this.setMatchSubstring(request.getSubstring());
+    this.setMatchRegex(request.getRegex());
+    return this;
+  }
+
   @Override
   public List<TagInfo> apply(ProjectResource resource)
       throws IOException, ResourceNotFoundException, BadRequestException {
     List<TagInfo> tags = new ArrayList<>();
 
+    PermissionBackend.ForProject perm = permissionBackend.user(user).project(resource.getNameKey());
     try (Repository repo = getRepository(resource.getNameKey());
         RevWalk rw = new RevWalk(repo)) {
-      ProjectControl pctl = resource.getControl();
       Map<String, Ref> all =
-          visibleTags(pctl, repo, repo.getRefDatabase().getRefs(Constants.R_TAGS));
+          visibleTags(
+              resource.getProjectState(), repo, repo.getRefDatabase().getRefs(Constants.R_TAGS));
       for (Ref ref : all.values()) {
-        tags.add(createTagInfo(ref, rw, pctl.controlForRef(ref.getName()), pctl, links));
+        tags.add(createTagInfo(perm.ref(ref.getName()), ref, rw, resource.getNameKey(), links));
       }
     }
 
@@ -156,19 +161,33 @@
         tagName = Constants.R_TAGS + tagName;
       }
       Ref ref = repo.getRefDatabase().exactRef(tagName);
-      ProjectControl pctl = resource.getControl();
-      if (ref != null && !visibleTags(pctl, repo, ImmutableMap.of(ref.getName(), ref)).isEmpty()) {
-        return createTagInfo(ref, rw, pctl.controlForRef(ref.getName()), pctl, links);
+      if (ref != null
+          && !visibleTags(resource.getProjectState(), repo, ImmutableMap.of(ref.getName(), ref))
+              .isEmpty()) {
+        return createTagInfo(
+            permissionBackend
+                .user(resource.getUser())
+                .project(resource.getNameKey())
+                .ref(ref.getName()),
+            ref,
+            rw,
+            resource.getNameKey(),
+            links);
       }
     }
     throw new ResourceNotFoundException(id);
   }
 
   public static TagInfo createTagInfo(
-      Ref ref, RevWalk rw, RefControl control, ProjectControl pctl, WebLinks links)
+      PermissionBackend.ForRef perm,
+      Ref ref,
+      RevWalk rw,
+      Project.NameKey projectName,
+      WebLinks links)
       throws MissingObjectException, IOException {
     RevObject object = rw.parseAny(ref.getObjectId());
-    List<WebLinkInfo> webLinks = links.getTagLinks(pctl.getProject().getName(), ref.getName());
+    Boolean canDelete = perm.testOrFalse(RefPermission.DELETE) ? true : null;
+    List<WebLinkInfo> webLinks = links.getTagLinks(projectName.get(), ref.getName());
     if (object instanceof RevTag) {
       // Annotated or signed tag
       RevTag tag = (RevTag) object;
@@ -179,14 +198,14 @@
           tag.getObject().getName(),
           tag.getFullMessage().trim(),
           tagger != null ? CommonConverters.toGitPerson(tag.getTaggerIdent()) : null,
-          control.canDelete(),
+          canDelete,
           webLinks.isEmpty() ? null : webLinks);
     }
     // Lightweight tag
     return new TagInfo(
         ref.getName(),
         ref.getObjectId().getName(),
-        control.canDelete(),
+        canDelete,
         webLinks.isEmpty() ? null : webLinks);
   }
 
@@ -199,10 +218,7 @@
     }
   }
 
-  private Map<String, Ref> visibleTags(
-      ProjectControl pctl, Repository repo, Map<String, Ref> tags) {
-    return new VisibleRefFilter(
-            tagCache, changeNotesFactory, changeCache, repo, pctl, dbProvider.get(), false)
-        .filter(tags, true);
+  private Map<String, Ref> visibleTags(ProjectState state, Repository repo, Map<String, Ref> tags) {
+    return refFilterFactory.create(state, repo).setShowMetadata(false).filter(tags, true);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
index d7af195..d0753eb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/Module.java
@@ -24,6 +24,7 @@
 
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.RestApiModule;
+import com.google.gerrit.server.change.CherryPickCommit;
 
 public class Module extends RestApiModule {
   @Override
@@ -47,6 +48,8 @@
 
     get(PROJECT_KIND, "access").to(GetAccess.class);
     post(PROJECT_KIND, "access").to(SetAccess.class);
+    put(PROJECT_KIND, "access:review").to(CreateAccessChange.class);
+    post(PROJECT_KIND, "check.access").to(CheckAccess.class);
 
     get(PROJECT_KIND, "parent").to(GetParent.class);
     put(PROJECT_KIND, "parent").to(SetParent.class);
@@ -95,6 +98,7 @@
 
     get(PROJECT_KIND, "config").to(GetConfig.class);
     put(PROJECT_KIND, "config").to(PutConfig.class);
+    post(COMMIT_KIND, "cherrypick").to(CherryPickCommit.class);
 
     factory(DeleteRef.Factory.class);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchProjectException.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchProjectException.java
index 61b5c05..23d8d80 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchProjectException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchProjectException.java
@@ -23,11 +23,11 @@
   private static final String MESSAGE = "Project not found: ";
   private final Project.NameKey project;
 
-  public NoSuchProjectException(final Project.NameKey key) {
+  public NoSuchProjectException(Project.NameKey key) {
     this(key, null);
   }
 
-  public NoSuchProjectException(final Project.NameKey key, final Throwable why) {
+  public NoSuchProjectException(Project.NameKey key, Throwable why) {
     super(MESSAGE + key.toString(), why);
     project = key;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchRefException.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchRefException.java
index 2c90ce86..59debde 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchRefException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/NoSuchRefException.java
@@ -18,11 +18,11 @@
 public class NoSuchRefException extends Exception {
   private static final long serialVersionUID = 1L;
 
-  public NoSuchRefException(final String ref) {
+  public NoSuchRefException(String ref) {
     this(ref, null);
   }
 
-  public NoSuchRefException(final String ref, final Throwable why) {
+  public NoSuchRefException(String ref, Throwable why) {
     super(ref, why);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
index 4ed6480..cb3223d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCache.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import java.io.IOException;
@@ -31,19 +32,31 @@
    * Get the cached data for a project by its unique name.
    *
    * @param projectName name of the project.
-   * @return the cached data; null if no such project exists or a error occurred.
+   * @return the cached data; null if no such project exists, projectName is null or an error
+   *     occurred.
    * @see #checkedGet(com.google.gerrit.reviewdb.client.Project.NameKey)
    */
-  ProjectState get(Project.NameKey projectName);
+  ProjectState get(@Nullable Project.NameKey projectName);
 
   /**
    * Get the cached data for a project by its unique name.
    *
    * @param projectName name of the project.
    * @throws IOException when there was an error.
-   * @return the cached data; null if no such project exists.
+   * @return the cached data; null if no such project exists or projectName is null.
    */
-  ProjectState checkedGet(Project.NameKey projectName) throws IOException;
+  ProjectState checkedGet(@Nullable Project.NameKey projectName) throws IOException;
+
+  /**
+   * Get the cached data for a project by its unique name.
+   *
+   * @param projectName name of the project.
+   * @param strict true when any error generates an exception
+   * @throws Exception in case of any error (strict = true) or only for I/O or other internal
+   *     errors.
+   * @return the cached data or null when strict = false
+   */
+  public ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception;
 
   /** Invalidate the cached information about the given project. */
   void evict(Project p);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
index e124a74..3daf9c8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheImpl.java
@@ -125,7 +125,7 @@
   }
 
   @Override
-  public ProjectState get(final Project.NameKey projectName) {
+  public ProjectState get(Project.NameKey projectName) {
     try {
       return checkedGet(projectName);
     } catch (IOException e) {
@@ -139,24 +139,34 @@
       return null;
     }
     try {
-      ProjectState state = byName.get(projectName.get());
-      if (state != null && state.needsRefresh(clock.read())) {
-        byName.invalidate(projectName.get());
-        state = byName.get(projectName.get());
-      }
-      return state;
-    } catch (ExecutionException e) {
+      return strictCheckedGet(projectName);
+    } catch (Exception e) {
       if (!(e.getCause() instanceof RepositoryNotFoundException)) {
         log.warn("Cannot read project {}", projectName.get(), e);
         Throwables.throwIfInstanceOf(e.getCause(), IOException.class);
         throw new IOException(e);
       }
+      log.debug("Cannot find project {}", projectName.get(), e);
       return null;
     }
   }
 
   @Override
-  public void evict(final Project p) {
+  public ProjectState checkedGet(Project.NameKey projectName, boolean strict) throws Exception {
+    return strict ? strictCheckedGet(projectName) : checkedGet(projectName);
+  }
+
+  private ProjectState strictCheckedGet(Project.NameKey projectName) throws Exception {
+    ProjectState state = byName.get(projectName.get());
+    if (state != null && state.needsRefresh(clock.read())) {
+      byName.invalidate(projectName.get());
+      state = byName.get(projectName.get());
+    }
+    return state;
+  }
+
+  @Override
+  public void evict(Project p) {
     if (p != null) {
       byName.invalidate(p.getNameKey().get());
     }
@@ -164,14 +174,14 @@
 
   /** Invalidate the cached information about the given project. */
   @Override
-  public void evict(final Project.NameKey p) {
+  public void evict(Project.NameKey p) {
     if (p != null) {
       byName.invalidate(p.get());
     }
   }
 
   @Override
-  public void remove(final Project p) {
+  public void remove(Project p) {
     remove(p.getNameKey());
   }
 
@@ -228,7 +238,7 @@
   }
 
   @Override
-  public Iterable<Project.NameKey> byName(final String pfx) {
+  public Iterable<Project.NameKey> byName(String pfx) {
     final Iterable<Project.NameKey> src;
     try {
       src = list.get(ListKey.ALL).tailSet(new Project.NameKey(pfx));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
index 5e0ba28..66bbcca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectCacheWarmer.java
@@ -46,7 +46,7 @@
   public void start() {
     int cpus = Runtime.getRuntime().availableProcessors();
     if (config.getBoolean("cache", "projects", "loadOnStartup", false)) {
-      final ThreadPoolExecutor pool =
+      ThreadPoolExecutor pool =
           new ScheduledThreadPoolExecutor(
               config.getInt("cache", "projects", "loadThreads", cpus),
               new ThreadFactoryBuilder().setNameFormat("ProjectCacheLoader-%d").build());
@@ -54,25 +54,19 @@
 
       log.info("Loading project cache");
       scheduler.execute(
-          new Runnable() {
-            @Override
-            public void run() {
-              for (final Project.NameKey name : cache.all()) {
-                pool.execute(
-                    new Runnable() {
-                      @Override
-                      public void run() {
-                        cache.get(name);
-                      }
-                    });
-              }
-              pool.shutdown();
-              try {
-                pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
-                log.info("Finished loading project cache");
-              } catch (InterruptedException e) {
-                log.warn("Interrupted while waiting for project cache to load");
-              }
+          () -> {
+            for (Project.NameKey name : cache.all()) {
+              pool.execute(
+                  () -> {
+                    cache.get(name);
+                  });
+            }
+            pool.shutdown();
+            try {
+              pool.awaitTermination(Long.MAX_VALUE, TimeUnit.MILLISECONDS);
+              log.info("Finished loading project cache");
+            } catch (InterruptedException e) {
+              log.warn("Interrupted while waiting for project cache to load");
             }
           });
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
index fefc84d..e97411e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectControl.java
@@ -14,19 +14,17 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.reviewdb.client.RefNames.REFS_TAGS;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Iterables;
 import com.google.common.collect.Maps;
-import com.google.gerrit.common.Nullable;
-import com.google.gerrit.common.PageLinks;
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.Capable;
-import com.google.gerrit.common.data.ContributorAgreement;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.common.data.PermissionRule.Action;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.metrics.Counter0;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.MetricMaker;
@@ -34,54 +32,54 @@
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupMembership;
-import com.google.gerrit.server.change.IncludedInResolver;
-import com.google.gerrit.server.config.CanonicalWebUrl;
 import com.google.gerrit.server.config.GitReceivePackGroups;
 import com.google.gerrit.server.config.GitUploadPackGroups;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.TagCache;
-import com.google.gerrit.server.git.VisibleRefFilter;
 import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackend.ForProject;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.gerrit.server.query.change.ChangeData;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefDatabase;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 /** Access control management for a user accessing a project's data. */
 public class ProjectControl {
-  public static final int VISIBLE = 1 << 0;
-  public static final int OWNER = 1 << 1;
-
   private static final Logger log = LoggerFactory.getLogger(ProjectControl.class);
 
   public static class GenericFactory {
     private final ProjectCache projectCache;
 
     @Inject
-    GenericFactory(final ProjectCache pc) {
+    GenericFactory(ProjectCache pc) {
       projectCache = pc;
     }
 
@@ -93,18 +91,6 @@
       }
       return p.controlFor(user);
     }
-
-    public ProjectControl validateFor(Project.NameKey nameKey, int need, CurrentUser user)
-        throws NoSuchProjectException, IOException {
-      final ProjectControl c = controlFor(nameKey, user);
-      if ((need & VISIBLE) == VISIBLE && c.isVisible()) {
-        return c;
-      }
-      if ((need & OWNER) == OWNER && c.isOwner()) {
-        return c;
-      }
-      throw new NoSuchProjectException(nameKey);
-    }
   }
 
   public static class Factory {
@@ -115,29 +101,9 @@
       userCache = uc;
     }
 
-    public ProjectControl controlFor(final Project.NameKey nameKey) throws NoSuchProjectException {
+    public ProjectControl controlFor(Project.NameKey nameKey) throws NoSuchProjectException {
       return userCache.get().get(nameKey);
     }
-
-    public ProjectControl validateFor(final Project.NameKey nameKey) throws NoSuchProjectException {
-      return validateFor(nameKey, VISIBLE);
-    }
-
-    public ProjectControl ownerFor(final Project.NameKey nameKey) throws NoSuchProjectException {
-      return validateFor(nameKey, OWNER);
-    }
-
-    public ProjectControl validateFor(final Project.NameKey nameKey, final int need)
-        throws NoSuchProjectException {
-      final ProjectControl c = controlFor(nameKey);
-      if ((need & VISIBLE) == VISIBLE && c.isVisible()) {
-        return c;
-      }
-      if ((need & OWNER) == OWNER && c.isOwner()) {
-        return c;
-      }
-      throw new NoSuchProjectException(nameKey);
-    }
   }
 
   public interface AssistedFactory {
@@ -159,22 +125,14 @@
 
   private final Set<AccountGroup.UUID> uploadGroups;
   private final Set<AccountGroup.UUID> receiveGroups;
-
-  private final String canonicalWebUrl;
+  private final PermissionBackend.WithUser perm;
   private final CurrentUser user;
   private final ProjectState state;
-  private final ChangeNotes.Factory changeNotesFactory;
+  private final CommitsCollection commits;
   private final ChangeControl.Factory changeControlFactory;
   private final PermissionCollection.Factory permissionFilter;
-  private final Collection<ContributorAgreement> contributorAgreements;
-  private final TagCache tagCache;
-  @Nullable private final SearchingChangeCacheImpl changeCache;
-  private final Provider<InternalChangeQuery> queryProvider;
-  private final Metrics metrics;
 
   private List<SectionMatcher> allSections;
-  private List<SectionMatcher> localSections;
-  private LabelTypes labelTypes;
   private Map<String, RefControl> refControls;
   private Boolean declaredOwner;
 
@@ -182,28 +140,18 @@
   ProjectControl(
       @GitUploadPackGroups Set<AccountGroup.UUID> uploadGroups,
       @GitReceivePackGroups Set<AccountGroup.UUID> receiveGroups,
-      ProjectCache pc,
       PermissionCollection.Factory permissionFilter,
-      ChangeNotes.Factory changeNotesFactory,
+      CommitsCollection commits,
       ChangeControl.Factory changeControlFactory,
-      TagCache tagCache,
-      Provider<InternalChangeQuery> queryProvider,
-      @Nullable SearchingChangeCacheImpl changeCache,
-      @CanonicalWebUrl @Nullable String canonicalWebUrl,
+      PermissionBackend permissionBackend,
       @Assisted CurrentUser who,
-      @Assisted ProjectState ps,
-      Metrics metrics) {
-    this.changeNotesFactory = changeNotesFactory;
+      @Assisted ProjectState ps) {
     this.changeControlFactory = changeControlFactory;
-    this.tagCache = tagCache;
-    this.changeCache = changeCache;
     this.uploadGroups = uploadGroups;
     this.receiveGroups = receiveGroups;
     this.permissionFilter = permissionFilter;
-    this.contributorAgreements = pc.getAllProjects().getConfig().getContributorAgreements();
-    this.canonicalWebUrl = canonicalWebUrl;
-    this.queryProvider = queryProvider;
-    this.metrics = metrics;
+    this.commits = commits;
+    this.perm = permissionBackend.user(who);
     user = who;
     state = ps;
   }
@@ -220,17 +168,6 @@
         controlForRef(change.getDest()), db, change.getProject(), change.getId());
   }
 
-  /**
-   * Create a change control for a change that was loaded from index. This method should only be
-   * used when database access is harmful and potentially stale data from the index is acceptable.
-   *
-   * @param change change loaded from secondary index
-   * @return change control
-   */
-  public ChangeControl controlForIndexedChange(Change change) {
-    return changeControlFactory.createForIndexedChange(controlForRef(change.getDest()), change);
-  }
-
   public ChangeControl controlFor(ChangeNotes notes) {
     return changeControlFactory.create(controlForRef(notes.getChange().getDest()), notes);
   }
@@ -264,42 +201,62 @@
     return state.getProject();
   }
 
-  public LabelTypes getLabelTypes() {
-    if (labelTypes == null) {
-      labelTypes = state.getLabelTypes();
+  /** Is this user a project owner? */
+  public boolean isOwner() {
+    return (isDeclaredOwner() && !controlForRef("refs/*").isBlocked(Permission.OWNER)) || isAdmin();
+  }
+
+  /**
+   * @return {@code Capable.OK} if the user can upload to at least one reference. Does not check
+   *     Contributor Agreements.
+   */
+  public Capable canPushToAtLeastOneRef() {
+    if (!canPerformOnAnyRef(Permission.PUSH)
+        && !canPerformOnAnyRef(Permission.CREATE_TAG)
+        && !isOwner()) {
+      return new Capable("Upload denied for project '" + state.getName() + "'");
     }
-    return labelTypes;
+    return Capable.OK;
+  }
+
+  /** Can the user run upload pack? */
+  private boolean canRunUploadPack() {
+    for (AccountGroup.UUID group : uploadGroups) {
+      if (match(group)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Can the user run receive pack? */
+  private boolean canRunReceivePack() {
+    for (AccountGroup.UUID group : receiveGroups) {
+      if (match(group)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  private boolean allRefsAreVisible(Set<String> ignore) {
+    return user.isInternalUser() || canPerformOnAllRefs(Permission.READ, ignore);
   }
 
   /** Returns whether the project is hidden. */
-  public boolean isHidden() {
+  private boolean isHidden() {
     return getProject().getState().equals(com.google.gerrit.extensions.client.ProjectState.HIDDEN);
   }
 
-  /**
-   * Returns whether the project is readable to the current user. Note that the project could still
-   * be hidden.
-   */
-  public boolean isReadable() {
-    return (user.isInternalUser() || canPerformOnAnyRef(Permission.READ));
+  private boolean canAddRefs() {
+    return (canPerformOnAnyRef(Permission.CREATE) || isAdmin());
   }
 
-  /**
-   * Returns whether the project is accessible to the current user, i.e. readable and not hidden.
-   */
-  public boolean isVisible() {
-    return isReadable() && !isHidden();
+  private boolean canAddTagRefs() {
+    return (canPerformOnTagRef(Permission.CREATE) || isAdmin());
   }
 
-  public boolean canAddRefs() {
-    return (canPerformOnAnyRef(Permission.CREATE) || isOwnerAnyRef());
-  }
-
-  public boolean canAddTagRefs() {
-    return (canPerformOnTagRef(Permission.CREATE) || isOwnerAnyRef());
-  }
-
-  public boolean canUpload() {
+  private boolean canCreateChanges() {
     for (SectionMatcher matcher : access()) {
       AccessSection section = matcher.section;
       if (section.getName().startsWith("refs/for/")) {
@@ -312,19 +269,13 @@
     return false;
   }
 
-  /** Can this user see all the refs in this projects? */
-  public boolean allRefsAreVisible() {
-    return allRefsAreVisible(Collections.<String>emptySet());
-  }
-
-  public boolean allRefsAreVisible(Set<String> ignore) {
-    return user.isInternalUser() || canPerformOnAllRefs(Permission.READ, ignore);
-  }
-
-  /** Is this user a project owner? Ownership does not imply {@link #isVisible()} */
-  public boolean isOwner() {
-    return (isDeclaredOwner() && !controlForRef("refs/*").isBlocked(Permission.OWNER))
-        || user.getCapabilities().canAdministrateServer();
+  boolean isAdmin() {
+    try {
+      perm.check(GlobalPermission.ADMINISTRATE_SERVER);
+      return true;
+    } catch (AuthException | PermissionBackendException e) {
+      return false;
+    }
   }
 
   private boolean isDeclaredOwner() {
@@ -335,86 +286,6 @@
     return declaredOwner;
   }
 
-  /** Does this user have ownership on at least one reference name? */
-  public boolean isOwnerAnyRef() {
-    return canPerformOnAnyRef(Permission.OWNER) || user.getCapabilities().canAdministrateServer();
-  }
-
-  /** @return true if the user can upload to at least one reference */
-  public Capable canPushToAtLeastOneRef() {
-    if (!canPerformOnAnyRef(Permission.PUSH)
-        && !canPerformOnAnyRef(Permission.CREATE_TAG)
-        && !isOwner()) {
-      String pName = state.getProject().getName();
-      return new Capable("Upload denied for project '" + pName + "'");
-    }
-    if (state.isUseContributorAgreements()) {
-      return verifyActiveContributorAgreement();
-    }
-    return Capable.OK;
-  }
-
-  public Set<GroupReference> getAllGroups() {
-    return getGroups(access());
-  }
-
-  public Set<GroupReference> getLocalGroups() {
-    return getGroups(localAccess());
-  }
-
-  private static Set<GroupReference> getGroups(final List<SectionMatcher> sectionMatcherList) {
-    final Set<GroupReference> all = new HashSet<>();
-    for (final SectionMatcher matcher : sectionMatcherList) {
-      final AccessSection section = matcher.section;
-      for (final Permission permission : section.getPermissions()) {
-        for (final PermissionRule rule : permission.getRules()) {
-          all.add(rule.getGroup());
-        }
-      }
-    }
-    return all;
-  }
-
-  private Capable verifyActiveContributorAgreement() {
-    metrics.claCheckCount.increment();
-    if (!(user.isIdentifiedUser())) {
-      return new Capable("Must be logged in to verify Contributor Agreement");
-    }
-    final IdentifiedUser iUser = user.asIdentifiedUser();
-
-    List<AccountGroup.UUID> okGroupIds = new ArrayList<>();
-    for (ContributorAgreement ca : contributorAgreements) {
-      List<AccountGroup.UUID> groupIds;
-      groupIds = okGroupIds;
-
-      for (PermissionRule rule : ca.getAccepted()) {
-        if ((rule.getAction() == Action.ALLOW)
-            && (rule.getGroup() != null)
-            && (rule.getGroup().getUUID() != null)) {
-          groupIds.add(new AccountGroup.UUID(rule.getGroup().getUUID().get()));
-        }
-      }
-    }
-
-    if (iUser.getEffectiveGroups().containsAnyOf(okGroupIds)) {
-      return Capable.OK;
-    }
-
-    final StringBuilder msg = new StringBuilder();
-    msg.append("A Contributor Agreement must be completed before uploading");
-    if (canonicalWebUrl != null) {
-      msg.append(":\n\n  ");
-      msg.append(canonicalWebUrl);
-      msg.append("#");
-      msg.append(PageLinks.SETTINGS_AGREEMENTS);
-      msg.append("\n");
-    } else {
-      msg.append(".");
-    }
-    msg.append("\n");
-    return new Capable(msg.toString());
-  }
-
   private boolean canPerformOnTagRef(String permissionName) {
     for (SectionMatcher matcher : access()) {
       AccessSection section = matcher.section;
@@ -478,7 +349,7 @@
       // matches every possible reference.  Check all
       // patterns also have the permission.
       //
-      for (final String pattern : patterns) {
+      for (String pattern : patterns) {
         if (controlForRef(pattern).canPerform(permission)) {
           canPerform = true;
         } else if (ignore.contains(pattern)) {
@@ -510,13 +381,6 @@
     return allSections;
   }
 
-  private List<SectionMatcher> localAccess() {
-    if (localSections == null) {
-      localSections = state.getLocalAccessSections();
-    }
-    return localSections;
-  }
-
   boolean match(PermissionRule rule) {
     return match(rule.getGroup().getUUID());
   }
@@ -539,66 +403,110 @@
     }
   }
 
-  public boolean canRunUploadPack() {
-    for (AccountGroup.UUID group : uploadGroups) {
-      if (match(group)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  public boolean canRunReceivePack() {
-    for (AccountGroup.UUID group : receiveGroups) {
-      if (match(group)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  /** @return whether a commit is visible to user. */
-  public boolean canReadCommit(ReviewDb db, Repository repo, RevCommit commit) {
-    // Look for changes associated with the commit.
+  boolean isReachableFromHeadsOrTags(Repository repo, RevCommit commit) {
     try {
-      List<ChangeData> changes =
-          queryProvider.get().byProjectCommit(getProject().getNameKey(), commit);
-      for (ChangeData change : changes) {
-        if (controlFor(db, change.change()).isVisible(db)) {
-          return true;
-        }
+      RefDatabase refdb = repo.getRefDatabase();
+      Collection<Ref> heads = refdb.getRefs(Constants.R_HEADS).values();
+      Collection<Ref> tags = refdb.getRefs(Constants.R_TAGS).values();
+      Map<String, Ref> refs = Maps.newHashMapWithExpectedSize(heads.size() + tags.size());
+      for (Ref r : Iterables.concat(heads, tags)) {
+        refs.put(r.getName(), r);
       }
-    } catch (OrmException e) {
-      log.error(
-          "Cannot look up change for commit " + commit.name() + " in " + getProject().getName(), e);
-    }
-    // Scan all visible refs.
-    return canReadCommitFromVisibleRef(db, repo, commit);
-  }
-
-  private boolean canReadCommitFromVisibleRef(ReviewDb db, Repository repo, RevCommit commit) {
-    try (RevWalk rw = new RevWalk(repo)) {
-      return isMergedIntoVisibleRef(repo, db, rw, commit, repo.getAllRefs().values());
+      return commits.isReachableFrom(state, repo, commit, refs);
     } catch (IOException e) {
-      String msg =
-          String.format(
-              "Cannot verify permissions to commit object %s in repository %s",
-              commit.name(), getProject().getNameKey());
-      log.error(msg, e);
+      log.error(
+          "Cannot verify permissions to commit object {} in repository {}",
+          commit.name(),
+          getProject().getNameKey(),
+          e);
       return false;
     }
   }
 
-  boolean isMergedIntoVisibleRef(
-      Repository repo, ReviewDb db, RevWalk rw, RevCommit commit, Collection<Ref> unfilteredRefs)
-      throws IOException {
-    VisibleRefFilter filter =
-        new VisibleRefFilter(tagCache, changeNotesFactory, changeCache, repo, this, db, true);
-    Map<String, Ref> m = Maps.newHashMapWithExpectedSize(unfilteredRefs.size());
-    for (Ref r : unfilteredRefs) {
-      m.put(r.getName(), r);
+  ForProject asForProject() {
+    return new ForProjectImpl();
+  }
+
+  private class ForProjectImpl extends ForProject {
+    @Override
+    public ForProject user(CurrentUser user) {
+      return forUser(user).asForProject().database(db);
     }
-    Map<String, Ref> refs = filter.filter(m, true);
-    return !refs.isEmpty() && IncludedInResolver.includedInOne(repo, rw, commit, refs.values());
+
+    @Override
+    public ForRef ref(String ref) {
+      return controlForRef(ref).asForRef().database(db);
+    }
+
+    @Override
+    public ForChange change(ChangeData cd) {
+      try {
+        checkProject(cd.change());
+        return super.change(cd);
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    @Override
+    public ForChange change(ChangeNotes notes) {
+      checkProject(notes.getChange());
+      return super.change(notes);
+    }
+
+    private void checkProject(Change change) {
+      Project.NameKey project = getProject().getNameKey();
+      checkArgument(
+          project.equals(change.getProject()),
+          "expected change in project %s, not %s",
+          project,
+          change.getProject());
+    }
+
+    @Override
+    public void check(ProjectPermission perm) throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted");
+      }
+    }
+
+    @Override
+    public Set<ProjectPermission> test(Collection<ProjectPermission> permSet)
+        throws PermissionBackendException {
+      EnumSet<ProjectPermission> ok = EnumSet.noneOf(ProjectPermission.class);
+      for (ProjectPermission perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(ProjectPermission perm) throws PermissionBackendException {
+      switch (perm) {
+        case ACCESS:
+          return (!isHidden() && (user.isInternalUser() || canPerformOnAnyRef(Permission.READ)))
+              || isOwner();
+
+        case READ:
+          return !isHidden() && allRefsAreVisible(Collections.emptySet());
+
+        case READ_NO_CONFIG:
+          return !isHidden() && allRefsAreVisible(ImmutableSet.of(RefNames.REFS_CONFIG));
+
+        case CREATE_REF:
+          return canAddRefs();
+        case CREATE_TAG_REF:
+          return canAddTagRefs();
+        case CREATE_CHANGE:
+          return canCreateChanges();
+
+        case RUN_RECEIVE_PACK:
+          return canRunReceivePack();
+        case RUN_UPLOAD_PACK:
+          return canRunUploadPack();
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
index 9d9e5bb..ac8d536 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectHierarchyIterator.java
@@ -44,7 +44,7 @@
     allProjectsName = all;
 
     seen = Sets.newLinkedHashSet();
-    seen.add(firstResult.getProject().getNameKey());
+    seen.add(firstResult.getNameKey());
     next = firstResult;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
index b4b9c49..f2a93d3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectJson.java
@@ -14,7 +14,12 @@
 
 package com.google.gerrit.server.project;
 
+import static java.util.stream.Collectors.toMap;
+
 import com.google.common.base.Strings;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.common.data.LabelValue;
+import com.google.gerrit.extensions.common.LabelTypeInfo;
 import com.google.gerrit.extensions.common.ProjectInfo;
 import com.google.gerrit.extensions.common.WebLinkInfo;
 import com.google.gerrit.extensions.restapi.Url;
@@ -23,6 +28,7 @@
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
+import java.util.HashMap;
 import java.util.List;
 
 @Singleton
@@ -37,8 +43,18 @@
     this.webLinks = webLinks;
   }
 
-  public ProjectInfo format(ProjectResource rsrc) {
-    return format(rsrc.getControl().getProject());
+  public ProjectInfo format(ProjectState projectState) {
+    ProjectInfo info = format(projectState.getProject());
+    info.labels = new HashMap<>();
+    for (LabelType t : projectState.getLabelTypes().getLabelTypes()) {
+      LabelTypeInfo labelInfo = new LabelTypeInfo();
+      labelInfo.values =
+          t.getValues().stream().collect(toMap(LabelValue::formatValue, LabelValue::getText));
+      labelInfo.defaultValue = t.getDefaultValue();
+      info.labels.put(t.getName(), labelInfo);
+    }
+
+    return info;
   }
 
   public ProjectInfo format(Project p) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java
index 403efd2..e1ba692 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectNode.java
@@ -76,12 +76,12 @@
     return children;
   }
 
-  public void addChild(final ProjectNode child) {
+  public void addChild(ProjectNode child) {
     children.add(child);
   }
 
   @Override
-  public int compareTo(final ProjectNode o) {
+  public int compareTo(ProjectNode o) {
     return project.getNameKey().compareTo(o.project.getNameKey());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
index b8830a0..a91ba62 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectResource.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.restapi.RestResource;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.inject.TypeLiteral;
 
 public class ProjectResource implements RestResource {
@@ -42,8 +42,12 @@
     return control.getProject().getNameKey();
   }
 
-  public ProjectState getState() {
-    return control.getProject().getState();
+  public ProjectState getProjectState() {
+    return control.getProjectState();
+  }
+
+  public CurrentUser getUser() {
+    return getControl().getUser();
   }
 
   public ProjectControl getControl() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
index 0ca4ddf6..08c3d9f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectState.java
@@ -28,6 +28,7 @@
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.data.RefConfigSection;
 import com.google.gerrit.common.data.SubscribeSection;
 import com.google.gerrit.extensions.api.projects.CommentLinkInfo;
 import com.google.gerrit.extensions.api.projects.ThemeInfo;
@@ -48,6 +49,7 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.ProjectLevelConfig;
 import com.google.gerrit.server.git.TransferConfig;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
@@ -113,6 +115,9 @@
   /** If this is all projects, the capabilities used by the server. */
   private final CapabilityCollection capabilities;
 
+  /** All label types applicable to changes in this project. */
+  private LabelTypes labelTypes;
+
   @Inject
   public ProjectState(
       SitePaths sitePaths,
@@ -124,7 +129,7 @@
       GitRepositoryManager gitMgr,
       RulesCache rulesCache,
       List<CommentLinkInfo> commentLinks,
-      CapabilityCollection.Factory capabilityFactory,
+      CapabilityCollection.Factory limitsFactory,
       TransferConfig transferConfig,
       @Assisted ProjectConfig config) {
     this.sitePaths = sitePaths;
@@ -141,7 +146,7 @@
     this.configs = new HashMap<>();
     this.capabilities =
         isAllProjects
-            ? capabilityFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
+            ? limitsFactory.create(config.getAccessSection(AccessSection.GLOBAL_CAPABILITIES))
             : null;
     this.globalMaxObjectSizeLimit = transferConfig.getMaxObjectSizeLimit();
     this.inheritProjectMaxObjectSizeLimit = transferConfig.getInheritProjectMaxObjectSizeLimit();
@@ -182,7 +187,7 @@
   }
 
   private boolean isRevisionOutOfDate() {
-    try (Repository git = gitMgr.openRepository(getProject().getNameKey())) {
+    try (Repository git = gitMgr.openRepository(getNameKey())) {
       Ref ref = git.getRefDatabase().exactRef(RefNames.REFS_CONFIG);
       if (ref == null || ref.getObjectId() == null) {
         return true;
@@ -205,7 +210,7 @@
   public PrologEnvironment newPrologEnvironment() throws CompileException {
     PrologMachineCopy pmc = rulesMachine;
     if (pmc == null) {
-      pmc = rulesCache.loadMachine(getProject().getNameKey(), config.getRulesId());
+      pmc = rulesCache.loadMachine(getNameKey(), config.getRulesId());
       rulesMachine = pmc;
     }
     return envFactory.create(pmc);
@@ -228,6 +233,14 @@
     return config.getProject();
   }
 
+  public Project.NameKey getNameKey() {
+    return getProject().getNameKey();
+  }
+
+  public String getName() {
+    return getNameKey().get();
+  }
+
   public ProjectConfig getConfig() {
     return config;
   }
@@ -238,10 +251,10 @@
     }
 
     ProjectLevelConfig cfg = new ProjectLevelConfig(fileName, this);
-    try (Repository git = gitMgr.openRepository(getProject().getNameKey())) {
+    try (Repository git = gitMgr.openRepository(getNameKey())) {
       cfg.load(git);
     } catch (IOException | ConfigInvalidException e) {
-      log.warn("Failed to load " + fileName + " for " + getProject().getName(), e);
+      log.warn("Failed to load " + fileName + " for " + getName(), e);
     }
 
     configs.put(fileName, cfg);
@@ -320,7 +333,7 @@
           section.setPermissions(copy);
         }
 
-        SectionMatcher matcher = SectionMatcher.wrap(getProject().getNameKey(), section);
+        SectionMatcher matcher = SectionMatcher.wrap(getNameKey(), section);
         if (matcher != null) {
           sm.add(matcher);
         }
@@ -376,7 +389,7 @@
     return result;
   }
 
-  public ProjectControl controlFor(final CurrentUser user) {
+  public ProjectControl controlFor(CurrentUser user) {
     return projectControlFactory.create(user, this);
   }
 
@@ -451,24 +464,55 @@
     return getInheritableBoolean(Project::getRejectImplicitMerges);
   }
 
+  public boolean isPrivateByDefault() {
+    return getInheritableBoolean(Project::getPrivateByDefault);
+  }
+
+  public boolean isWorkInProgressByDefault() {
+    return getInheritableBoolean(Project::getWorkInProgressByDefault);
+  }
+
+  public boolean isEnableReviewerByEmail() {
+    return getInheritableBoolean(Project::getEnableReviewerByEmail);
+  }
+
+  public boolean isMatchAuthorToCommitterDate() {
+    return getInheritableBoolean(Project::getMatchAuthorToCommitterDate);
+  }
+
+  /** All available label types. */
   public LabelTypes getLabelTypes() {
-    Map<String, LabelType> types = new LinkedHashMap<>();
-    for (ProjectState s : treeInOrder()) {
-      for (LabelType type : s.getConfig().getLabelSections().values()) {
-        String lower = type.getName().toLowerCase();
-        LabelType old = types.get(lower);
-        if (old == null || old.canOverride()) {
-          types.put(lower, type);
+    if (labelTypes == null) {
+      labelTypes = loadLabelTypes();
+    }
+    return labelTypes;
+  }
+
+  /** All available label types for this change and user. */
+  public LabelTypes getLabelTypes(ChangeNotes notes, CurrentUser user) {
+    return getLabelTypes(notes.getChange().getDest(), user);
+  }
+
+  /** All available label types for this branch and user. */
+  public LabelTypes getLabelTypes(Branch.NameKey destination, CurrentUser user) {
+    List<LabelType> all = getLabelTypes().getLabelTypes();
+
+    List<LabelType> r = Lists.newArrayListWithCapacity(all.size());
+    for (LabelType l : all) {
+      List<String> refs = l.getRefPatterns();
+      if (refs == null) {
+        r.add(l);
+      } else {
+        for (String refPattern : refs) {
+          if (RefConfigSection.isValid(refPattern) && match(destination, refPattern, user)) {
+            r.add(l);
+            break;
+          }
         }
       }
     }
-    List<LabelType> all = Lists.newArrayListWithCapacity(types.size());
-    for (LabelType type : types.values()) {
-      if (!type.getValues().isEmpty()) {
-        all.add(type);
-      }
-    }
-    return new LabelTypes(Collections.unmodifiableList(all));
+
+    return new LabelTypes(r);
   }
 
   public List<CommentLinkInfo> getCommentLinks() {
@@ -529,6 +573,27 @@
     return theme;
   }
 
+  public Set<GroupReference> getAllGroups() {
+    return getGroups(getAllSections());
+  }
+
+  public Set<GroupReference> getLocalGroups() {
+    return getGroups(getLocalAccessSections());
+  }
+
+  private static Set<GroupReference> getGroups(List<SectionMatcher> sectionMatcherList) {
+    final Set<GroupReference> all = new HashSet<>();
+    for (SectionMatcher matcher : sectionMatcherList) {
+      final AccessSection section = matcher.section;
+      for (Permission permission : section.getPermissions()) {
+        for (PermissionRule rule : permission.getRules()) {
+          all.add(rule.getGroup());
+        }
+      }
+    }
+    return all;
+  }
+
   private ThemeInfo loadTheme() {
     String name = getConfig().getProject().getName();
     Path dir = sitePaths.themes_dir.resolve(name);
@@ -567,4 +632,28 @@
     }
     return false;
   }
+
+  private LabelTypes loadLabelTypes() {
+    Map<String, LabelType> types = new LinkedHashMap<>();
+    for (ProjectState s : treeInOrder()) {
+      for (LabelType type : s.getConfig().getLabelSections().values()) {
+        String lower = type.getName().toLowerCase();
+        LabelType old = types.get(lower);
+        if (old == null || old.canOverride()) {
+          types.put(lower, type);
+        }
+      }
+    }
+    List<LabelType> all = Lists.newArrayListWithCapacity(types.size());
+    for (LabelType type : types.values()) {
+      if (!type.getValues().isEmpty()) {
+        all.add(type);
+      }
+    }
+    return new LabelTypes(Collections.unmodifiableList(all));
+  }
+
+  private boolean match(Branch.NameKey destination, String refPattern, CurrentUser user) {
+    return RefPatternMatcher.getMatcher(refPattern).match(destination.get(), user);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
index dcb3404..e0741f0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/ProjectsCollection.java
@@ -14,8 +14,10 @@
 
 package com.google.gerrit.server.project;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
+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.RestCollection;
@@ -25,6 +27,9 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.OutputFormat;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -37,6 +42,7 @@
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<ListProjects> list;
   private final ProjectControl.GenericFactory controlFactory;
+  private final PermissionBackend permissionBackend;
   private final Provider<CurrentUser> user;
   private final CreateProject.Factory createProjectFactory;
 
@@ -45,11 +51,13 @@
       DynamicMap<RestView<ProjectResource>> views,
       Provider<ListProjects> list,
       ProjectControl.GenericFactory controlFactory,
+      PermissionBackend permissionBackend,
       CreateProject.Factory factory,
       Provider<CurrentUser> user) {
     this.views = views;
     this.list = list;
     this.controlFactory = controlFactory;
+    this.permissionBackend = permissionBackend;
     this.user = user;
     this.createProjectFactory = factory;
   }
@@ -61,7 +69,7 @@
 
   @Override
   public ProjectResource parse(TopLevelResource parent, IdString id)
-      throws ResourceNotFoundException, IOException {
+      throws ResourceNotFoundException, IOException, PermissionBackendException {
     ProjectResource rsrc = _parse(id.get(), true);
     if (rsrc == null) {
       throw new ResourceNotFoundException(id);
@@ -77,8 +85,10 @@
    * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
    *     project is not visible to the calling user
    * @throws IOException thrown when there is an error.
+   * @throws PermissionBackendException
    */
-  public ProjectResource parse(String id) throws UnprocessableEntityException, IOException {
+  public ProjectResource parse(String id)
+      throws UnprocessableEntityException, IOException, PermissionBackendException {
     return parse(id, true);
   }
 
@@ -86,33 +96,43 @@
    * Parses a project ID from a request body and returns the project.
    *
    * @param id ID of the project, can be a project name
-   * @param checkVisibility Whether to check or not that project is visible to the calling user
+   * @param checkAccess if true, check the project is accessible by the current user
    * @return the project
    * @throws UnprocessableEntityException thrown if the project ID cannot be resolved or if the
    *     project is not visible to the calling user and checkVisibility is true.
    * @throws IOException thrown when there is an error.
+   * @throws PermissionBackendException
    */
-  public ProjectResource parse(String id, boolean checkVisibility)
-      throws UnprocessableEntityException, IOException {
-    ProjectResource rsrc = _parse(id, checkVisibility);
+  public ProjectResource parse(String id, boolean checkAccess)
+      throws UnprocessableEntityException, IOException, PermissionBackendException {
+    ProjectResource rsrc = _parse(id, checkAccess);
     if (rsrc == null) {
       throw new UnprocessableEntityException(String.format("Project Not Found: %s", id));
     }
     return rsrc;
   }
 
-  private ProjectResource _parse(String id, boolean checkVisibility) throws IOException {
+  @Nullable
+  private ProjectResource _parse(String id, boolean checkAccess)
+      throws IOException, PermissionBackendException {
     if (id.endsWith(Constants.DOT_GIT_EXT)) {
       id = id.substring(0, id.length() - Constants.DOT_GIT_EXT.length());
     }
+
+    Project.NameKey nameKey = new Project.NameKey(id);
     ProjectControl ctl;
     try {
-      ctl = controlFactory.controlFor(new Project.NameKey(id), user.get());
+      ctl = controlFactory.controlFor(nameKey, user.get());
     } catch (NoSuchProjectException e) {
       return null;
     }
-    if (checkVisibility && !ctl.isVisible() && !ctl.isOwner()) {
-      return null;
+
+    if (checkAccess) {
+      try {
+        permissionBackend.user(user).project(nameKey).check(ProjectPermission.ACCESS);
+      } catch (AuthException e) {
+        return null; // Pretend like not found on access denied.
+      }
     }
     return new ProjectResource(ctl);
   }
@@ -122,7 +142,6 @@
     return views;
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public CreateProject create(TopLevelResource parent, IdString name) {
     return createProjectFactory.create(name.get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
index 9e0db6e..8e8efaf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutBranch.java
@@ -25,7 +25,6 @@
 
   @Override
   public BranchInfo apply(BranchResource rsrc, BranchInput input) throws ResourceConflictException {
-    throw new ResourceConflictException(
-        "Branch \"" + rsrc.getBranchInfo().ref + "\" already exists");
+    throw new ResourceConflictException("Branch \"" + rsrc.getRef() + "\" already exists");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
index 5521316..c1ca04a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/PutConfig.java
@@ -21,9 +21,11 @@
 import com.google.gerrit.extensions.api.projects.ConfigValue;
 import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.extensions.restapi.RestView;
 import com.google.gerrit.reviewdb.client.Project;
@@ -33,6 +35,7 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 import com.google.gerrit.server.config.ProjectConfigEntry;
+import com.google.gerrit.server.extensions.webui.UiActions;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.inject.Inject;
@@ -62,6 +65,7 @@
   private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
   private final PluginConfigFactory cfgFactory;
   private final AllProjectsName allProjects;
+  private final UiActions uiActions;
   private final DynamicMap<RestView<ProjectResource>> views;
   private final Provider<CurrentUser> user;
 
@@ -74,6 +78,7 @@
       DynamicMap<ProjectConfigEntry> pluginConfigEntries,
       PluginConfigFactory cfgFactory,
       AllProjectsName allProjects,
+      UiActions uiActions,
       DynamicMap<RestView<ProjectResource>> views,
       Provider<CurrentUser> user) {
     this.serverEnableSignedPush = serverEnableSignedPush;
@@ -83,22 +88,22 @@
     this.pluginConfigEntries = pluginConfigEntries;
     this.cfgFactory = cfgFactory;
     this.allProjects = allProjects;
+    this.uiActions = uiActions;
     this.views = views;
     this.user = user;
   }
 
   @Override
-  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input)
-      throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
+  public ConfigInfo apply(ProjectResource rsrc, ConfigInput input) throws RestApiException {
     if (!rsrc.getControl().isOwner()) {
-      throw new ResourceNotFoundException(rsrc.getName());
+      throw new AuthException("restricted to project owner");
     }
-    return apply(rsrc.getControl(), input);
+    return apply(rsrc.getProjectState(), input);
   }
 
-  public ConfigInfo apply(ProjectControl ctrl, ConfigInput input)
+  public ConfigInfo apply(ProjectState projectState, ConfigInput input)
       throws ResourceNotFoundException, BadRequestException, ResourceConflictException {
-    Project.NameKey projectName = ctrl.getProject().getNameKey();
+    Project.NameKey projectName = projectState.getNameKey();
     if (input == null) {
       throw new BadRequestException("config is required");
     }
@@ -140,6 +145,14 @@
         p.setRejectImplicitMerges(input.rejectImplicitMerges);
       }
 
+      if (input.privateByDefault != null) {
+        p.setPrivateByDefault(input.privateByDefault);
+      }
+
+      if (input.workInProgressByDefault != null) {
+        p.setWorkInProgressByDefault(input.workInProgressByDefault);
+      }
+
       if (input.maxObjectSizeLimit != null) {
         p.setMaxObjectSizeLimit(input.maxObjectSizeLimit);
       }
@@ -152,8 +165,16 @@
         p.setState(input.state);
       }
 
+      if (input.enableReviewerByEmail != null) {
+        p.setEnableReviewerByEmail(input.enableReviewerByEmail);
+      }
+
+      if (input.matchAuthorToCommitterDate != null) {
+        p.setMatchAuthorToCommitterDate(input.matchAuthorToCommitterDate);
+      }
+
       if (input.pluginConfigValues != null) {
-        setPluginConfigValues(ctrl.getProjectState(), projectConfig, input.pluginConfigValues);
+        setPluginConfigValues(projectState, projectConfig, input.pluginConfigValues);
       }
 
       md.setMessage("Modified project settings\n");
@@ -177,6 +198,7 @@
           pluginConfigEntries,
           cfgFactory,
           allProjects,
+          uiActions,
           views);
     } catch (RepositoryNotFoundException notFound) {
       throw new ResourceNotFoundException(projectName.get());
@@ -290,7 +312,7 @@
       throw new BadRequestException(
           String.format(
               "Not allowed to set parameter '%s' of plugin '%s' on project '%s'.",
-              parameterName, pluginName, projectState.getProject().getName()));
+              parameterName, pluginName, projectState.getName()));
     }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
index ada1855..cde2c80 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java
@@ -14,37 +14,37 @@
 
 package com.google.gerrit.server.project;
 
+import static com.google.common.base.Preconditions.checkArgument;
+
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRange;
 import com.google.gerrit.common.data.PermissionRule;
-import com.google.gerrit.extensions.client.ProjectState;
+import com.google.gerrit.extensions.restapi.AuthException;
+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.CurrentUser;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import java.io.IOException;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.permissions.FailedPermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackend.ForChange;
+import com.google.gerrit.server.permissions.PermissionBackend.ForRef;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.RefPermission;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.util.Providers;
 import java.util.ArrayList;
+import java.util.Collection;
 import java.util.Collections;
+import java.util.EnumSet;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import org.eclipse.jgit.lib.Constants;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevObject;
-import org.eclipse.jgit.revwalk.RevTag;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 /** Manages access control for Git references (aka branches, tags). */
 public class RefControl {
-  private static final Logger log = LoggerFactory.getLogger(RefControl.class);
-
   private final ProjectControl projectControl;
   private final String refName;
 
@@ -100,68 +100,48 @@
   }
 
   /** Can this user see this reference exists? */
-  public boolean isVisible() {
+  boolean isVisible() {
     if (isVisible == null) {
-      isVisible = (getUser().isInternalUser() || canPerform(Permission.READ)) && canRead();
+      isVisible =
+          (getUser().isInternalUser() || canPerform(Permission.READ))
+              && isProjectStatePermittingRead();
     }
     return isVisible;
   }
 
-  /** True if this reference is visible by all REGISTERED_USERS */
-  public boolean isVisibleByRegisteredUsers() {
-    List<PermissionRule> access = relevant.getPermission(Permission.READ);
-    List<PermissionRule> overridden = relevant.getOverridden(Permission.READ);
-    Set<ProjectRef> allows = new HashSet<>();
-    Set<ProjectRef> blocks = new HashSet<>();
-    for (PermissionRule rule : access) {
-      if (rule.isBlock()) {
-        blocks.add(relevant.getRuleProps(rule));
-      } else if (SystemGroupBackend.isAnonymousOrRegistered(rule.getGroup())) {
-        allows.add(relevant.getRuleProps(rule));
-      }
-    }
-    for (PermissionRule rule : overridden) {
-      if (SystemGroupBackend.isAnonymousOrRegistered(rule.getGroup())) {
-        blocks.remove(relevant.getRuleProps(rule));
-      }
-    }
-    blocks.removeAll(allows);
-    return blocks.isEmpty() && !allows.isEmpty();
+  /** Can this user see other users change edits? */
+  public boolean isEditVisible() {
+    return canViewPrivateChanges();
   }
 
-  /**
-   * Determines whether the user can upload a change to the ref controlled by this object.
-   *
-   * @return {@code true} if the user specified can upload a change to the Git ref
-   */
-  public boolean canUpload() {
+  private boolean canUpload() {
     return projectControl.controlForRef("refs/for/" + getRefName()).canPerform(Permission.PUSH)
-        && canWrite();
+        && isProjectStatePermittingWrite();
   }
 
   /** @return true if this user can add a new patch set to this ref */
-  public boolean canAddPatchSet() {
+  boolean canAddPatchSet() {
     return projectControl
             .controlForRef("refs/for/" + getRefName())
             .canPerform(Permission.ADD_PATCH_SET)
-        && canWrite();
+        && isProjectStatePermittingWrite();
   }
 
   /** @return true if this user can submit merge patch sets to this ref */
-  public boolean canUploadMerges() {
+  private boolean canUploadMerges() {
     return projectControl
             .controlForRef("refs/for/" + getRefName())
             .canPerform(Permission.PUSH_MERGE)
-        && canWrite();
+        && isProjectStatePermittingWrite();
   }
 
   /** @return true if this user can rebase changes on this ref */
-  public boolean canRebase() {
-    return canPerform(Permission.REBASE) && canWrite();
+  boolean canRebase() {
+    return canPerform(Permission.REBASE) && isProjectStatePermittingWrite();
   }
 
   /** @return true if this user can submit patch sets to this ref */
-  public boolean canSubmit(boolean isChangeOwner) {
+  boolean canSubmit(boolean isChangeOwner) {
     if (RefNames.REFS_CONFIG.equals(refName)) {
       // Always allow project owners to submit configuration changes.
       // Submitting configuration changes modifies the access control
@@ -170,16 +150,11 @@
       // granting of powers beyond submitting to the configuration.
       return projectControl.isOwner();
     }
-    return canPerform(Permission.SUBMIT, isChangeOwner) && canWrite();
-  }
-
-  /** @return true if this user was granted submitAs to this ref */
-  public boolean canSubmitAs() {
-    return canPerform(Permission.SUBMIT_AS);
+    return canPerform(Permission.SUBMIT, isChangeOwner) && isProjectStatePermittingWrite();
   }
 
   /** @return true if the user can update the reference as a fast-forward. */
-  public boolean canUpdate() {
+  private boolean canUpdate() {
     if (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner()) {
       // Pushing requires being at least project owner, in addition to push.
       // Pushing configuration changes modifies the access control
@@ -190,17 +165,16 @@
       // On the AllProjects project the owner access right cannot be assigned,
       // this why for the AllProjects project we allow administrators to push
       // configuration changes if they have push without being project owner.
-      if (!(projectControl.getProjectState().isAllProjects()
-          && getUser().getCapabilities().canAdministrateServer())) {
+      if (!(projectControl.getProjectState().isAllProjects() && projectControl.isAdmin())) {
         return false;
       }
     }
-    return canPerform(Permission.PUSH) && canWrite();
+    return canPerform(Permission.PUSH) && isProjectStatePermittingWrite();
   }
 
   /** @return true if the user can rewind (force push) the reference. */
-  public boolean canForceUpdate() {
-    if (!canWrite()) {
+  private boolean canForceUpdate() {
+    if (!isProjectStatePermittingWrite()) {
       return false;
     }
 
@@ -218,21 +192,21 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return getUser().getCapabilities().canAdministrateServer()
-            || (isOwner() && !isForceBlocked(Permission.PUSH));
+        return (isOwner() && !isForceBlocked(Permission.PUSH)) || projectControl.isAdmin();
     }
   }
 
-  public boolean canWrite() {
-    return getProjectControl().getProject().getState().equals(ProjectState.ACTIVE);
+  private boolean isProjectStatePermittingWrite() {
+    return getProjectControl().getProject().getState().permitsWrite();
   }
 
-  public boolean canRead() {
-    return getProjectControl().getProject().getState().equals(ProjectState.READ_ONLY) || canWrite();
+  private boolean isProjectStatePermittingRead() {
+    return getProjectControl().getProject().getState().permitsRead();
   }
 
   private boolean canPushWithForce() {
-    if (!canWrite() || (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner())) {
+    if (!isProjectStatePermittingWrite()
+        || (RefNames.REFS_CONFIG.equals(refName) && !projectControl.isOwner())) {
       // Pushing requires being at least project owner, in addition to push.
       // Pushing configuration changes modifies the access control
       // rules. Allowing this to be done by a non-project-owner opens
@@ -244,108 +218,12 @@
   }
 
   /**
-   * Determines whether the user can create a new Git ref.
-   *
-   * @param db db for checking change visibility.
-   * @param repo repository on which user want to create
-   * @param object the object the user will start the reference with.
-   * @return {@code true} if the user specified can create a new Git ref
-   */
-  public boolean canCreate(ReviewDb db, Repository repo, RevObject object) {
-    if (!canWrite()) {
-      return false;
-    }
-
-    if (object instanceof RevCommit) {
-      if (!canPerform(Permission.CREATE)) {
-        // No create permissions.
-        return false;
-      }
-      return canCreateCommit(db, repo, (RevCommit) object);
-    } else if (object instanceof RevTag) {
-      final RevTag tag = (RevTag) object;
-      try (RevWalk rw = new RevWalk(repo)) {
-        rw.parseBody(tag);
-      } catch (IOException e) {
-        return false;
-      }
-
-      // If tagger is present, require it matches the user's email.
-      //
-      final PersonIdent tagger = tag.getTaggerIdent();
-      if (tagger != null) {
-        boolean valid;
-        if (getUser().isIdentifiedUser()) {
-          final String addr = tagger.getEmailAddress();
-          valid = getUser().asIdentifiedUser().hasEmailAddress(addr);
-        } else {
-          valid = false;
-        }
-        if (!valid && !canForgeCommitter()) {
-          return false;
-        }
-      }
-
-      RevObject tagObject = tag.getObject();
-      if (tagObject instanceof RevCommit) {
-        if (!canCreateCommit(db, repo, (RevCommit) tagObject)) {
-          return false;
-        }
-      } else {
-        if (!canCreate(db, repo, tagObject)) {
-          return false;
-        }
-      }
-
-      // If the tag has a PGP signature, allow a lower level of permission
-      // than if it doesn't have a PGP signature.
-      //
-      if (tag.getFullMessage().contains("-----BEGIN PGP SIGNATURE-----\n")) {
-        return canPerform(Permission.CREATE_SIGNED_TAG);
-      }
-      return canPerform(Permission.CREATE_TAG);
-    } else {
-      return false;
-    }
-  }
-
-  private boolean canCreateCommit(ReviewDb db, Repository repo, RevCommit commit) {
-    if (canUpdate()) {
-      // If the user has push permissions, they can create the ref regardless
-      // of whether they are pushing any new objects along with the create.
-      return true;
-    } else if (isMergedIntoBranchOrTag(db, repo, commit)) {
-      // If the user has no push permissions, check whether the object is
-      // merged into a branch or tag readable by this user. If so, they are
-      // not effectively "pushing" more objects, so they can create the ref
-      // even if they don't have push permission.
-      return true;
-    }
-    return false;
-  }
-
-  private boolean isMergedIntoBranchOrTag(ReviewDb db, Repository repo, RevCommit commit) {
-    try (RevWalk rw = new RevWalk(repo)) {
-      List<Ref> refs = new ArrayList<>(repo.getRefDatabase().getRefs(Constants.R_HEADS).values());
-      refs.addAll(repo.getRefDatabase().getRefs(Constants.R_TAGS).values());
-      return projectControl.isMergedIntoVisibleRef(repo, db, rw, commit, refs);
-    } catch (IOException e) {
-      String msg =
-          String.format(
-              "Cannot verify permissions to commit object %s in repository %s",
-              commit.name(), projectControl.getProject().getNameKey());
-      log.error(msg, e);
-    }
-    return false;
-  }
-
-  /**
    * Determines whether the user can delete the Git ref controlled by this object.
    *
    * @return {@code true} if the user specified can delete a Git ref.
    */
-  public boolean canDelete() {
-    if (!canWrite() || (RefNames.REFS_CONFIG.equals(refName))) {
+  private boolean canDelete() {
+    if (!isProjectStatePermittingWrite() || (RefNames.REFS_CONFIG.equals(refName))) {
       // Never allow removal of the refs/meta/config branch.
       // Deleting the branch would destroy all Gerrit specific
       // metadata about the project, including its access rules.
@@ -364,15 +242,15 @@
       case UNKNOWN:
       case WEB_BROWSER:
       default:
-        return getUser().getCapabilities().canAdministrateServer()
-            || (isOwner() && !isForceBlocked(Permission.PUSH))
+        return (isOwner() && !isForceBlocked(Permission.PUSH))
             || canPushWithForce()
-            || canPerform(Permission.DELETE);
+            || canPerform(Permission.DELETE)
+            || projectControl.isAdmin();
     }
   }
 
   /** @return true if this user can forge the author line in a commit. */
-  public boolean canForgeAuthor() {
+  private boolean canForgeAuthor() {
     if (canForgeAuthor == null) {
       canForgeAuthor = canPerform(Permission.FORGE_AUTHOR);
     }
@@ -380,7 +258,7 @@
   }
 
   /** @return true if this user can forge the committer line in a commit. */
-  public boolean canForgeCommitter() {
+  private boolean canForgeCommitter() {
     if (canForgeCommitter == null) {
       canForgeCommitter = canPerform(Permission.FORGE_COMMITTER);
     }
@@ -388,12 +266,12 @@
   }
 
   /** @return true if this user can forge the server on the committer line. */
-  public boolean canForgeGerritServerIdentity() {
+  private boolean canForgeGerritServerIdentity() {
     return canPerform(Permission.FORGE_SERVER);
   }
 
   /** @return true if this user can abandon a change for this ref */
-  public boolean canAbandon() {
+  boolean canAbandon() {
     return canPerform(Permission.ABANDON);
   }
 
@@ -402,74 +280,43 @@
     return canPerform(Permission.REMOVE_REVIEWER);
   }
 
-  /** @return true if this user can view draft changes. */
-  public boolean canViewDrafts() {
-    return canPerform(Permission.VIEW_DRAFTS);
-  }
-
-  /** @return true if this user can publish draft changes. */
-  public boolean canPublishDrafts() {
-    return canPerform(Permission.PUBLISH_DRAFTS);
-  }
-
-  /** @return true if this user can delete draft changes. */
-  public boolean canDeleteDrafts() {
-    return canPerform(Permission.DELETE_DRAFTS);
+  /** @return true if this user can view private changes. */
+  boolean canViewPrivateChanges() {
+    return canPerform(Permission.VIEW_PRIVATE_CHANGES);
   }
 
   /** @return true if this user can delete changes. */
-  public boolean canDeleteChanges(boolean isChangeOwner) {
+  boolean canDeleteChanges(boolean isChangeOwner) {
     return canPerform(Permission.DELETE_CHANGES)
         || (isChangeOwner && canPerform(Permission.DELETE_OWN_CHANGES, isChangeOwner));
   }
 
   /** @return true if this user can edit topic names. */
-  public boolean canEditTopicName() {
+  boolean canEditTopicName() {
     return canPerform(Permission.EDIT_TOPIC_NAME);
   }
 
   /** @return true if this user can edit hashtag names. */
-  public boolean canEditHashtags() {
+  boolean canEditHashtags() {
     return canPerform(Permission.EDIT_HASHTAGS);
   }
 
-  public boolean canEditAssignee() {
+  boolean canEditAssignee() {
     return canPerform(Permission.EDIT_ASSIGNEE);
   }
 
   /** @return true if this user can force edit topic names. */
-  public boolean canForceEditTopicName() {
+  boolean canForceEditTopicName() {
     return canForcePerform(Permission.EDIT_TOPIC_NAME);
   }
 
-  /** All value ranges of any allowed label permission. */
-  public List<PermissionRange> getLabelRanges(boolean isChangeOwner) {
-    List<PermissionRange> r = new ArrayList<>();
-    for (Map.Entry<String, List<PermissionRule>> e : relevant.getDeclaredPermissions()) {
-      if (Permission.isLabel(e.getKey())) {
-        int min = 0;
-        int max = 0;
-        for (PermissionRule rule : e.getValue()) {
-          if (projectControl.match(rule, isChangeOwner)) {
-            min = Math.min(min, rule.getMin());
-            max = Math.max(max, rule.getMax());
-          }
-        }
-        if (min != 0 || max != 0) {
-          r.add(new PermissionRange(e.getKey(), min, max));
-        }
-      }
-    }
-    return r;
-  }
-
   /** The range of permitted values associated with a label permission. */
-  public PermissionRange getRange(String permission) {
+  PermissionRange getRange(String permission) {
     return getRange(permission, false);
   }
 
   /** The range of permitted values associated with a label permission. */
-  public PermissionRange getRange(String permission, boolean isChangeOwner) {
+  PermissionRange getRange(String permission, boolean isChangeOwner) {
     if (Permission.hasRange(permission)) {
       return toRange(permission, access(permission, isChangeOwner));
     }
@@ -643,4 +490,102 @@
     effective.put(permissionName, mine);
     return mine;
   }
+
+  ForRef asForRef() {
+    return new ForRefImpl();
+  }
+
+  private class ForRefImpl extends ForRef {
+    @Override
+    public ForRef user(CurrentUser user) {
+      return forUser(user).asForRef().database(db);
+    }
+
+    @Override
+    public ForChange change(ChangeData cd) {
+      try {
+        // TODO(hiesel) Force callers to call database() and use db instead of cd.db()
+        return getProjectControl()
+            .controlFor(cd.db(), cd.change())
+            .asForChange(cd, Providers.of(cd.db()));
+      } catch (OrmException e) {
+        return FailedPermissionBackend.change("unavailable", e);
+      }
+    }
+
+    @Override
+    public ForChange change(ChangeNotes notes) {
+      Project.NameKey project = getProjectControl().getProject().getNameKey();
+      Change change = notes.getChange();
+      checkArgument(
+          project.equals(change.getProject()),
+          "expected change in project %s, not %s",
+          project,
+          change.getProject());
+      return getProjectControl().controlFor(notes).asForChange(null, db);
+    }
+
+    @Override
+    public ForChange indexedChange(ChangeData cd, ChangeNotes notes) {
+      return getProjectControl().controlFor(notes).asForChange(cd, db);
+    }
+
+    @Override
+    public void check(RefPermission perm) throws AuthException, PermissionBackendException {
+      if (!can(perm)) {
+        throw new AuthException(perm.describeForException() + " not permitted for " + getRefName());
+      }
+    }
+
+    @Override
+    public Set<RefPermission> test(Collection<RefPermission> permSet)
+        throws PermissionBackendException {
+      EnumSet<RefPermission> ok = EnumSet.noneOf(RefPermission.class);
+      for (RefPermission perm : permSet) {
+        if (can(perm)) {
+          ok.add(perm);
+        }
+      }
+      return ok;
+    }
+
+    private boolean can(RefPermission perm) throws PermissionBackendException {
+      switch (perm) {
+        case READ:
+          return isVisible();
+        case CREATE:
+          // TODO This isn't an accurate test.
+          return canPerform(perm.permissionName().get());
+        case DELETE:
+          return canDelete();
+        case UPDATE:
+          return canUpdate();
+        case FORCE_UPDATE:
+          return canForceUpdate();
+
+        case FORGE_AUTHOR:
+          return canForgeAuthor();
+        case FORGE_COMMITTER:
+          return canForgeCommitter();
+        case FORGE_SERVER:
+          return canForgeGerritServerIdentity();
+        case MERGE:
+          return canUploadMerges();
+
+        case CREATE_CHANGE:
+          return canUpload();
+
+        case UPDATE_BY_SUBMIT:
+          return projectControl.controlForRef("refs/for/" + getRefName()).canSubmit(true);
+
+        case SKIP_VALIDATION:
+          return canForgeAuthor()
+              && canForgeCommitter()
+              && canForgeGerritServerIdentity()
+              && canUploadMerges()
+              && !projectControl.getProjectState().isUseSignedOffBy();
+      }
+      throw new PermissionBackendException(perm + " unsupported");
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java
new file mode 100644
index 0000000..d4c0f53
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RemoveReviewerControl.java
@@ -0,0 +1,126 @@
+// 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.project;
+
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+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.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.io.IOException;
+
+@Singleton
+public class RemoveReviewerControl {
+  private final PermissionBackend permissionBackend;
+  private final Provider<ReviewDb> dbProvider;
+  private final ProjectControl.GenericFactory projectControlFactory;
+
+  @Inject
+  RemoveReviewerControl(
+      PermissionBackend permissionBackend,
+      Provider<ReviewDb> dbProvider,
+      ProjectControl.GenericFactory projectControlFactory) {
+    this.permissionBackend = permissionBackend;
+    this.dbProvider = dbProvider;
+    this.projectControlFactory = projectControlFactory;
+  }
+
+  /**
+   * Checks if removing the given reviewer and patch set approval is OK.
+   *
+   * @throws AuthException if this user is not allowed to remove this approval.
+   */
+  public void checkRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, PatchSetApproval approval)
+      throws PermissionBackendException, AuthException, NoSuchProjectException, IOException {
+    checkRemoveReviewer(notes, currentUser, approval.getAccountId(), approval.getValue());
+  }
+
+  /**
+   * Checks if removing the given reviewer is OK. Does not check if removing any approvals the
+   * reviewer might have given is OK.
+   *
+   * @throws AuthException if this user is not allowed to remove this approval.
+   */
+  public void checkRemoveReviewer(ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer)
+      throws PermissionBackendException, AuthException, NoSuchProjectException, IOException {
+    checkRemoveReviewer(notes, currentUser, reviewer, 0);
+  }
+
+  /** @return true if the user is allowed to remove this reviewer. */
+  public boolean testRemoveReviewer(
+      ChangeData cd, CurrentUser currentUser, Account.Id reviewer, int value)
+      throws PermissionBackendException, NoSuchProjectException, OrmException, IOException {
+    if (canRemoveReviewerWithoutPermissionCheck(cd.change(), currentUser, reviewer, value)) {
+      return true;
+    }
+    return permissionBackend
+        .user(currentUser)
+        .change(cd)
+        .database(dbProvider)
+        .test(ChangePermission.REMOVE_REVIEWER);
+  }
+
+  private void checkRemoveReviewer(
+      ChangeNotes notes, CurrentUser currentUser, Account.Id reviewer, int val)
+      throws PermissionBackendException, NoSuchProjectException, AuthException, IOException {
+    if (canRemoveReviewerWithoutPermissionCheck(notes.getChange(), currentUser, reviewer, val)) {
+      return;
+    }
+
+    permissionBackend
+        .user(currentUser)
+        .change(notes)
+        .database(dbProvider)
+        .check(ChangePermission.REMOVE_REVIEWER);
+  }
+
+  private boolean canRemoveReviewerWithoutPermissionCheck(
+      Change change, CurrentUser currentUser, Account.Id reviewer, int value)
+      throws NoSuchProjectException, IOException {
+    if (change.getStatus().equals(Change.Status.MERGED)) {
+      return false;
+    }
+
+    if (currentUser.isIdentifiedUser()) {
+      Account.Id aId = currentUser.getAccountId();
+      if (aId.equals(reviewer)) {
+        return true; // A user can always remove themselves.
+      } else if (aId.equals(change.getOwner()) && 0 <= value) {
+        return true; // The change owner may remove any zero or positive score.
+      }
+    }
+
+    // Users with the remove reviewer permission, the branch owner, project
+    // owner and site admin can remove anyone
+    ProjectControl ctl = projectControlFactory.controlFor(change.getProject(), currentUser);
+    if (ctl.controlForRef(change.getDest()).isOwner() // branch owner
+        || ctl.isOwner() // project owner
+        || ctl.isAdmin()) { // project admin
+      return true;
+    }
+    return false;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/RuleEvalException.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/RuleEvalException.java
index 9dae11c..d2af2a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/RuleEvalException.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/RuleEvalException.java
@@ -14,8 +14,9 @@
 
 package com.google.gerrit.server.project;
 
-@SuppressWarnings("serial")
 public class RuleEvalException extends Exception {
+  private static final long serialVersionUID = 1L;
+
   public RuleEvalException(String message) {
     super(message);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
index c74efc6..e875388 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
@@ -14,18 +14,10 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
 import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.data.GroupDescription;
-import com.google.gerrit.common.data.GroupReference;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.InvalidNameException;
-import com.google.gerrit.extensions.api.access.AccessSectionInfo;
-import com.google.gerrit.extensions.api.access.PermissionInfo;
-import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInfo;
 import com.google.gerrit.extensions.api.access.ProjectAccessInput;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -37,161 +29,85 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
-import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.GroupsCollection;
+import com.google.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 java.io.IOException;
-import java.util.ArrayList;
-import java.util.Collections;
 import java.util.List;
-import java.util.Map;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 
 @Singleton
 public class SetAccess implements RestModifyView<ProjectResource, ProjectAccessInput> {
   protected final GroupBackend groupBackend;
-  private final GroupsCollection groupsCollection;
+  private final PermissionBackend permissionBackend;
   private final Provider<MetaDataUpdate.User> metaDataUpdateFactory;
-  private final AllProjectsName allProjects;
-  private final Provider<SetParent> setParent;
   private final GetAccess getAccess;
   private final ProjectCache projectCache;
   private final Provider<IdentifiedUser> identifiedUser;
+  private final SetAccessUtil accessUtil;
 
   @Inject
   private SetAccess(
       GroupBackend groupBackend,
+      PermissionBackend permissionBackend,
       Provider<MetaDataUpdate.User> metaDataUpdateFactory,
-      AllProjectsName allProjects,
-      Provider<SetParent> setParent,
-      GroupsCollection groupsCollection,
       ProjectCache projectCache,
       GetAccess getAccess,
-      Provider<IdentifiedUser> identifiedUser) {
+      Provider<IdentifiedUser> identifiedUser,
+      SetAccessUtil accessUtil) {
     this.groupBackend = groupBackend;
+    this.permissionBackend = permissionBackend;
     this.metaDataUpdateFactory = metaDataUpdateFactory;
-    this.allProjects = allProjects;
-    this.setParent = setParent;
-    this.groupsCollection = groupsCollection;
     this.getAccess = getAccess;
     this.projectCache = projectCache;
     this.identifiedUser = identifiedUser;
+    this.accessUtil = accessUtil;
   }
 
   @Override
   public ProjectAccessInfo apply(ProjectResource rsrc, ProjectAccessInput input)
       throws ResourceNotFoundException, ResourceConflictException, IOException, AuthException,
-          BadRequestException, UnprocessableEntityException {
-    List<AccessSection> removals = getAccessSections(input.remove);
-    List<AccessSection> additions = getAccessSections(input.add);
+          BadRequestException, UnprocessableEntityException, OrmException,
+          PermissionBackendException {
     MetaDataUpdate.User metaDataUpdateUser = metaDataUpdateFactory.get();
 
-    ProjectControl projectControl = rsrc.getControl();
     ProjectConfig config;
 
-    Project.NameKey newParentProjectName =
-        input.parent == null ? null : new Project.NameKey(input.parent);
-
+    List<AccessSection> removals = accessUtil.getAccessSections(input.remove);
+    List<AccessSection> additions = accessUtil.getAccessSections(input.add);
     try (MetaDataUpdate md = metaDataUpdateUser.create(rsrc.getNameKey())) {
       config = ProjectConfig.read(md);
 
-      // Perform removal checks
-      for (AccessSection section : removals) {
+      // Check that the user has the right permissions.
+      boolean checkedAdmin = false;
+      for (AccessSection section : Iterables.concat(additions, removals)) {
         boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
-
         if (isGlobalCapabilities) {
-          checkGlobalCapabilityPermissions(config.getName());
-        } else if (!projectControl.controlForRef(section.getName()).isOwner()) {
+          if (!checkedAdmin) {
+            permissionBackend.user(identifiedUser).check(GlobalPermission.ADMINISTRATE_SERVER);
+            checkedAdmin = true;
+          }
+        } else if (!rsrc.getControl().controlForRef(section.getName()).isOwner()) {
           throw new AuthException(
-              "You are not allowed to edit permissionsfor ref: " + section.getName());
-        }
-      }
-      // Perform addition checks
-      for (AccessSection section : additions) {
-        String name = section.getName();
-        boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(name);
-
-        if (isGlobalCapabilities) {
-          checkGlobalCapabilityPermissions(config.getName());
-        } else {
-          if (!AccessSection.isValid(name)) {
-            throw new BadRequestException("invalid section name");
-          }
-          if (!projectControl.controlForRef(name).isOwner()) {
-            throw new AuthException("You are not allowed to edit permissionsfor ref: " + name);
-          }
-          RefPattern.validate(name);
-        }
-
-        // Check all permissions for soundness
-        for (Permission p : section.getPermissions()) {
-          if (isGlobalCapabilities && !GlobalCapability.isCapability(p.getName())) {
-            throw new BadRequestException(
-                "Cannot add non-global capability " + p.getName() + " to global capabilities");
-          }
+              "You are not allowed to edit permissions for ref: " + section.getName());
         }
       }
 
-      // Apply removals
-      for (AccessSection section : removals) {
-        if (section.getPermissions().isEmpty()) {
-          // Remove entire section
-          config.remove(config.getAccessSection(section.getName()));
-        }
-        // Remove specific permissions
-        for (Permission p : section.getPermissions()) {
-          if (p.getRules().isEmpty()) {
-            config.remove(config.getAccessSection(section.getName()), p);
-          } else {
-            for (PermissionRule r : p.getRules()) {
-              config.remove(config.getAccessSection(section.getName()), p, r);
-            }
-          }
-        }
-      }
+      accessUtil.validateChanges(config, removals, additions);
+      accessUtil.applyChanges(config, removals, additions);
 
-      // Apply additions
-      for (AccessSection section : additions) {
-        AccessSection currentAccessSection = config.getAccessSection(section.getName());
-
-        if (currentAccessSection == null) {
-          // Add AccessSection
-          config.replace(section);
-        } else {
-          for (Permission p : section.getPermissions()) {
-            Permission currentPermission = currentAccessSection.getPermission(p.getName());
-            if (currentPermission == null) {
-              // Add Permission
-              currentAccessSection.addPermission(p);
-            } else {
-              for (PermissionRule r : p.getRules()) {
-                // AddPermissionRule
-                currentPermission.add(r);
-              }
-            }
-          }
-        }
-      }
-
-      if (newParentProjectName != null
-          && !config.getProject().getNameKey().equals(allProjects)
-          && !config.getProject().getParent(allProjects).equals(newParentProjectName)) {
-        try {
-          setParent
-              .get()
-              .validateParentUpdate(
-                  projectControl,
-                  MoreObjects.firstNonNull(newParentProjectName, allProjects).get(),
-                  true);
-        } catch (UnprocessableEntityException e) {
-          throw new ResourceConflictException(e.getMessage(), e);
-        }
-        config.getProject().setParentName(newParentProjectName);
-      }
+      accessUtil.setParentName(
+          identifiedUser.get(),
+          config,
+          rsrc.getNameKey(),
+          input.parent == null ? null : new Project.NameKey(input.parent),
+          !checkedAdmin);
 
       if (!Strings.isNullOrEmpty(input.message)) {
         if (!input.message.endsWith("\n")) {
@@ -212,73 +128,4 @@
 
     return getAccess.apply(rsrc.getNameKey());
   }
-
-  private List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
-      throws UnprocessableEntityException {
-    if (sectionInfos == null) {
-      return Collections.emptyList();
-    }
-
-    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
-    for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
-      AccessSection accessSection = new AccessSection(entry.getKey());
-
-      if (entry.getValue().permissions == null) {
-        continue;
-      }
-
-      for (Map.Entry<String, PermissionInfo> permissionEntry :
-          entry.getValue().permissions.entrySet()) {
-        Permission p = new Permission(permissionEntry.getKey());
-        if (permissionEntry.getValue().exclusive != null) {
-          p.setExclusiveGroup(permissionEntry.getValue().exclusive);
-        }
-
-        if (permissionEntry.getValue().rules == null) {
-          continue;
-        }
-        for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
-            permissionEntry.getValue().rules.entrySet()) {
-          PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
-
-          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
-          if (group == null) {
-            throw new UnprocessableEntityException(
-                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
-          }
-          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
-          if (pri != null) {
-            if (pri.max != null) {
-              r.setMax(pri.max);
-            }
-            if (pri.min != null) {
-              r.setMin(pri.min);
-            }
-            r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
-            if (pri.force != null) {
-              r.setForce(pri.force);
-            }
-          }
-          p.add(r);
-        }
-        accessSection.getPermissions().add(p);
-      }
-      sections.add(accessSection);
-    }
-    return sections;
-  }
-
-  private void checkGlobalCapabilityPermissions(Project.NameKey projectName)
-      throws BadRequestException, AuthException {
-
-    if (!allProjects.equals(projectName)) {
-      throw new BadRequestException(
-          "Cannot edit global capabilities for projects other than " + allProjects.get());
-    }
-
-    if (!identifiedUser.get().getCapabilities().canAdministrateServer()) {
-      throw new AuthException(
-          "Editing global capabilities requires " + GlobalCapability.ADMINISTRATE_SERVER);
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java
new file mode 100644
index 0000000..848d68c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccessUtil.java
@@ -0,0 +1,224 @@
+// 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.project;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.GlobalCapability;
+import com.google.gerrit.common.data.GroupDescription;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.common.data.PermissionRule;
+import com.google.gerrit.common.errors.InvalidNameException;
+import com.google.gerrit.extensions.api.access.AccessSectionInfo;
+import com.google.gerrit.extensions.api.access.PermissionInfo;
+import com.google.gerrit.extensions.api.access.PermissionRuleInfo;
+import com.google.gerrit.extensions.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.server.IdentifiedUser;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class SetAccessUtil {
+  private final GroupsCollection groupsCollection;
+  private final AllProjectsName allProjects;
+  private final Provider<SetParent> setParent;
+
+  @Inject
+  private SetAccessUtil(
+      GroupsCollection groupsCollection,
+      AllProjectsName allProjects,
+      Provider<SetParent> setParent) {
+    this.groupsCollection = groupsCollection;
+    this.allProjects = allProjects;
+    this.setParent = setParent;
+  }
+
+  List<AccessSection> getAccessSections(Map<String, AccessSectionInfo> sectionInfos)
+      throws UnprocessableEntityException {
+    if (sectionInfos == null) {
+      return Collections.emptyList();
+    }
+
+    List<AccessSection> sections = new ArrayList<>(sectionInfos.size());
+    for (Map.Entry<String, AccessSectionInfo> entry : sectionInfos.entrySet()) {
+      if (entry.getValue().permissions == null) {
+        continue;
+      }
+
+      AccessSection accessSection = new AccessSection(entry.getKey());
+      for (Map.Entry<String, PermissionInfo> permissionEntry :
+          entry.getValue().permissions.entrySet()) {
+        if (permissionEntry.getValue().rules == null) {
+          continue;
+        }
+
+        Permission p = new Permission(permissionEntry.getKey());
+        if (permissionEntry.getValue().exclusive != null) {
+          p.setExclusiveGroup(permissionEntry.getValue().exclusive);
+        }
+
+        for (Map.Entry<String, PermissionRuleInfo> permissionRuleInfoEntry :
+            permissionEntry.getValue().rules.entrySet()) {
+          GroupDescription.Basic group = groupsCollection.parseId(permissionRuleInfoEntry.getKey());
+          if (group == null) {
+            throw new UnprocessableEntityException(
+                permissionRuleInfoEntry.getKey() + " is not a valid group ID");
+          }
+
+          PermissionRuleInfo pri = permissionRuleInfoEntry.getValue();
+          PermissionRule r = new PermissionRule(GroupReference.forGroup(group));
+          if (pri != null) {
+            if (pri.max != null) {
+              r.setMax(pri.max);
+            }
+            if (pri.min != null) {
+              r.setMin(pri.min);
+            }
+            r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
+            if (pri.force != null) {
+              r.setForce(pri.force);
+            }
+          }
+          p.add(r);
+        }
+        accessSection.getPermissions().add(p);
+      }
+      sections.add(accessSection);
+    }
+    return sections;
+  }
+
+  /**
+   * Checks that the removals and additions are logically valid, but doesn't check current user's
+   * permission.
+   */
+  void validateChanges(
+      ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions)
+      throws BadRequestException, InvalidNameException {
+    // Perform permission checks
+    for (AccessSection section : Iterables.concat(additions, removals)) {
+      boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(section.getName());
+      if (isGlobalCapabilities) {
+        if (!allProjects.equals(config.getName())) {
+          throw new BadRequestException(
+              "Cannot edit global capabilities for projects other than " + allProjects.get());
+        }
+      }
+    }
+
+    // Perform addition checks
+    for (AccessSection section : additions) {
+      String name = section.getName();
+      boolean isGlobalCapabilities = AccessSection.GLOBAL_CAPABILITIES.equals(name);
+
+      if (!isGlobalCapabilities) {
+        if (!AccessSection.isValid(name)) {
+          throw new BadRequestException("invalid section name");
+        }
+        RefPattern.validate(name);
+      } else {
+        // Check all permissions for soundness
+        for (Permission p : section.getPermissions()) {
+          if (!GlobalCapability.isCapability(p.getName())) {
+            throw new BadRequestException(
+                "Cannot add non-global capability " + p.getName() + " to global capabilities");
+          }
+        }
+      }
+    }
+  }
+
+  void applyChanges(
+      ProjectConfig config, List<AccessSection> removals, List<AccessSection> additions) {
+    // Apply removals
+    for (AccessSection section : removals) {
+      if (section.getPermissions().isEmpty()) {
+        // Remove entire section
+        config.remove(config.getAccessSection(section.getName()));
+        continue;
+      }
+
+      // Remove specific permissions
+      for (Permission p : section.getPermissions()) {
+        if (p.getRules().isEmpty()) {
+          config.remove(config.getAccessSection(section.getName()), p);
+        } else {
+          for (PermissionRule r : p.getRules()) {
+            config.remove(config.getAccessSection(section.getName()), p, r);
+          }
+        }
+      }
+    }
+
+    // Apply additions
+    for (AccessSection section : additions) {
+      AccessSection currentAccessSection = config.getAccessSection(section.getName());
+
+      if (currentAccessSection == null) {
+        // Add AccessSection
+        config.replace(section);
+      } else {
+        for (Permission p : section.getPermissions()) {
+          Permission currentPermission = currentAccessSection.getPermission(p.getName());
+          if (currentPermission == null) {
+            // Add Permission
+            currentAccessSection.addPermission(p);
+          } else {
+            for (PermissionRule r : p.getRules()) {
+              // AddPermissionRule
+              currentPermission.add(r);
+            }
+          }
+        }
+      }
+    }
+  }
+
+  void setParentName(
+      IdentifiedUser identifiedUser,
+      ProjectConfig config,
+      Project.NameKey projectName,
+      Project.NameKey newParentProjectName,
+      boolean checkAdmin)
+      throws ResourceConflictException, AuthException, PermissionBackendException {
+    if (newParentProjectName != null
+        && !config.getProject().getNameKey().equals(allProjects)
+        && !config.getProject().getParent(allProjects).equals(newParentProjectName)) {
+      try {
+        setParent
+            .get()
+            .validateParentUpdate(
+                projectName, identifiedUser, newParentProjectName.get(), checkAdmin);
+      } catch (UnprocessableEntityException e) {
+        throw new ResourceConflictException(e.getMessage(), e);
+      }
+      config.getProject().setParentName(newParentProjectName);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
index 332ea76..21ec077 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDashboard.java
@@ -14,28 +14,20 @@
 
 package com.google.gerrit.server.project;
 
-import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
-import com.google.gerrit.extensions.restapi.DefaultInput;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.common.SetDashboardInput;
 import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
-import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
-import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
-import com.google.gerrit.server.project.SetDashboard.Input;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
 import java.io.IOException;
 
 @Singleton
-class SetDashboard implements RestModifyView<DashboardResource, Input> {
-  static class Input {
-    @DefaultInput String id;
-    String commitMessage;
-  }
-
+public class SetDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
   private final Provider<SetDefaultDashboard> defaultSetter;
 
   @Inject
@@ -44,9 +36,8 @@
   }
 
   @Override
-  public Response<DashboardInfo> apply(DashboardResource resource, Input input)
-      throws AuthException, BadRequestException, ResourceConflictException,
-          MethodNotAllowedException, ResourceNotFoundException, IOException {
+  public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
+      throws RestApiException, IOException, PermissionBackendException {
     if (resource.isProjectDefault()) {
       return defaultSetter.get().apply(resource, input);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java
index be93296..9aa9ae7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetDefaultDashboard.java
@@ -16,18 +16,20 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.DashboardInfo;
+import com.google.gerrit.extensions.common.SetDashboardInput;
 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.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.Response;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.DashboardsCollection.DashboardInfo;
-import com.google.gerrit.server.project.SetDashboard.Input;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import java.io.IOException;
@@ -35,7 +37,7 @@
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Option;
 
-class SetDefaultDashboard implements RestModifyView<DashboardResource, Input> {
+class SetDefaultDashboard implements RestModifyView<DashboardResource, SetDashboardInput> {
   private final ProjectCache cache;
   private final MetaDataUpdate.Server updateFactory;
   private final DashboardsCollection dashboards;
@@ -57,11 +59,10 @@
   }
 
   @Override
-  public Response<DashboardInfo> apply(DashboardResource resource, Input input)
-      throws AuthException, BadRequestException, ResourceConflictException,
-          ResourceNotFoundException, IOException {
+  public Response<DashboardInfo> apply(DashboardResource resource, SetDashboardInput input)
+      throws RestApiException, IOException, PermissionBackendException {
     if (input == null) {
-      input = new Input(); // Delete would set input to null.
+      input = new SetDashboardInput(); // Delete would set input to null.
     }
     input.id = Strings.emptyToNull(input.id);
 
@@ -118,7 +119,7 @@
     }
   }
 
-  static class CreateDefault implements RestModifyView<ProjectResource, SetDashboard.Input> {
+  static class CreateDefault implements RestModifyView<ProjectResource, SetDashboardInput> {
     private final Provider<SetDefaultDashboard> setDefault;
 
     @Option(name = "--inherited", usage = "set dashboard inherited by children")
@@ -130,9 +131,8 @@
     }
 
     @Override
-    public Response<DashboardInfo> apply(ProjectResource resource, Input input)
-        throws AuthException, BadRequestException, ResourceConflictException,
-            ResourceNotFoundException, IOException {
+    public Response<DashboardInfo> apply(ProjectResource resource, SetDashboardInput input)
+        throws RestApiException, IOException, PermissionBackendException {
       SetDefaultDashboard set = setDefault.get();
       set.inherited = inherited;
       return set.apply(DashboardResource.projectDefault(resource.getControl()), input);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
index 6c45bc3..eeb47df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetHead.java
@@ -65,7 +65,7 @@
   }
 
   @Override
-  public String apply(final ProjectResource rsrc, Input input)
+  public String apply(ProjectResource rsrc, Input input)
       throws AuthException, ResourceNotFoundException, BadRequestException,
           UnprocessableEntityException, IOException {
     if (!rsrc.getControl().isOwner()) {
@@ -100,6 +100,8 @@
           case NOT_ATTEMPTED:
           case REJECTED:
           case REJECTED_CURRENT_BRANCH:
+          case REJECTED_MISSING_OBJECT:
+          case REJECTED_OTHER_REASON:
           default:
             throw new IOException("Setting HEAD failed with " + res);
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
index f8d649b..37cfcdd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetParent.java
@@ -30,6 +30,9 @@
 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.project.SetParent.Input;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
@@ -45,12 +48,18 @@
   }
 
   private final ProjectCache cache;
+  private final PermissionBackend permissionBackend;
   private final MetaDataUpdate.Server updateFactory;
   private final AllProjectsName allProjects;
 
   @Inject
-  SetParent(ProjectCache cache, MetaDataUpdate.Server updateFactory, AllProjectsName allProjects) {
+  SetParent(
+      ProjectCache cache,
+      PermissionBackend permissionBackend,
+      MetaDataUpdate.Server updateFactory,
+      AllProjectsName allProjects) {
     this.cache = cache;
+    this.permissionBackend = permissionBackend;
     this.updateFactory = updateFactory;
     this.allProjects = allProjects;
   }
@@ -58,17 +67,17 @@
   @Override
   public String apply(ProjectResource rsrc, Input input)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
-          UnprocessableEntityException, IOException {
+          UnprocessableEntityException, IOException, PermissionBackendException {
     return apply(rsrc, input, true);
   }
 
   public String apply(ProjectResource rsrc, Input input, boolean checkIfAdmin)
       throws AuthException, ResourceConflictException, ResourceNotFoundException,
-          UnprocessableEntityException, IOException {
-    ProjectControl ctl = rsrc.getControl();
+          UnprocessableEntityException, IOException, PermissionBackendException {
+    IdentifiedUser user = rsrc.getUser().asIdentifiedUser();
     String parentName =
         MoreObjects.firstNonNull(Strings.emptyToNull(input.parent), allProjects.get());
-    validateParentUpdate(ctl, parentName, checkIfAdmin);
+    validateParentUpdate(rsrc.getProjectState().getNameKey(), user, parentName, checkIfAdmin);
     try (MetaDataUpdate md = updateFactory.create(rsrc.getNameKey())) {
       ProjectConfig config = ProjectConfig.read(md);
       Project project = config.getProject();
@@ -80,10 +89,10 @@
       } else if (!msg.endsWith("\n")) {
         msg += "\n";
       }
-      md.setAuthor(ctl.getUser().asIdentifiedUser());
+      md.setAuthor(user);
       md.setMessage(msg);
       config.commit(md);
-      cache.evict(ctl.getProject());
+      cache.evict(rsrc.getProjectState().getProject());
 
       Project.NameKey parent = project.getParent(allProjects);
       checkNotNull(parent);
@@ -96,14 +105,15 @@
     }
   }
 
-  public void validateParentUpdate(final ProjectControl ctl, String newParent, boolean checkIfAdmin)
-      throws AuthException, ResourceConflictException, UnprocessableEntityException {
-    IdentifiedUser user = ctl.getUser().asIdentifiedUser();
-    if (checkIfAdmin && !user.getCapabilities().canAdministrateServer()) {
-      throw new AuthException("not administrator");
+  public void validateParentUpdate(
+      Project.NameKey project, IdentifiedUser user, String newParent, boolean checkIfAdmin)
+      throws AuthException, ResourceConflictException, UnprocessableEntityException,
+          PermissionBackendException {
+    if (checkIfAdmin) {
+      permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
     }
 
-    if (ctl.getProject().getNameKey().equals(allProjects)) {
+    if (project.equals(allProjects)) {
       throw new ResourceConflictException("cannot set parent of " + allProjects.get());
     }
 
@@ -117,14 +127,11 @@
       if (Iterables.tryFind(
               parent.tree(),
               p -> {
-                return p.getProject().getNameKey().equals(ctl.getProject().getNameKey());
+                return p.getNameKey().equals(project);
               })
           .isPresent()) {
         throw new ResourceConflictException(
-            "cycle exists between "
-                + ctl.getProject().getName()
-                + " and "
-                + parent.getProject().getName());
+            "cycle exists between " + project.get() + " and " + parent.getName());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
index d535062..f501dd3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleEvaluator.java
@@ -27,8 +27,13 @@
 import com.google.gerrit.rules.PrologEnvironment;
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.Emails;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
 import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
@@ -38,6 +43,7 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.io.IOException;
 import java.io.StringReader;
 import java.util.ArrayList;
 import java.util.Collections;
@@ -81,20 +87,41 @@
     }
   }
 
+  public interface Factory {
+    SubmitRuleEvaluator create(CurrentUser user, ChangeData cd);
+  }
+
+  private final AccountCache accountCache;
+  private final Accounts accounts;
+  private final Emails emails;
+  private final ProjectCache projectCache;
   private final ChangeData cd;
-  private final ChangeControl control;
 
   private SubmitRuleOptions.Builder optsBuilder = SubmitRuleOptions.defaults();
   private SubmitRuleOptions opts;
+  private Change change;
+  private CurrentUser user;
   private PatchSet patchSet;
   private boolean logErrors = true;
   private long reductionsConsumed;
+  private ProjectState projectState;
 
   private Term submitRule;
 
-  public SubmitRuleEvaluator(ChangeData cd) throws OrmException {
+  @Inject
+  SubmitRuleEvaluator(
+      AccountCache accountCache,
+      Accounts accounts,
+      Emails emails,
+      ProjectCache projectCache,
+      @Assisted CurrentUser user,
+      @Assisted ChangeData cd) {
+    this.accountCache = accountCache;
+    this.accounts = accounts;
+    this.emails = emails;
+    this.projectCache = projectCache;
+    this.user = user;
     this.cd = cd;
-    this.control = cd.changeControl();
   }
 
   /**
@@ -157,16 +184,6 @@
   }
 
   /**
-   * @param allow whether to allow {@link #evaluate()} on draft changes.
-   * @return this
-   */
-  public SubmitRuleEvaluator setAllowDraft(boolean allow) {
-    checkNotStarted();
-    optsBuilder.allowDraft(allow);
-    return this;
-  }
-
-  /**
    * @param skip if true, submit filter will not be applied.
    * @return this
    */
@@ -208,23 +225,17 @@
    */
   public List<SubmitRecord> evaluate() {
     initOptions();
-    Change c = control.getChange();
-    if (!opts.allowClosed() && c.getStatus().isClosed()) {
+    try {
+      init();
+    } catch (OrmException e) {
+      return ruleError("Error looking up change " + cd.getId(), e);
+    }
+
+    if (!opts.allowClosed() && change.getStatus().isClosed()) {
       SubmitRecord rec = new SubmitRecord();
       rec.status = SubmitRecord.Status.CLOSED;
       return Collections.singletonList(rec);
     }
-    if (!opts.allowDraft()) {
-      try {
-        initPatchSet();
-      } catch (OrmException e) {
-        return ruleError(
-            "Error looking up patch set " + control.getChange().currentPatchSetId(), e);
-      }
-      if (c.getStatus() == Change.Status.DRAFT || patchSet.isDraft()) {
-        return cannotSubmitDraft();
-      }
-    }
 
     List<Term> results;
     try {
@@ -234,7 +245,7 @@
               "can_submit",
               "locate_submit_filter",
               "filter_submit_results",
-              control.getUser());
+              user);
     } catch (RuleEvalException e) {
       return ruleError(e.getMessage(), e);
     }
@@ -253,24 +264,6 @@
     return resultsToSubmitRecord(getSubmitRule(), results);
   }
 
-  private List<SubmitRecord> cannotSubmitDraft() {
-    try {
-      if (!control.isDraftVisible(cd.db(), cd)) {
-        return createRuleError("Patch set " + patchSet.getId() + " not found");
-      }
-      if (patchSet.isDraft()) {
-        return createRuleError("Cannot submit draft patch sets");
-      }
-      return createRuleError("Cannot submit draft changes");
-    } catch (OrmException err) {
-      PatchSet.Id psId =
-          patchSet != null ? patchSet.getId() : control.getChange().currentPatchSetId();
-      String msg = "Cannot check visibility of patch set " + psId;
-      log.error(msg, err);
-      return createRuleError(msg);
-    }
-  }
-
   /**
    * Convert the results from Prolog Cafe's format to Gerrit's common format.
    *
@@ -398,23 +391,9 @@
   public SubmitTypeRecord getSubmitType() {
     initOptions();
     try {
-      initPatchSet();
+      init();
     } catch (OrmException e) {
-      return typeError("Error looking up patch set " + control.getChange().currentPatchSetId(), e);
-    }
-
-    try {
-      if (control.getChange().getStatus() == Change.Status.DRAFT
-          && !control.isDraftVisible(cd.db(), cd)) {
-        return SubmitTypeRecord.error("Patch set " + patchSet.getId() + " not found");
-      }
-      if (patchSet.isDraft() && !control.isDraftVisible(cd.db(), cd)) {
-        return SubmitTypeRecord.error("Patch set " + patchSet.getId() + " not found");
-      }
-    } catch (OrmException err) {
-      String msg = "Cannot read patch set " + patchSet.getId();
-      log.error(msg, err);
-      return SubmitTypeRecord.error(msg);
+      return typeError("Error looking up change " + cd.getId(), e);
     }
 
     List<Term> results;
@@ -545,7 +524,6 @@
   }
 
   private PrologEnvironment getPrologEnvironment(CurrentUser user) throws RuleEvalException {
-    ProjectState projectState = control.getProjectControl().getProjectState();
     PrologEnvironment env;
     try {
       if (opts.rule() == null) {
@@ -555,21 +533,22 @@
       }
     } catch (CompileException err) {
       String msg;
-      if (opts.rule() == null && control.getProjectControl().isOwner()) {
+      if (opts.rule() == null) {
         msg = String.format("Cannot load rules.pl for %s: %s", getProjectName(), err.getMessage());
-      } else if (opts.rule() != null) {
-        msg = err.getMessage();
       } else {
-        msg = String.format("Cannot load rules.pl for %s", getProjectName());
+        msg = err.getMessage();
       }
       throw new RuleEvalException(msg, err);
     }
+    env.set(StoredValues.ACCOUNTS, accounts);
+    env.set(StoredValues.ACCOUNT_CACHE, accountCache);
+    env.set(StoredValues.EMAILS, emails);
     env.set(StoredValues.REVIEW_DB, cd.db());
     env.set(StoredValues.CHANGE_DATA, cd);
-    env.set(StoredValues.CHANGE_CONTROL, control);
     if (user != null) {
       env.set(StoredValues.CURRENT_USER, user);
     }
+    env.set(StoredValues.PROJECT_STATE, projectState);
     return env;
   }
 
@@ -579,15 +558,13 @@
       String filterRuleLocatorName,
       String filterRuleWrapperName)
       throws RuleEvalException {
-    ProjectState projectState = control.getProjectControl().getProjectState();
     PrologEnvironment childEnv = env;
     for (ProjectState parentState : projectState.parents()) {
       PrologEnvironment parentEnv;
       try {
         parentEnv = parentState.newPrologEnvironment();
       } catch (CompileException err) {
-        throw new RuleEvalException(
-            "Cannot consult rules.pl for " + parentState.getProject().getName(), err);
+        throw new RuleEvalException("Cannot consult rules.pl for " + parentState.getName(), err);
       }
 
       parentEnv.copyStoredValues(childEnv);
@@ -605,12 +582,12 @@
         throw new RuleEvalException(
             String.format(
                 "%s on change %d of %s",
-                err.getMessage(), cd.getId().get(), parentState.getProject().getName()));
+                err.getMessage(), cd.getId().get(), parentState.getName()));
       } catch (RuntimeException err) {
         throw new RuleEvalException(
             String.format(
                 "Exception calling %s on change %d of %s",
-                filterRule, cd.getId().get(), parentState.getProject().getName()),
+                filterRule, cd.getId().get(), parentState.getName()),
             err);
       } finally {
         reductionsConsumed += env.getReductions();
@@ -666,7 +643,22 @@
     }
   }
 
-  private void initPatchSet() throws OrmException {
+  private void init() throws OrmException {
+    if (change == null) {
+      change = cd.change();
+      if (change == null) {
+        throw new OrmException("No change found");
+      }
+    }
+
+    if (projectState == null) {
+      try {
+        projectState = projectCache.checkedGet(change.getProject());
+      } catch (IOException e) {
+        throw new OrmException("Can't load project state", e);
+      }
+    }
+
     if (patchSet == null) {
       patchSet = cd.currentPatchSet();
       if (patchSet == null) {
@@ -676,6 +668,6 @@
   }
 
   private String getProjectName() {
-    return control.getProjectControl().getProjectState().getProject().getName();
+    return projectState.getName();
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java
index 6d6aaad..3e89f23 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SubmitRuleOptions.java
@@ -30,18 +30,11 @@
   }
 
   public static Builder defaults() {
-    return builder()
-        .fastEvalLabels(false)
-        .allowDraft(false)
-        .allowClosed(false)
-        .skipFilters(false)
-        .rule(null);
+    return builder().fastEvalLabels(false).allowClosed(false).skipFilters(false).rule(null);
   }
 
   public abstract boolean fastEvalLabels();
 
-  public abstract boolean allowDraft();
-
   public abstract boolean allowClosed();
 
   public abstract boolean skipFilters();
@@ -53,8 +46,6 @@
   public abstract static class Builder {
     public abstract SubmitRuleOptions.Builder fastEvalLabels(boolean fastEvalLabels);
 
-    public abstract SubmitRuleOptions.Builder allowDraft(boolean allowDraft);
-
     public abstract SubmitRuleOptions.Builder allowClosed(boolean allowClosed);
 
     public abstract SubmitRuleOptions.Builder skipFilters(boolean skipFilters);
@@ -67,7 +58,6 @@
   public Builder toBuilder() {
     return builder()
         .fastEvalLabels(fastEvalLabels())
-        .allowDraft(allowDraft())
         .allowClosed(allowClosed())
         .skipFilters(skipFilters())
         .rule(rule());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
index 9d3005c..7c7f8a5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SuggestParentCandidates.java
@@ -14,65 +14,61 @@
 
 package com.google.gerrit.server.project;
 
+import static java.util.stream.Collectors.toList;
+
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.permissions.ProjectPermission;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.Comparator;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
-import java.util.TreeSet;
 
 @Singleton
 public class SuggestParentCandidates {
-  private final ProjectControl.Factory projectControlFactory;
   private final ProjectCache projectCache;
-  private final AllProjectsName allProject;
+  private final PermissionBackend permissionBackend;
+  private final Provider<CurrentUser> user;
+  private final AllProjectsName allProjects;
 
   @Inject
   SuggestParentCandidates(
-      final ProjectControl.Factory projectControlFactory,
-      final ProjectCache projectCache,
-      final AllProjectsName allProject) {
-    this.projectControlFactory = projectControlFactory;
+      ProjectCache projectCache,
+      PermissionBackend permissionBackend,
+      Provider<CurrentUser> user,
+      AllProjectsName allProjects) {
     this.projectCache = projectCache;
-    this.allProject = allProject;
+    this.permissionBackend = permissionBackend;
+    this.user = user;
+    this.allProjects = allProjects;
   }
 
-  public List<Project.NameKey> getNameKeys() throws NoSuchProjectException {
-    List<Project> pList = getProjects();
-    final List<Project.NameKey> nameKeys = new ArrayList<>(pList.size());
-    for (Project p : pList) {
-      nameKeys.add(p.getNameKey());
-    }
-    return nameKeys;
+  public List<Project.NameKey> getNameKeys() throws PermissionBackendException {
+    return permissionBackend
+        .user(user)
+        .filter(ProjectPermission.ACCESS, parents())
+        .stream()
+        .sorted()
+        .collect(toList());
   }
 
-  public List<Project> getProjects() throws NoSuchProjectException {
-    Set<Project> projects =
-        new TreeSet<>(
-            new Comparator<Project>() {
-              @Override
-              public int compare(Project o1, Project o2) {
-                return o1.getName().compareTo(o2.getName());
-              }
-            });
+  private Set<Project.NameKey> parents() {
+    Set<Project.NameKey> parents = new HashSet<>();
     for (Project.NameKey p : projectCache.all()) {
-      try {
-        final ProjectControl control = projectControlFactory.controlFor(p);
-        final Project.NameKey parentK = control.getProject().getParent();
-        if (parentK != null) {
-          ProjectControl pControl = projectControlFactory.controlFor(parentK);
-          if (pControl.isVisible() || pControl.isOwner()) {
-            projects.add(pControl.getProject());
-          }
+      ProjectState ps = projectCache.get(p);
+      if (ps != null) {
+        Project.NameKey parent = ps.getProject().getParent();
+        if (parent != null) {
+          parents.add(parent);
         }
-      } catch (NoSuchProjectException e) {
-        continue;
       }
     }
-    projects.add(projectControlFactory.controlFor(allProject).getProject());
-    return new ArrayList<>(projects);
+    parents.add(allProjects);
+    return parents;
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
index 82afce4..78670ad 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/TagsCollection.java
@@ -58,7 +58,6 @@
     return views;
   }
 
-  @SuppressWarnings("unchecked")
   @Override
   public CreateTag create(ProjectResource resource, IdString name) {
     return createTagFactory.create(name.get());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
deleted file mode 100644
index 1bf6d8b..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndPredicate.java
+++ /dev/null
@@ -1,131 +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;
-
-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(final Predicate<T>... that) {
-    this(Arrays.asList(that));
-  }
-
-  protected AndPredicate(final 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(final int i) {
-    return children.get(i);
-  }
-
-  @Override
-  public Predicate<T> copy(final 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(final 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(final 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-server/src/main/java/com/google/gerrit/server/query/AndSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
deleted file mode 100644
index dcd8a66..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/AndSource.java
+++ /dev/null
@@ -1,200 +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.server.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-server/src/main/java/com/google/gerrit/server/query/DataSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/DataSource.java
deleted file mode 100644
index 8a1718d..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/DataSource.java
+++ /dev/null
@@ -1,26 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query;
-
-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-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
deleted file mode 100644
index 6627687..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/IntPredicate.java
+++ /dev/null
@@ -1,56 +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;
-
-/** Predicate to filter a field by matching integer value. */
-public abstract class IntPredicate<T> extends OperatorPredicate<T> {
-  private final int value;
-
-  public IntPredicate(final String name, final String value) {
-    super(name, value);
-    this.value = Integer.parseInt(value);
-  }
-
-  public IntPredicate(final String name, final int value) {
-    super(name, String.valueOf(value));
-    this.value = value;
-  }
-
-  public int intValue() {
-    return value;
-  }
-
-  @Override
-  public int hashCode() {
-    return getOperator().hashCode() * 31 + value;
-  }
-
-  @Override
-  public boolean equals(final 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-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java
deleted file mode 100644
index 87772d2..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/InternalQuery.java
+++ /dev/null
@@ -1,99 +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.server.query;
-
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Lists;
-import com.google.gerrit.server.index.Index;
-import com.google.gerrit.server.index.IndexCollection;
-import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.Schema;
-import com.google.gwtorm.server.OrmException;
-import java.util.List;
-import java.util.Set;
-
-/**
- * Execute a single query over a secondary index, for use by Gerrit internals.
- *
- * <p>By default, visibility of returned entities is not enforced (unlike in {@link
- * QueryProcessor}). The methods in this class are not typically used by user-facing paths, but
- * rather by internal callers that need to process all matching results.
- */
-public class InternalQuery<T> {
-  private final QueryProcessor<T> queryProcessor;
-  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
-
-  protected final IndexConfig indexConfig;
-
-  protected InternalQuery(
-      QueryProcessor<T> queryProcessor,
-      IndexCollection<?, T, ? extends Index<?, T>> indexes,
-      IndexConfig indexConfig) {
-    this.queryProcessor = queryProcessor.enforceVisibility(false);
-    this.indexes = indexes;
-    this.indexConfig = indexConfig;
-  }
-
-  public InternalQuery<T> setLimit(int n) {
-    queryProcessor.setLimit(n);
-    return this;
-  }
-
-  public InternalQuery<T> enforceVisibility(boolean enforce) {
-    queryProcessor.enforceVisibility(enforce);
-    return this;
-  }
-
-  public InternalQuery<T> setRequestedFields(Set<String> fields) {
-    queryProcessor.setRequestedFields(fields);
-    return this;
-  }
-
-  public InternalQuery<T> noFields() {
-    queryProcessor.setRequestedFields(ImmutableSet.<String>of());
-    return this;
-  }
-
-  public List<T> query(Predicate<T> p) throws OrmException {
-    try {
-      return queryProcessor.query(p).entities();
-    } catch (QueryParseException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  /**
-   * 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-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java
deleted file mode 100644
index 9295eb9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/IsVisibleToPredicate.java
+++ /dev/null
@@ -1,34 +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.server.query;
-
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.query.change.SingleGroupUser;
-
-public abstract class IsVisibleToPredicate<T> extends OperatorPredicate<T> implements Matchable<T> {
-  public IsVisibleToPredicate(String name, String value) {
-    super(name, value);
-  }
-
-  protected static String describe(CurrentUser user) {
-    if (user.isIdentifiedUser()) {
-      return user.getAccountId().toString();
-    }
-    if (user instanceof SingleGroupUser) {
-      return "group:" + user.getEffectiveGroups().getKnownGroups().iterator().next().toString();
-    }
-    return user.toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java
deleted file mode 100644
index 7c38e5a8..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/LimitPredicate.java
+++ /dev/null
@@ -1,40 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query;
-
-public class LimitPredicate<T> extends IntPredicate<T> implements Matchable<T> {
-  @SuppressWarnings("unchecked")
-  public static Integer getLimit(String fieldName, Predicate<?> p) {
-    IntPredicate<?> ip = QueryBuilder.find(p, IntPredicate.class, fieldName);
-    return ip != null ? ip.intValue() : null;
-  }
-
-  public LimitPredicate(String fieldName, int limit) throws QueryParseException {
-    super(fieldName, limit);
-    if (limit <= 0) {
-      throw new QueryParseException("limit must be positive: " + limit);
-    }
-  }
-
-  @Override
-  public boolean match(T object) {
-    return true;
-  }
-
-  @Override
-  public int getCost() {
-    return 0;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/Matchable.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/Matchable.java
deleted file mode 100644
index b37e112..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/Matchable.java
+++ /dev/null
@@ -1,29 +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.server.query;
-
-import com.google.gwtorm.server.OrmException;
-
-public interface Matchable<T> {
-  /**
-   * Does this predicate match this object?
-   *
-   * @throws OrmException
-   */
-  boolean match(T object) throws OrmException;
-
-  /** @return a cost estimate to run this predicate, higher figures cost more. */
-  int getCost();
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
deleted file mode 100644
index 3716ec1..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/NotPredicate.java
+++ /dev/null
@@ -1,99 +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;
-
-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(final 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(final int i) {
-    if (i != 0) {
-      throw new ArrayIndexOutOfBoundsException(i);
-    }
-    return that;
-  }
-
-  @Override
-  public Predicate<T> copy(final 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(final 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(final 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-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
deleted file mode 100644
index 96a30ee..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/OperatorPredicate.java
+++ /dev/null
@@ -1,70 +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;
-
-import java.util.Collection;
-
-/** Predicate to filter a field by matching value. */
-public abstract class OperatorPredicate<T> extends Predicate<T> {
-  private final String name;
-  private final String value;
-
-  protected OperatorPredicate(final String name, final String value) {
-    this.name = name;
-    this.value = value;
-  }
-
-  public String getOperator() {
-    return name;
-  }
-
-  public String getValue() {
-    return value;
-  }
-
-  @Override
-  public Predicate<T> copy(final Collection<? extends Predicate<T>> children) {
-    if (!children.isEmpty()) {
-      throw new IllegalArgumentException("Expected 0 children");
-    }
-    return this;
-  }
-
-  @Override
-  public int hashCode() {
-    return getOperator().hashCode() * 31 + getValue().hashCode();
-  }
-
-  @Override
-  public boolean equals(final Object other) {
-    if (other == null) {
-      return false;
-    }
-    if (getClass() == other.getClass()) {
-      final OperatorPredicate<?> p = (OperatorPredicate<?>) other;
-      return getOperator().equals(p.getOperator()) && getValue().equals(p.getValue());
-    }
-    return false;
-  }
-
-  @Override
-  public String toString() {
-    final String val = getValue();
-    if (QueryParser.isSingleWord(val)) {
-      return getOperator() + ":" + val;
-    }
-    return getOperator() + ":\"" + val + "\"";
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
deleted file mode 100644
index 4845a86..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/OrPredicate.java
+++ /dev/null
@@ -1,131 +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;
-
-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 one predicate to be true. */
-public class OrPredicate<T> extends Predicate<T> implements Matchable<T> {
-  private final List<Predicate<T>> children;
-  private final int cost;
-
-  @SafeVarargs
-  protected OrPredicate(final Predicate<T>... that) {
-    this(Arrays.asList(that));
-  }
-
-  protected OrPredicate(final 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(final int i) {
-    return children.get(i);
-  }
-
-  @Override
-  public Predicate<T> copy(final Collection<? extends Predicate<T>> children) {
-    return new OrPredicate<>(children);
-  }
-
-  @Override
-  public boolean isMatchable() {
-    for (Predicate<T> c : children) {
-      if (!c.isMatchable()) {
-        return false;
-      }
-    }
-    return true;
-  }
-
-  @Override
-  public boolean match(final T object) throws OrmException {
-    for (final Predicate<T> c : children) {
-      checkState(
-          c.isMatchable(),
-          "match invoked, but child predicate %s doesn't implement %s",
-          c,
-          Matchable.class.getName());
-      if (c.asMatchable().match(object)) {
-        return true;
-      }
-    }
-    return false;
-  }
-
-  @Override
-  public int getCost() {
-    return cost;
-  }
-
-  @Override
-  public int hashCode() {
-    return getChild(0).hashCode() * 31 + getChild(1).hashCode();
-  }
-
-  @Override
-  public boolean equals(final 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(" OR ");
-      }
-      r.append(getChild(i));
-    }
-    r.append(")");
-    return r.toString();
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/Paginated.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/Paginated.java
deleted file mode 100644
index a51555e..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/Paginated.java
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (C) 2010 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query;
-
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.ResultSet;
-
-public interface Paginated<T> {
-  QueryOptions getOptions();
-
-  ResultSet<T> restart(int start) throws OrmException;
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/PostFilterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/PostFilterPredicate.java
deleted file mode 100644
index ea2f417..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/PostFilterPredicate.java
+++ /dev/null
@@ -1,21 +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.server.query;
-
-/**
- * Matches all documents in the index, with additional filtering done in the subclass's {@code
- * match} method.
- */
-public abstract class PostFilterPredicate<T> extends Predicate<T> implements Matchable<T> {}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
deleted file mode 100644
index aabc066..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/Predicate.java
+++ /dev/null
@@ -1,165 +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;
-
-import static com.google.common.base.Preconditions.checkState;
-
-import com.google.common.collect.Iterables;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-
-/**
- * An abstract predicate tree for any form of query.
- *
- * <p>Implementations should be immutable, such that the meaning of a predicate never changes once
- * constructed. They should ensure their immutable promise by defensively copying any structures
- * which might be modified externally, but was passed into the object's constructor.
- *
- * <p>However, implementations <i>may</i> retain non-thread-safe caches internally, to speed up
- * evaluation operations within the context of one thread's evaluation of the predicate. As a
- * result, callers should assume predicates are not thread-safe, but that two predicate graphs
- * produce the same results given the same inputs if they are {@link #equals(Object)}.
- *
- * <p>Predicates should support deep inspection whenever possible, so that generic algorithms can be
- * written to operate against them. Predicates which contain other predicates should override {@link
- * #getChildren()} to return the list of children nested within the predicate.
- *
- * @param <T> type of object the predicate can evaluate in memory.
- */
-public abstract class Predicate<T> {
-  /** A predicate that matches any input, always, with no cost. */
-  @SuppressWarnings("unchecked")
-  public static <T> Predicate<T> any() {
-    return (Predicate<T>) Any.INSTANCE;
-  }
-
-  /** Combine the passed predicates into a single AND node. */
-  @SafeVarargs
-  public static <T> Predicate<T> and(final Predicate<T>... that) {
-    if (that.length == 1) {
-      return that[0];
-    }
-    return new AndPredicate<>(that);
-  }
-
-  /** Combine the passed predicates into a single AND node. */
-  public static <T> Predicate<T> and(final Collection<? extends Predicate<T>> that) {
-    if (that.size() == 1) {
-      return Iterables.getOnlyElement(that);
-    }
-    return new AndPredicate<>(that);
-  }
-
-  /** Combine the passed predicates into a single OR node. */
-  @SafeVarargs
-  public static <T> Predicate<T> or(final Predicate<T>... that) {
-    if (that.length == 1) {
-      return that[0];
-    }
-    return new OrPredicate<>(that);
-  }
-
-  /** Combine the passed predicates into a single OR node. */
-  public static <T> Predicate<T> or(final Collection<? extends Predicate<T>> that) {
-    if (that.size() == 1) {
-      return Iterables.getOnlyElement(that);
-    }
-    return new OrPredicate<>(that);
-  }
-
-  /** Invert the passed node. */
-  public static <T> Predicate<T> not(final Predicate<T> that) {
-    if (that instanceof NotPredicate) {
-      // Negate of a negate is the original predicate.
-      //
-      return that.getChild(0);
-    }
-    return new NotPredicate<>(that);
-  }
-
-  /** Get the children of this predicate, if any. */
-  public List<Predicate<T>> getChildren() {
-    return Collections.emptyList();
-  }
-
-  /** Same as {@code getChildren().size()} */
-  public int getChildCount() {
-    return getChildren().size();
-  }
-
-  /** Same as {@code getChildren().get(i)} */
-  public Predicate<T> getChild(final int i) {
-    return getChildren().get(i);
-  }
-
-  /** Create a copy of this predicate, with new children. */
-  public abstract Predicate<T> copy(Collection<? extends Predicate<T>> children);
-
-  public boolean isMatchable() {
-    return this instanceof Matchable;
-  }
-
-  @SuppressWarnings("unchecked")
-  public Matchable<T> asMatchable() {
-    checkState(isMatchable(), "not matchable");
-    return (Matchable<T>) this;
-  }
-
-  /** @return a cost estimate to run this predicate, higher figures cost more. */
-  public int estimateCost() {
-    if (!isMatchable()) {
-      return 1;
-    }
-    return asMatchable().getCost();
-  }
-
-  @Override
-  public abstract int hashCode();
-
-  @Override
-  public abstract boolean equals(Object other);
-
-  private static class Any<T> extends Predicate<T> implements Matchable<T> {
-    private static final Any<Object> INSTANCE = new Any<>();
-
-    private Any() {}
-
-    @Override
-    public Predicate<T> copy(Collection<? extends Predicate<T>> children) {
-      return this;
-    }
-
-    @Override
-    public boolean match(T object) {
-      return true;
-    }
-
-    @Override
-    public int getCost() {
-      return 0;
-    }
-
-    @Override
-    public int hashCode() {
-      return System.identityHashCode(this);
-    }
-
-    @Override
-    public boolean equals(Object other) {
-      return other == this;
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
deleted file mode 100644
index 62144ec..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
+++ /dev/null
@@ -1,352 +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;
-
-import static com.google.gerrit.server.query.Predicate.and;
-import static com.google.gerrit.server.query.Predicate.not;
-import static com.google.gerrit.server.query.Predicate.or;
-import static com.google.gerrit.server.query.QueryParser.AND;
-import static com.google.gerrit.server.query.QueryParser.DEFAULT_FIELD;
-import static com.google.gerrit.server.query.QueryParser.EXACT_PHRASE;
-import static com.google.gerrit.server.query.QueryParser.FIELD_NAME;
-import static com.google.gerrit.server.query.QueryParser.NOT;
-import static com.google.gerrit.server.query.QueryParser.OR;
-import static com.google.gerrit.server.query.QueryParser.SINGLE_WORD;
-
-import com.google.common.base.Strings;
-import java.lang.annotation.ElementType;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-import java.lang.annotation.Target;
-import java.lang.reflect.InvocationTargetException;
-import java.lang.reflect.Method;
-import java.lang.reflect.Modifier;
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import org.antlr.runtime.tree.Tree;
-
-/**
- * Base class to support writing parsers for query languages.
- *
- * <p>Subclasses may document their supported query operators by declaring public methods that
- * perform the query conversion into a {@link Predicate}. For example, to support "is:starred",
- * "is:unread", and nothing else, a subclass may write:
- *
- * <pre>
- * &#064;Operator
- * public Predicate is(final String value) {
- *   if (&quot;starred&quot;.equals(value)) {
- *     return new StarredPredicate();
- *   }
- *   if (&quot;unread&quot;.equals(value)) {
- *     return new UnreadPredicate();
- *   }
- *   throw new IllegalArgumentException();
- * }
- * </pre>
- *
- * <p>The available operator methods are discovered at runtime via reflection. Method names (after
- * being converted to lowercase), correspond to operators in the query language, method string
- * values correspond to the operator argument. Methods must be declared {@code public}, returning
- * {@link Predicate}, accepting one {@link String}, and annotated with the {@link Operator}
- * annotation.
- *
- * <p>Subclasses may also declare a handler for values which appear without operator by overriding
- * {@link #defaultField(String)}.
- *
- * @param <T> type of object the predicates can evaluate in memory.
- */
-public abstract class QueryBuilder<T> {
-  /** Converts a value string passed to an operator into a {@link Predicate}. */
-  public interface OperatorFactory<T, Q extends QueryBuilder<T>> {
-    Predicate<T> create(Q builder, String value) throws QueryParseException;
-  }
-
-  /**
-   * Defines the operators known by a QueryBuilder.
-   *
-   * <p>This class is thread-safe and may be reused or cached.
-   *
-   * @param <T> type of object the predicates can evaluate in memory.
-   * @param <Q> type of the query builder subclass.
-   */
-  public static class Definition<T, Q extends QueryBuilder<T>> {
-    private final Map<String, OperatorFactory<T, Q>> opFactories = new HashMap<>();
-
-    public Definition(Class<Q> clazz) {
-      // Guess at the supported operators by scanning methods.
-      //
-      Class<?> c = clazz;
-      while (c != QueryBuilder.class) {
-        for (final Method method : c.getDeclaredMethods()) {
-          if (method.getAnnotation(Operator.class) != null
-              && Predicate.class.isAssignableFrom(method.getReturnType())
-              && method.getParameterTypes().length == 1
-              && method.getParameterTypes()[0] == String.class
-              && (method.getModifiers() & Modifier.ABSTRACT) == 0
-              && (method.getModifiers() & Modifier.PUBLIC) == Modifier.PUBLIC) {
-            final String name = method.getName().toLowerCase();
-            if (!opFactories.containsKey(name)) {
-              opFactories.put(name, new ReflectionFactory<T, Q>(name, method));
-            }
-          }
-        }
-        c = c.getSuperclass();
-      }
-    }
-  }
-
-  /**
-   * Locate a predicate in the predicate tree.
-   *
-   * @param p the predicate to find.
-   * @param clazz type of the predicate instance.
-   * @return the predicate, null if not found.
-   */
-  @SuppressWarnings("unchecked")
-  public static <T, P extends Predicate<T>> P find(Predicate<T> p, Class<P> clazz) {
-    if (clazz.isAssignableFrom(p.getClass())) {
-      return (P) p;
-    }
-
-    for (Predicate<T> c : p.getChildren()) {
-      P r = find(c, clazz);
-      if (r != null) {
-        return r;
-      }
-    }
-
-    return null;
-  }
-
-  /**
-   * Locate a predicate in the predicate tree.
-   *
-   * @param p the predicate to find.
-   * @param clazz type of the predicate instance.
-   * @param name name of the operator.
-   * @return the first instance of a predicate having the given type, as found by a depth-first
-   *     search.
-   */
-  @SuppressWarnings("unchecked")
-  public static <T, P extends OperatorPredicate<T>> P find(
-      Predicate<T> p, Class<P> clazz, String name) {
-    if (p instanceof OperatorPredicate
-        && ((OperatorPredicate<?>) p).getOperator().equals(name)
-        && clazz.isAssignableFrom(p.getClass())) {
-      return (P) p;
-    }
-
-    for (Predicate<T> c : p.getChildren()) {
-      P r = find(c, clazz, name);
-      if (r != null) {
-        return r;
-      }
-    }
-
-    return null;
-  }
-
-  protected final Definition<T, ? extends QueryBuilder<T>> builderDef;
-
-  protected final Map<String, OperatorFactory<?, ?>> opFactories;
-
-  @SuppressWarnings({"unchecked", "rawtypes"})
-  protected QueryBuilder(Definition<T, ? extends QueryBuilder<T>> def) {
-    builderDef = def;
-    opFactories = (Map) def.opFactories;
-  }
-
-  /**
-   * Parse a user-supplied query string into a predicate.
-   *
-   * @param query the query string.
-   * @return predicate representing the user query.
-   * @throws QueryParseException the query string is invalid and cannot be parsed by this parser.
-   *     This may be due to a syntax error, may be due to an operator not being supported, or due to
-   *     an invalid value being passed to a recognized operator.
-   */
-  public Predicate<T> parse(final String query) throws QueryParseException {
-    if (Strings.isNullOrEmpty(query)) {
-      throw new QueryParseException("query is empty");
-    }
-    return toPredicate(QueryParser.parse(query));
-  }
-
-  /**
-   * Parse multiple user-supplied query strings into a list of predicates.
-   *
-   * @param queries the query strings.
-   * @return predicates representing the user query, in the same order as the input.
-   * @throws QueryParseException one of the query strings is invalid and cannot be parsed by this
-   *     parser. This may be due to a syntax error, may be due to an operator not being supported,
-   *     or due to an invalid value being passed to a recognized operator.
-   */
-  public List<Predicate<T>> parse(final List<String> queries) throws QueryParseException {
-    List<Predicate<T>> predicates = new ArrayList<>(queries.size());
-    for (String query : queries) {
-      predicates.add(parse(query));
-    }
-    return predicates;
-  }
-
-  private Predicate<T> toPredicate(final Tree r)
-      throws QueryParseException, IllegalArgumentException {
-    switch (r.getType()) {
-      case AND:
-        return and(children(r));
-      case OR:
-        return or(children(r));
-      case NOT:
-        return not(toPredicate(onlyChildOf(r)));
-
-      case DEFAULT_FIELD:
-        return defaultField(onlyChildOf(r));
-
-      case FIELD_NAME:
-        return operator(r.getText(), onlyChildOf(r));
-
-      default:
-        throw error("Unsupported operator: " + r);
-    }
-  }
-
-  private Predicate<T> operator(final String name, final Tree val) throws QueryParseException {
-    switch (val.getType()) {
-        // Expand multiple values, "foo:(a b c)", as though they were written
-        // out with the longer form, "foo:a foo:b foo:c".
-        //
-      case AND:
-      case OR:
-        {
-          List<Predicate<T>> p = new ArrayList<>(val.getChildCount());
-          for (int i = 0; i < val.getChildCount(); i++) {
-            final Tree c = val.getChild(i);
-            if (c.getType() != DEFAULT_FIELD) {
-              throw error("Nested operator not expected: " + c);
-            }
-            p.add(operator(name, onlyChildOf(c)));
-          }
-          return val.getType() == AND ? and(p) : or(p);
-        }
-
-      case SINGLE_WORD:
-      case EXACT_PHRASE:
-        if (val.getChildCount() != 0) {
-          throw error("Expected no children under: " + val);
-        }
-        return operator(name, val.getText());
-
-      default:
-        throw error("Unsupported node in operator " + name + ": " + val);
-    }
-  }
-
-  @SuppressWarnings("unchecked")
-  private Predicate<T> operator(final String name, final String value) throws QueryParseException {
-    @SuppressWarnings("rawtypes")
-    OperatorFactory f = opFactories.get(name);
-    if (f == null) {
-      throw error("Unsupported operator " + name + ":" + value);
-    }
-    return f.create(this, value);
-  }
-
-  private Predicate<T> defaultField(final Tree r) throws QueryParseException {
-    switch (r.getType()) {
-      case SINGLE_WORD:
-      case EXACT_PHRASE:
-        if (r.getChildCount() != 0) {
-          throw error("Expected no children under: " + r);
-        }
-        return defaultField(r.getText());
-
-      default:
-        throw error("Unsupported node: " + r);
-    }
-  }
-
-  /**
-   * Handle a value present outside of an operator.
-   *
-   * <p>This default implementation always throws an "Unsupported query: " message containing the
-   * input text. Subclasses may override this method to perform do-what-i-mean guesses based on the
-   * input string.
-   *
-   * @param value the value supplied by itself in the query.
-   * @return predicate representing this value.
-   * @throws QueryParseException the parser does not recognize this value.
-   */
-  protected Predicate<T> defaultField(final String value) throws QueryParseException {
-    throw error("Unsupported query:" + value);
-  }
-
-  private List<Predicate<T>> children(final Tree r)
-      throws QueryParseException, IllegalArgumentException {
-    List<Predicate<T>> p = new ArrayList<>(r.getChildCount());
-    for (int i = 0; i < r.getChildCount(); i++) {
-      p.add(toPredicate(r.getChild(i)));
-    }
-    return p;
-  }
-
-  private Tree onlyChildOf(final Tree r) throws QueryParseException {
-    if (r.getChildCount() != 1) {
-      throw error("Expected exactly one child: " + r);
-    }
-    return r.getChild(0);
-  }
-
-  protected static QueryParseException error(String msg) {
-    return new QueryParseException(msg);
-  }
-
-  protected static QueryParseException error(String msg, Throwable why) {
-    return new QueryParseException(msg, why);
-  }
-
-  /** Denotes a method which is a query operator. */
-  @Retention(RetentionPolicy.RUNTIME)
-  @Target(ElementType.METHOD)
-  protected @interface Operator {}
-
-  private static class ReflectionFactory<T, Q extends QueryBuilder<T>>
-      implements OperatorFactory<T, Q> {
-    private final String name;
-    private final Method method;
-
-    ReflectionFactory(final String name, final Method method) {
-      this.name = name;
-      this.method = method;
-    }
-
-    @SuppressWarnings("unchecked")
-    @Override
-    public Predicate<T> create(Q builder, String value) throws QueryParseException {
-      try {
-        return (Predicate<T>) method.invoke(builder, value);
-      } catch (RuntimeException | IllegalAccessException e) {
-        throw error("Error in operator " + name + ":" + value, e);
-      } catch (InvocationTargetException e) {
-        if (e.getCause() instanceof QueryParseException) {
-          throw (QueryParseException) e.getCause();
-        }
-        throw error("Error in operator " + name + ":" + value, e.getCause());
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
deleted file mode 100644
index 5fb4497..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
+++ /dev/null
@@ -1,258 +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.server.query;
-
-import com.google.common.base.Throwables;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.common.collect.Ordering;
-import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.metrics.Description;
-import com.google.gerrit.metrics.Field;
-import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.metrics.Timer1;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.index.Index;
-import com.google.gerrit.server.index.IndexCollection;
-import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexRewriter;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.index.SchemaDefinitions;
-import com.google.gwtorm.server.OrmException;
-import com.google.gwtorm.server.OrmRuntimeException;
-import com.google.gwtorm.server.ResultSet;
-import com.google.inject.Inject;
-import com.google.inject.Provider;
-import com.google.inject.Singleton;
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-
-public abstract class QueryProcessor<T> {
-  @Singleton
-  protected static class Metrics {
-    final Timer1<String> executionTime;
-
-    @Inject
-    Metrics(MetricMaker metricMaker) {
-      Field<String> index = Field.ofString("index", "index name");
-      executionTime =
-          metricMaker.newTimer(
-              "query/query_latency",
-              new Description("Successful query latency, accumulated over the life of the process")
-                  .setCumulative()
-                  .setUnit(Description.Units.MILLISECONDS),
-              index);
-    }
-  }
-
-  protected final Provider<CurrentUser> userProvider;
-
-  private final Metrics metrics;
-  private final SchemaDefinitions<T> schemaDef;
-  private final IndexConfig indexConfig;
-  private final IndexCollection<?, T, ? extends Index<?, T>> indexes;
-  private final IndexRewriter<T> rewriter;
-  private final String limitField;
-
-  protected int start;
-
-  private boolean enforceVisibility = true;
-  private int limitFromCaller;
-  private Set<String> requestedFields;
-
-  protected QueryProcessor(
-      Provider<CurrentUser> userProvider,
-      Metrics metrics,
-      SchemaDefinitions<T> schemaDef,
-      IndexConfig indexConfig,
-      IndexCollection<?, T, ? extends Index<?, T>> indexes,
-      IndexRewriter<T> rewriter,
-      String limitField) {
-    this.userProvider = userProvider;
-    this.metrics = metrics;
-    this.schemaDef = schemaDef;
-    this.indexConfig = indexConfig;
-    this.indexes = indexes;
-    this.rewriter = rewriter;
-    this.limitField = limitField;
-  }
-
-  public QueryProcessor<T> setStart(int n) {
-    start = n;
-    return this;
-  }
-
-  public QueryProcessor<T> enforceVisibility(boolean enforce) {
-    enforceVisibility = enforce;
-    return this;
-  }
-
-  public QueryProcessor<T> setLimit(int n) {
-    limitFromCaller = n;
-    return this;
-  }
-
-  public QueryProcessor<T> setRequestedFields(Set<String> fields) {
-    requestedFields = fields;
-    return this;
-  }
-
-  /**
-   * Query for entities that match a structured query.
-   *
-   * @see #query(List)
-   * @param query the query.
-   * @return results of the query.
-   */
-  public QueryResult<T> query(Predicate<T> query) throws OrmException, QueryParseException {
-    return query(ImmutableList.of(query)).get(0);
-  }
-
-  /**
-   * Perform multiple queries in parallel.
-   *
-   * @param queries list of queries.
-   * @return results of the queries, one QueryResult per input query, in the same order as the
-   *     input.
-   */
-  public List<QueryResult<T>> query(List<Predicate<T>> queries)
-      throws OrmException, QueryParseException {
-    try {
-      return query(null, queries);
-    } catch (OrmRuntimeException e) {
-      throw new OrmException(e.getMessage(), e);
-    } catch (OrmException e) {
-      if (e.getCause() != null) {
-        Throwables.throwIfInstanceOf(e.getCause(), QueryParseException.class);
-      }
-      throw e;
-    }
-  }
-
-  private List<QueryResult<T>> query(List<String> queryStrings, List<Predicate<T>> queries)
-      throws OrmException, QueryParseException {
-    long startNanos = System.nanoTime();
-
-    int cnt = queries.size();
-    // Parse and rewrite all queries.
-    List<Integer> limits = new ArrayList<>(cnt);
-    List<Predicate<T>> predicates = new ArrayList<>(cnt);
-    List<DataSource<T>> sources = new ArrayList<>(cnt);
-    for (Predicate<T> q : queries) {
-      int limit = getEffectiveLimit(q);
-      limits.add(limit);
-
-      if (limit == getBackendSupportedLimit()) {
-        limit--;
-      }
-
-      int page = (start / limit) + 1;
-      if (page > indexConfig.maxPages()) {
-        throw new QueryParseException(
-            "Cannot go beyond page " + indexConfig.maxPages() + " of results");
-      }
-
-      // Always bump limit by 1, even if this results in exceeding the permitted
-      // max for this user. The only way to see if there are more entities is to
-      // ask for one more result from the query.
-      QueryOptions opts = createOptions(indexConfig, start, limit + 1, getRequestedFields());
-      Predicate<T> pred = rewriter.rewrite(q, opts);
-      if (enforceVisibility) {
-        pred = enforceVisibility(pred);
-      }
-      predicates.add(pred);
-
-      @SuppressWarnings("unchecked")
-      DataSource<T> s = (DataSource<T>) pred;
-      sources.add(s);
-    }
-
-    // Run each query asynchronously, if supported.
-    List<ResultSet<T>> matches = new ArrayList<>(cnt);
-    for (DataSource<T> s : sources) {
-      matches.add(s.read());
-    }
-
-    List<QueryResult<T>> out = new ArrayList<>(cnt);
-    for (int i = 0; i < cnt; i++) {
-      out.add(
-          QueryResult.create(
-              queryStrings != null ? queryStrings.get(i) : null,
-              predicates.get(i),
-              limits.get(i),
-              matches.get(i).toList()));
-    }
-
-    // only measure successful queries
-    metrics.executionTime.record(
-        schemaDef.getName(), System.nanoTime() - startNanos, TimeUnit.NANOSECONDS);
-    return out;
-  }
-
-  protected QueryOptions createOptions(
-      IndexConfig indexConfig, int start, int limit, Set<String> requestedFields) {
-    return QueryOptions.create(indexConfig, start, limit, requestedFields);
-  }
-
-  /**
-   * Invoked after the query was rewritten. Subclasses must overwrite this method to filter out
-   * results that are not visible to the calling user.
-   *
-   * @param pred the query
-   * @return the modified query
-   */
-  protected abstract Predicate<T> enforceVisibility(Predicate<T> pred);
-
-  private Set<String> getRequestedFields() {
-    if (requestedFields != null) {
-      return requestedFields;
-    }
-    Index<?, T> index = indexes.getSearchIndex();
-    return index != null ? index.getSchema().getStoredFields().keySet() : ImmutableSet.<String>of();
-  }
-
-  public boolean isDisabled() {
-    return getPermittedLimit() <= 0;
-  }
-
-  private int getPermittedLimit() {
-    if (enforceVisibility) {
-      return userProvider.get().getCapabilities().getRange(GlobalCapability.QUERY_LIMIT).getMax();
-    }
-    return Integer.MAX_VALUE;
-  }
-
-  private int getBackendSupportedLimit() {
-    return indexConfig.maxLimit();
-  }
-
-  private int getEffectiveLimit(Predicate<T> p) {
-    List<Integer> possibleLimits = new ArrayList<>(4);
-    possibleLimits.add(getBackendSupportedLimit());
-    possibleLimits.add(getPermittedLimit());
-    if (limitFromCaller > 0) {
-      possibleLimits.add(limitFromCaller);
-    }
-    if (limitField != null) {
-      Integer limitFromPredicate = LimitPredicate.getLimit(limitField, p);
-      if (limitFromPredicate != null) {
-        possibleLimits.add(limitFromPredicate);
-      }
-    }
-    return Ordering.natural().min(possibleLimits);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryResult.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryResult.java
deleted file mode 100644
index f86eb707..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryResult.java
+++ /dev/null
@@ -1,51 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query;
-
-import com.google.auto.value.AutoValue;
-import com.google.gerrit.common.Nullable;
-import java.util.List;
-
-/** Results of a query over entities. */
-@AutoValue
-public abstract class QueryResult<T> {
-  static <T> QueryResult<T> create(
-      @Nullable String query, Predicate<T> predicate, int limit, List<T> entites) {
-    boolean more;
-    if (entites.size() > limit) {
-      more = true;
-      entites = entites.subList(0, limit);
-    } else {
-      more = false;
-    }
-    return new AutoValue_QueryResult<>(query, predicate, entites, more);
-  }
-
-  /** @return the original query string, or null if the query was created programmatically. */
-  @Nullable
-  public abstract String query();
-
-  /** @return the predicate after all rewriting and other modification by the query subsystem. */
-  public abstract Predicate<T> predicate();
-
-  /** @return the query results. */
-  public abstract List<T> entities();
-
-  /**
-   * @return whether the query could be retried with {@link QueryProcessor#setStart(int)} to produce
-   *     more results. Never true if {@link #entities()} is empty.
-   */
-  public abstract boolean more();
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
index 0a74647..cc9fc0d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountIsVisibleToPredicate.java
@@ -14,16 +14,17 @@
 
 package com.google.gerrit.server.query.account;
 
+import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.account.AccountControl;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.query.IsVisibleToPredicate;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gwtorm.server.OrmException;
 
 public class AccountIsVisibleToPredicate extends IsVisibleToPredicate<AccountState> {
-  private final AccountControl accountControl;
+  protected final AccountControl accountControl;
 
-  AccountIsVisibleToPredicate(AccountControl accountControl) {
-    super(AccountQueryBuilder.FIELD_VISIBLETO, describe(accountControl.getUser()));
+  public AccountIsVisibleToPredicate(AccountControl accountControl) {
+    super(AccountQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(accountControl.getUser()));
     this.accountControl = accountControl;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
index 4359dc8..9ab8b0a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountPredicates.java
@@ -16,16 +16,16 @@
 
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.query.Matchable;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryBuilder;
 import com.google.gwtorm.server.OrmException;
 import java.util.List;
 
@@ -34,7 +34,11 @@
     return QueryBuilder.find(p, AccountPredicate.class, AccountField.ACTIVE.getName()) != null;
   }
 
-  static Predicate<AccountState> defaultPredicate(String query) {
+  public static Predicate<AccountState> andActive(Predicate<AccountState> p) {
+    return Predicate.and(p, isActive());
+  }
+
+  public static Predicate<AccountState> defaultPredicate(String query) {
     // Adapt the capacity of this list when adding more default predicates.
     List<Predicate<AccountState>> preds = Lists.newArrayListWithCapacity(3);
     Integer id = Ints.tryParse(query);
@@ -53,21 +57,33 @@
         AccountField.ID, AccountQueryBuilder.FIELD_ACCOUNT, accountId.toString());
   }
 
-  static Predicate<AccountState> email(String email) {
+  public static Predicate<AccountState> email(String email) {
     return new AccountPredicate(
         AccountField.EMAIL, AccountQueryBuilder.FIELD_EMAIL, email.toLowerCase());
   }
 
-  static Predicate<AccountState> equalsName(String name) {
+  public static Predicate<AccountState> preferredEmail(String email) {
+    return new AccountPredicate(
+        AccountField.PREFERRED_EMAIL,
+        AccountQueryBuilder.FIELD_PREFERRED_EMAIL,
+        email.toLowerCase());
+  }
+
+  public static Predicate<AccountState> preferredEmailExact(String email) {
+    return new AccountPredicate(
+        AccountField.PREFERRED_EMAIL_EXACT, AccountQueryBuilder.FIELD_PREFERRED_EMAIL_EXACT, email);
+  }
+
+  public static Predicate<AccountState> equalsName(String name) {
     return new AccountPredicate(
         AccountField.NAME_PART, AccountQueryBuilder.FIELD_NAME, name.toLowerCase());
   }
 
-  static Predicate<AccountState> externalId(String externalId) {
+  public static Predicate<AccountState> externalId(String externalId) {
     return new AccountPredicate(AccountField.EXTERNAL_ID, externalId);
   }
 
-  static Predicate<AccountState> fullName(String fullName) {
+  public static Predicate<AccountState> fullName(String fullName) {
     return new AccountPredicate(AccountField.FULL_NAME, fullName);
   }
 
@@ -75,23 +91,23 @@
     return new AccountPredicate(AccountField.ACTIVE, "1");
   }
 
-  static Predicate<AccountState> isInactive() {
+  public static Predicate<AccountState> isNotActive() {
     return new AccountPredicate(AccountField.ACTIVE, "0");
   }
 
-  static Predicate<AccountState> username(String username) {
+  public static Predicate<AccountState> username(String username) {
     return new AccountPredicate(
         AccountField.USERNAME, AccountQueryBuilder.FIELD_USERNAME, username.toLowerCase());
   }
 
-  static Predicate<AccountState> watchedProject(Project.NameKey project) {
+  public static Predicate<AccountState> watchedProject(Project.NameKey project) {
     return new AccountPredicate(AccountField.WATCHED_PROJECT, project.get());
   }
 
-  static Predicate<AccountState> cansee(
+  public static Predicate<AccountState> cansee(
       AccountQueryBuilder.Arguments args, ChangeNotes changeNotes) {
     return new CanSeeChangePredicate(
-        args.db, args.changeControlFactory, args.userFactory, changeNotes);
+        args.db, args.permissionBackend, args.userFactory, changeNotes);
   }
 
   static class AccountPredicate extends IndexPredicate<AccountState>
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
index 8f38f1e..959f764 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryBuilder.java
@@ -18,17 +18,20 @@
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.errors.NotSignedInException;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeFinder;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.query.LimitPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryBuilder;
-import com.google.gerrit.server.query.QueryParseException;
+import com.google.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.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -40,6 +43,8 @@
   public static final String FIELD_EMAIL = "email";
   public static final String FIELD_LIMIT = "limit";
   public static final String FIELD_NAME = "name";
+  public static final String FIELD_PREFERRED_EMAIL = "preferredemail";
+  public static final String FIELD_PREFERRED_EMAIL_EXACT = "preferredemail_exact";
   public static final String FIELD_USERNAME = "username";
   public static final String FIELD_VISIBLETO = "visibleto";
 
@@ -49,8 +54,8 @@
   public static class Arguments {
     final Provider<ReviewDb> db;
     final ChangeFinder changeFinder;
-    final ChangeControl.GenericFactory changeControlFactory;
     final IdentifiedUser.GenericFactory userFactory;
+    final PermissionBackend permissionBackend;
 
     private final Provider<CurrentUser> self;
 
@@ -59,13 +64,13 @@
         Provider<CurrentUser> self,
         Provider<ReviewDb> db,
         ChangeFinder changeFinder,
-        ChangeControl.GenericFactory changeControlFactory,
-        IdentifiedUser.GenericFactory userFactory) {
+        IdentifiedUser.GenericFactory userFactory,
+        PermissionBackend permissionBackend) {
       this.self = self;
       this.db = db;
       this.changeFinder = changeFinder;
-      this.changeControlFactory = changeControlFactory;
       this.userFactory = userFactory;
+      this.permissionBackend = permissionBackend;
     }
 
     IdentifiedUser getIdentifiedUser() throws QueryParseException {
@@ -98,13 +103,19 @@
   }
 
   @Operator
-  public Predicate<AccountState> cansee(String change) throws QueryParseException, OrmException {
-    ChangeControl changeControl = args.changeFinder.findOne(change, args.getUser());
-    if (changeControl == null || !changeControl.isVisible(args.db.get())) {
+  public Predicate<AccountState> cansee(String change)
+      throws QueryParseException, OrmException, PermissionBackendException {
+    ChangeNotes changeNotes = args.changeFinder.findOne(change);
+    if (changeNotes == null
+        || !args.permissionBackend
+            .user(args.getUser())
+            .database(args.db)
+            .change(changeNotes)
+            .test(ChangePermission.READ)) {
       throw error(String.format("change %s not found", change));
     }
 
-    return AccountPredicates.cansee(args, changeControl.getNotes());
+    return AccountPredicates.cansee(args, changeNotes);
   }
 
   @Operator
@@ -118,7 +129,7 @@
       return AccountPredicates.isActive();
     }
     if ("inactive".equalsIgnoreCase(value)) {
-      return AccountPredicates.isInactive();
+      return AccountPredicates.isNotActive();
     }
     throw error("Invalid query");
   }
@@ -150,15 +161,16 @@
 
   @Override
   protected Predicate<AccountState> defaultField(String query) {
+    Predicate<AccountState> defaultPredicate = AccountPredicates.defaultPredicate(query);
     if (query.startsWith("cansee:")) {
       try {
         return cansee(query.substring(7));
-      } catch (OrmException | QueryParseException e) {
+      } catch (OrmException | QueryParseException | PermissionBackendException e) {
         // Ignore, fall back to default query
       }
     }
-    Predicate<AccountState> defaultPredicate = AccountPredicates.defaultPredicate(query);
-    if ("self".equalsIgnoreCase(query)) {
+
+    if ("self".equalsIgnoreCase(query) || "me".equalsIgnoreCase(query)) {
       try {
         return Predicate.or(defaultPredicate, AccountPredicates.id(self()));
       } catch (QueryParseException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
index d984e6d..a33118d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/AccountQueryProcessor.java
@@ -17,20 +17,28 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.account.AccountQueryBuilder.FIELD_LIMIT;
 
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.AndSource;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryProcessor;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexPredicate;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.index.account.AccountIndexRewriter;
 import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
-import com.google.gerrit.server.query.AndSource;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryProcessor;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
+/**
+ * Query processor for the account index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
 public class AccountQueryProcessor extends QueryProcessor<AccountState> {
   private final AccountControl.Factory accountControlFactory;
 
@@ -44,19 +52,20 @@
   @Inject
   protected AccountQueryProcessor(
       Provider<CurrentUser> userProvider,
-      Metrics metrics,
+      AccountLimits.Factory limitsFactory,
+      MetricMaker metricMaker,
       IndexConfig indexConfig,
       AccountIndexCollection indexes,
       AccountIndexRewriter rewriter,
       AccountControl.Factory accountControlFactory) {
     super(
-        userProvider,
-        metrics,
+        metricMaker,
         AccountSchemaDefinitions.INSTANCE,
         indexConfig,
         indexes,
         rewriter,
-        FIELD_LIMIT);
+        FIELD_LIMIT,
+        () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.accountControlFactory = accountControlFactory;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
index f275272..f8b8cc7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/CanSeeChangePredicate.java
@@ -1,26 +1,14 @@
-// Copyright (C) 2016 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
 package com.google.gerrit.server.query.account;
 
+import com.google.gerrit.index.query.PostFilterPredicate;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountState;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.query.PostFilterPredicate;
-import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 import java.util.Collection;
@@ -28,26 +16,32 @@
 
 public class CanSeeChangePredicate extends PostFilterPredicate<AccountState> {
   private final Provider<ReviewDb> db;
-  private final ChangeControl.GenericFactory changeControlFactory;
+  private final PermissionBackend permissionBackend;
   private final IdentifiedUser.GenericFactory userFactory;
   private final ChangeNotes changeNotes;
 
   CanSeeChangePredicate(
       Provider<ReviewDb> db,
-      ChangeControl.GenericFactory changeControlFactory,
+      PermissionBackend permissionBackend,
       IdentifiedUser.GenericFactory userFactory,
       ChangeNotes changeNotes) {
     this.db = db;
-    this.changeControlFactory = changeControlFactory;
+    this.permissionBackend = permissionBackend;
     this.userFactory = userFactory;
     this.changeNotes = changeNotes;
   }
 
   @Override
   public boolean match(AccountState accountState) throws OrmException {
-    return changeControlFactory
-        .controlFor(changeNotes, userFactory.create(accountState.getAccount().getId()))
-        .isVisible(db.get());
+    try {
+      return permissionBackend
+          .user(userFactory.create(accountState.getAccount().getId()))
+          .database(db)
+          .change(changeNotes)
+          .test(ChangePermission.READ);
+    } catch (PermissionBackendException e) {
+      throw new OrmException("Failed to check if account can see change", e);
+    }
   }
 
   @Override
@@ -57,7 +51,7 @@
 
   @Override
   public Predicate<AccountState> copy(Collection<? extends Predicate<AccountState>> children) {
-    return new CanSeeChangePredicate(db, changeControlFactory, userFactory, changeNotes);
+    return new CanSeeChangePredicate(db, permissionBackend, userFactory, changeNotes);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
index c2b92aa..f42c099 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/account/InternalAccountQuery.java
@@ -14,21 +14,38 @@
 
 package com.google.gerrit.server.query.account;
 
+import static java.util.stream.Collectors.toList;
+import static java.util.stream.Collectors.toSet;
+
 import com.google.common.base.Joiner;
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableListMultimap;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.query.InternalQuery;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.account.AccountState;
-import com.google.gerrit.server.account.ExternalId;
-import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.index.account.AccountField;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
-import com.google.gerrit.server.query.InternalQuery;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import java.util.Arrays;
 import java.util.List;
 import java.util.Set;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
+/**
+ * Query wrapper for the account index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
 public class InternalAccountQuery extends InternalQuery<AccountState> {
   private static final Logger log = LoggerFactory.getLogger(InternalAccountQuery.class);
 
@@ -68,10 +85,6 @@
     return query(AccountPredicates.defaultPredicate(query));
   }
 
-  public List<AccountState> byEmailPrefix(String emailPrefix) throws OrmException {
-    return query(AccountPredicates.email(emailPrefix));
-  }
-
   public List<AccountState> byExternalId(String scheme, String id) throws OrmException {
     return byExternalId(ExternalId.Key.create(scheme, id));
   }
@@ -94,7 +107,7 @@
       return accountStates.get(0);
     } else if (accountStates.size() > 0) {
       StringBuilder msg = new StringBuilder();
-      msg.append("Ambiguous external ID ").append(externalId).append("for accounts: ");
+      msg.append("Ambiguous external ID ").append(externalId).append(" for accounts: ");
       Joiner.on(", ")
           .appendTo(msg, Lists.transform(accountStates, AccountState.ACCOUNT_ID_FUNCTION));
       log.warn(msg.toString());
@@ -106,7 +119,86 @@
     return query(AccountPredicates.fullName(fullName));
   }
 
+  /**
+   * Queries for accounts that have a preferred email that exactly matches the given email.
+   *
+   * @param email preferred email by which accounts should be found
+   * @return list of accounts that have a preferred email that exactly matches the given email
+   * @throws OrmException if query cannot be parsed
+   */
+  public List<AccountState> byPreferredEmail(String email) throws OrmException {
+    if (hasPreferredEmailExact()) {
+      return query(AccountPredicates.preferredEmailExact(email));
+    }
+
+    if (!hasPreferredEmail()) {
+      return ImmutableList.of();
+    }
+
+    return query(AccountPredicates.preferredEmail(email))
+        .stream()
+        .filter(a -> a.getAccount().getPreferredEmail().equals(email))
+        .collect(toList());
+  }
+
+  /**
+   * Makes multiple queries for accounts by preferred email (exact match).
+   *
+   * @param emails preferred emails by which accounts should be found
+   * @return multimap of the given emails to accounts that have a preferred email that exactly
+   *     matches this email
+   * @throws OrmException if query cannot be parsed
+   */
+  public Multimap<String, AccountState> byPreferredEmail(String... emails) throws OrmException {
+    List<String> emailList = Arrays.asList(emails);
+
+    if (hasPreferredEmailExact()) {
+      List<List<AccountState>> r =
+          query(
+              emailList
+                  .stream()
+                  .map(e -> AccountPredicates.preferredEmailExact(e))
+                  .collect(toList()));
+      Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
+      for (int i = 0; i < emailList.size(); i++) {
+        accountsByEmail.putAll(emailList.get(i), r.get(i));
+      }
+      return accountsByEmail;
+    }
+
+    if (!hasPreferredEmail()) {
+      return ImmutableListMultimap.of();
+    }
+
+    List<List<AccountState>> r =
+        query(emailList.stream().map(e -> AccountPredicates.preferredEmail(e)).collect(toList()));
+    Multimap<String, AccountState> accountsByEmail = ArrayListMultimap.create();
+    for (int i = 0; i < emailList.size(); i++) {
+      String email = emailList.get(i);
+      Set<AccountState> matchingAccounts =
+          r.get(i)
+              .stream()
+              .filter(a -> a.getAccount().getPreferredEmail().equals(email))
+              .collect(toSet());
+      accountsByEmail.putAll(email, matchingAccounts);
+    }
+    return accountsByEmail;
+  }
+
   public List<AccountState> byWatchedProject(Project.NameKey project) throws OrmException {
     return query(AccountPredicates.watchedProject(project));
   }
+
+  private boolean hasField(FieldDef<AccountState, ?> field) {
+    Schema<AccountState> s = schema();
+    return (s != null && s.hasField(field));
+  }
+
+  private boolean hasPreferredEmail() {
+    return hasField(AccountField.PREFERRED_EMAIL);
+  }
+
+  private boolean hasPreferredEmailExact() {
+    return hasField(AccountField.PREFERRED_EMAIL_EXACT);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
index b3cdd6a..099e841 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AddedPredicate.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 public class AddedPredicate extends IntegerRangeChangePredicate {
-  AddedPredicate(String value) throws QueryParseException {
+  public AddedPredicate(String value) throws QueryParseException {
     super(ChangeField.ADDED, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) throws OrmException {
-    return ChangeField.ADDED.get(changeData, null);
+    return ChangeField.ADDED.get(changeData);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
index 7d51217..de57b3b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AfterPredicate.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 import java.util.Date;
 
 public class AfterPredicate extends TimestampRangeChangePredicate {
-  private final Date cut;
+  protected final Date cut;
 
-  AfterPredicate(String value) throws QueryParseException {
+  public AfterPredicate(String value) throws QueryParseException {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
     cut = parse(value);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
index 0cd76bb..6310665 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AgePredicate.java
@@ -25,9 +25,9 @@
 import java.sql.Timestamp;
 
 public class AgePredicate extends TimestampRangeChangePredicate {
-  private final long cut;
+  protected final long cut;
 
-  AgePredicate(String value) {
+  public AgePredicate(String value) {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_AGE, value);
 
     long s = ConfigUtil.getTimeUnit(getValue(), 0, SECONDS);
@@ -46,7 +46,7 @@
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     Change change = object.change();
     return change != null && change.getLastUpdatedOn().getTime() <= cut;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
index b0fcfd1..ff1ab23 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AndChangeSource.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.query.AndSource;
-import com.google.gerrit.server.query.IsVisibleToPredicate;
-import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.index.query.AndSource;
+import com.google.gerrit.index.query.IsVisibleToPredicate;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.OrmRuntimeException;
 import java.util.Collection;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
index 38622ed..63f7467 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AssigneePredicate.java
@@ -18,16 +18,16 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class AssigneePredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class AssigneePredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  AssigneePredicate(Account.Id id) {
+  public AssigneePredicate(Account.Id id) {
     super(ChangeField.ASSIGNEE, id.toString());
     this.id = id;
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     if (id.get() == ChangeField.NO_ASSIGNEE) {
       Account.Id assignee = object.change().getAssignee();
       return assignee == null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
index dccd17e..3ee3352 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/AuthorPredicate.java
@@ -22,7 +22,7 @@
 import java.io.IOException;
 
 public class AuthorPredicate extends ChangeIndexPredicate {
-  AuthorPredicate(String value) {
+  public AuthorPredicate(String value) {
     super(AUTHOR, FIELD_AUTHOR, value.toLowerCase());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
index 9e443c9..4d6ed69 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BeforePredicate.java
@@ -14,15 +14,15 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 import java.util.Date;
 
 public class BeforePredicate extends TimestampRangeChangePredicate {
-  private final Date cut;
+  protected final Date cut;
 
-  BeforePredicate(String value) throws QueryParseException {
+  public BeforePredicate(String value) throws QueryParseException {
     super(ChangeField.UPDATED, ChangeQueryBuilder.FIELD_BEFORE, value);
     cut = parse(value);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
new file mode 100644
index 0000000..5930b74
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/BooleanPredicate.java
@@ -0,0 +1,34 @@
+// Copyright (C) 2013 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.index.FieldDef;
+import com.google.gwtorm.server.OrmException;
+
+public class BooleanPredicate extends ChangeIndexPredicate {
+  public BooleanPredicate(FieldDef<ChangeData, String> field) {
+    super(field, "1");
+  }
+
+  @Override
+  public boolean match(ChangeData object) throws OrmException {
+    return getValue().equals(getField().get(object));
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
index 83e2a7a..fc99201 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeData.java
@@ -15,11 +15,9 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.base.Preconditions.checkNotNull;
-import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.ApprovalsUtil.sortApprovals;
 import static java.util.stream.Collectors.toList;
 import static java.util.stream.Collectors.toMap;
-import static java.util.stream.Collectors.toSet;
 
 import com.google.auto.value.AutoValue;
 import com.google.common.base.MoreObjects;
@@ -33,8 +31,12 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Maps;
 import com.google.gerrit.common.Nullable;
+import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.extensions.client.DiffPreferencesInfo.Whitespace;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
@@ -45,34 +47,39 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.ReviewerByEmailSet;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.ReviewerStatusUpdate;
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.StarredChangesUtil.StarRef;
+import com.google.gerrit.server.change.GetPureRevert;
 import com.google.gerrit.server.change.MergeabilityCache;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.MergeUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffSummary;
+import com.google.gerrit.server.patch.DiffSummaryKey;
 import com.google.gerrit.server.patch.PatchListCache;
+import com.google.gerrit.server.patch.PatchListKey;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
 import com.google.gerrit.server.project.SubmitRuleOptions;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -84,7 +91,6 @@
 import java.util.Map;
 import java.util.Optional;
 import java.util.Set;
-import java.util.function.Predicate;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.IncorrectObjectTypeException;
 import org.eclipse.jgit.errors.MissingObjectException;
@@ -273,17 +279,35 @@
     }
   }
 
-  public interface Factory {
-    ChangeData create(ReviewDb db, Project.NameKey project, Change.Id id);
+  public static class Factory {
+    private final AssistedFactory assistedFactory;
 
-    ChangeData create(ReviewDb db, Change c);
+    @Inject
+    Factory(AssistedFactory assistedFactory) {
+      this.assistedFactory = assistedFactory;
+    }
 
-    ChangeData create(ReviewDb db, ChangeNotes cn);
+    public ChangeData create(ReviewDb db, Project.NameKey project, Change.Id id) {
+      return assistedFactory.create(db, project, id, null, null);
+    }
 
-    ChangeData create(ReviewDb db, ChangeControl c);
+    public ChangeData create(ReviewDb db, Change change) {
+      return assistedFactory.create(db, change.getProject(), change.getId(), change, null);
+    }
 
-    // TODO(dborowitz): Remove when deleting index schemas <27.
-    ChangeData createOnlyWhenNoteDbDisabled(ReviewDb db, Change.Id id);
+    public ChangeData create(ReviewDb db, ChangeNotes notes) {
+      return assistedFactory.create(
+          db, notes.getChange().getProject(), notes.getChangeId(), notes.getChange(), notes);
+    }
+  }
+
+  public interface AssistedFactory {
+    ChangeData create(
+        ReviewDb db,
+        Project.NameKey project,
+        Change.Id id,
+        @Nullable Change change,
+        @Nullable ChangeNotes notes);
   }
 
   /**
@@ -300,32 +324,41 @@
     ChangeData cd =
         new ChangeData(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, project, id);
+            null, null, null, null, project, id, null, null);
     cd.currentPatchSet = new PatchSet(new PatchSet.Id(id, currentPatchSetId));
     return cd;
   }
 
-  private boolean lazyLoad = true;
-  private final ReviewDb db;
-  private final GitRepositoryManager repoManager;
-  private final ChangeControl.GenericFactory changeControlFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final ProjectCache projectCache;
-  private final MergeUtil.Factory mergeUtilFactory;
-  private final ChangeNotes.Factory notesFactory;
+  // Injected fields.
+  private @Nullable final StarredChangesUtil starredChangesUtil;
+  private final AllUsersName allUsersName;
   private final ApprovalsUtil approvalsUtil;
   private final ChangeMessagesUtil cmUtil;
+  private final ChangeNotes.Factory notesFactory;
   private final CommentsUtil commentsUtil;
-  private final PatchSetUtil psUtil;
-  private final PatchListCache patchListCache;
-  private final NotesMigration notesMigration;
+  private final GitRepositoryManager repoManager;
+  private final IdentifiedUser.GenericFactory userFactory;
+  private final MergeUtil.Factory mergeUtilFactory;
   private final MergeabilityCache mergeabilityCache;
-  private final StarredChangesUtil starredChangesUtil;
+  private final NotesMigration notesMigration;
+  private final PatchListCache patchListCache;
+  private final PatchSetUtil psUtil;
+  private final ProjectCache projectCache;
+  private final TrackingFooters trackingFooters;
+  private final GetPureRevert pureRevert;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
+
+  // Required assisted injected fields.
+  private final ReviewDb db;
+  private final Project.NameKey project;
   private final Change.Id legacyId;
+
+  // Lazily populated fields, including optional assisted injected fields.
+
   private final Map<SubmitRuleOptions, List<SubmitRecord>> submitRecords =
       Maps.newLinkedHashMapWithExpectedSize(1);
 
-  private Project.NameKey project;
+  private boolean lazyLoad = true;
   private Change change;
   private ChangeNotes notes;
   private String commitMessage;
@@ -334,12 +367,11 @@
   private Collection<PatchSet> patchSets;
   private ListMultimap<PatchSet.Id, PatchSetApproval> allApprovals;
   private List<PatchSetApproval> currentApprovals;
-  private Map<Integer, List<String>> files;
-  private Map<Integer, Optional<DiffSummary>> diffSummaries;
+  private List<String> currentFiles;
+  private Optional<DiffSummary> diffSummary;
   private Collection<Comment> publishedComments;
   private Collection<RobotComment> robotComments;
   private CurrentUser visibleTo;
-  private ChangeControl changeControl;
   private List<ChangeMessage> messages;
   private Optional<ChangedLines> changedLines;
   private SubmitTypeRecord submitTypeRecord;
@@ -352,207 +384,71 @@
   private StarsOf starsOf;
   private ImmutableMap<Account.Id, StarRef> starRefs;
   private ReviewerSet reviewers;
+  private ReviewerByEmailSet reviewersByEmail;
+  private ReviewerSet pendingReviewers;
+  private ReviewerByEmailSet pendingReviewersByEmail;
   private List<ReviewerStatusUpdate> reviewerUpdates;
   private PersonIdent author;
   private PersonIdent committer;
+  private int parentCount;
   private Integer unresolvedCommentCount;
+  private LabelTypes labelTypes;
 
   private ImmutableList<byte[]> refStates;
   private ImmutableList<byte[]> refStatePatterns;
 
-  @AssistedInject
+  @Inject
   private ChangeData(
-      GitRepositoryManager repoManager,
-      ChangeControl.GenericFactory changeControlFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache,
-      NotesMigration notesMigration,
-      MergeabilityCache mergeabilityCache,
       @Nullable StarredChangesUtil starredChangesUtil,
+      ApprovalsUtil approvalsUtil,
+      AllUsersName allUsersName,
+      ChangeMessagesUtil cmUtil,
+      ChangeNotes.Factory notesFactory,
+      CommentsUtil commentsUtil,
+      GitRepositoryManager repoManager,
+      IdentifiedUser.GenericFactory userFactory,
+      MergeUtil.Factory mergeUtilFactory,
+      MergeabilityCache mergeabilityCache,
+      NotesMigration notesMigration,
+      PatchListCache patchListCache,
+      PatchSetUtil psUtil,
+      ProjectCache projectCache,
+      TrackingFooters trackingFooters,
+      GetPureRevert pureRevert,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory,
       @Assisted ReviewDb db,
       @Assisted Project.NameKey project,
-      @Assisted Change.Id id) {
-    this.db = db;
-    this.repoManager = repoManager;
-    this.changeControlFactory = changeControlFactory;
-    this.userFactory = userFactory;
-    this.projectCache = projectCache;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.notesFactory = notesFactory;
+      @Assisted Change.Id id,
+      @Assisted @Nullable Change change,
+      @Assisted @Nullable ChangeNotes notes) {
     this.approvalsUtil = approvalsUtil;
+    this.allUsersName = allUsersName;
     this.cmUtil = cmUtil;
+    this.notesFactory = notesFactory;
     this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
-    this.notesMigration = notesMigration;
+    this.repoManager = repoManager;
+    this.userFactory = userFactory;
+    this.mergeUtilFactory = mergeUtilFactory;
     this.mergeabilityCache = mergeabilityCache;
+    this.notesMigration = notesMigration;
+    this.patchListCache = patchListCache;
+    this.psUtil = psUtil;
+    this.projectCache = projectCache;
     this.starredChangesUtil = starredChangesUtil;
+    this.trackingFooters = trackingFooters;
+    this.pureRevert = pureRevert;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
+
+    // May be null in tests when created via createForTest above, in which case lazy-loading will
+    // intentionally fail with NPE. Still not marked @Nullable in the constructor, to force callers
+    // using Guice to pass a non-null value.
+    this.db = db;
+
     this.project = project;
     this.legacyId = id;
-  }
 
-  @AssistedInject
-  private ChangeData(
-      GitRepositoryManager repoManager,
-      ChangeControl.GenericFactory changeControlFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache,
-      NotesMigration notesMigration,
-      MergeabilityCache mergeabilityCache,
-      @Nullable StarredChangesUtil starredChangesUtil,
-      @Assisted ReviewDb db,
-      @Assisted Change c) {
-    this.db = db;
-    this.repoManager = repoManager;
-    this.changeControlFactory = changeControlFactory;
-    this.userFactory = userFactory;
-    this.projectCache = projectCache;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.notesFactory = notesFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
-    this.notesMigration = notesMigration;
-    this.mergeabilityCache = mergeabilityCache;
-    this.starredChangesUtil = starredChangesUtil;
-    legacyId = c.getId();
-    change = c;
-    project = c.getProject();
-  }
-
-  @AssistedInject
-  private ChangeData(
-      GitRepositoryManager repoManager,
-      ChangeControl.GenericFactory changeControlFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache,
-      NotesMigration notesMigration,
-      MergeabilityCache mergeabilityCache,
-      @Nullable StarredChangesUtil starredChangesUtil,
-      @Assisted ReviewDb db,
-      @Assisted ChangeNotes cn) {
-    this.db = db;
-    this.repoManager = repoManager;
-    this.changeControlFactory = changeControlFactory;
-    this.userFactory = userFactory;
-    this.projectCache = projectCache;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.notesFactory = notesFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
-    this.notesMigration = notesMigration;
-    this.mergeabilityCache = mergeabilityCache;
-    this.starredChangesUtil = starredChangesUtil;
-    legacyId = cn.getChangeId();
-    change = cn.getChange();
-    project = cn.getProjectName();
-    notes = cn;
-  }
-
-  @AssistedInject
-  private ChangeData(
-      GitRepositoryManager repoManager,
-      ChangeControl.GenericFactory changeControlFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache,
-      NotesMigration notesMigration,
-      MergeabilityCache mergeabilityCache,
-      @Nullable StarredChangesUtil starredChangesUtil,
-      @Assisted ReviewDb db,
-      @Assisted ChangeControl c) {
-    this.db = db;
-    this.repoManager = repoManager;
-    this.changeControlFactory = changeControlFactory;
-    this.userFactory = userFactory;
-    this.projectCache = projectCache;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.notesFactory = notesFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
-    this.notesMigration = notesMigration;
-    this.mergeabilityCache = mergeabilityCache;
-    this.starredChangesUtil = starredChangesUtil;
-    legacyId = c.getId();
-    change = c.getChange();
-    changeControl = c;
-    notes = c.getNotes();
-    project = notes.getProjectName();
-  }
-
-  @AssistedInject
-  private ChangeData(
-      GitRepositoryManager repoManager,
-      ChangeControl.GenericFactory changeControlFactory,
-      IdentifiedUser.GenericFactory userFactory,
-      ProjectCache projectCache,
-      MergeUtil.Factory mergeUtilFactory,
-      ChangeNotes.Factory notesFactory,
-      ApprovalsUtil approvalsUtil,
-      ChangeMessagesUtil cmUtil,
-      CommentsUtil commentsUtil,
-      PatchSetUtil psUtil,
-      PatchListCache patchListCache,
-      NotesMigration notesMigration,
-      MergeabilityCache mergeabilityCache,
-      @Nullable StarredChangesUtil starredChangesUtil,
-      @Assisted ReviewDb db,
-      @Assisted Change.Id id) {
-    checkState(
-        !notesMigration.readChanges(),
-        "do not call createOnlyWhenNoteDbDisabled when NoteDb is enabled");
-    this.db = db;
-    this.repoManager = repoManager;
-    this.changeControlFactory = changeControlFactory;
-    this.userFactory = userFactory;
-    this.projectCache = projectCache;
-    this.mergeUtilFactory = mergeUtilFactory;
-    this.notesFactory = notesFactory;
-    this.approvalsUtil = approvalsUtil;
-    this.cmUtil = cmUtil;
-    this.commentsUtil = commentsUtil;
-    this.psUtil = psUtil;
-    this.patchListCache = patchListCache;
-    this.notesMigration = notesMigration;
-    this.mergeabilityCache = mergeabilityCache;
-    this.starredChangesUtil = starredChangesUtil;
-    this.legacyId = id;
-    this.project = null;
+    this.change = change;
+    this.notes = notes;
   }
 
   public ChangeData setLazyLoad(boolean load) {
@@ -564,86 +460,65 @@
     return db;
   }
 
-  private Map<Integer, List<String>> initFiles() {
-    if (files == null) {
-      files = new HashMap<>();
-    }
-    return files;
+  public AllUsersName getAllUsersNameForIndexing() {
+    return allUsersName;
   }
 
   public void setCurrentFilePaths(List<String> filePaths) throws OrmException {
     PatchSet ps = currentPatchSet();
     if (ps != null) {
-      initFiles().put(ps.getPatchSetId(), ImmutableList.copyOf(filePaths));
+      currentFiles = ImmutableList.copyOf(filePaths);
     }
   }
 
-  public List<String> currentFilePaths() throws OrmException {
-    PatchSet ps = currentPatchSet();
-    return ps != null ? filePaths(ps) : null;
-  }
-
-  public List<String> filePaths(PatchSet ps) throws OrmException {
-    Integer psId = ps.getPatchSetId();
-    List<String> r = initFiles().get(psId);
-    if (r == null) {
-      Change c = change();
-      if (c == null) {
-        return null;
+  public List<String> currentFilePaths() throws IOException, OrmException {
+    if (currentFiles == null) {
+      if (!lazyLoad) {
+        return Collections.emptyList();
       }
-
-      Optional<DiffSummary> p = getDiffSummary(c, ps);
-      if (!p.isPresent()) {
-        List<String> emptyFileList = Collections.emptyList();
-        if (lazyLoad) {
-          files.put(ps.getPatchSetId(), emptyFileList);
-        }
-        return emptyFileList;
-      }
-
-      r = p.get().getPaths();
-      files.put(psId, r);
+      Optional<DiffSummary> p = getDiffSummary();
+      currentFiles = p.map(DiffSummary::getPaths).orElse(Collections.emptyList());
     }
-    return r;
+    return currentFiles;
   }
 
-  private Optional<DiffSummary> getDiffSummary(Change c, PatchSet ps) {
-    Integer psId = ps.getId().get();
-    if (diffSummaries == null) {
-      diffSummaries = new HashMap<>();
-    }
-    Optional<DiffSummary> r = diffSummaries.get(psId);
-    if (r == null) {
+  private Optional<DiffSummary> getDiffSummary() throws OrmException, IOException {
+    if (diffSummary == null) {
       if (!lazyLoad) {
         return Optional.empty();
       }
-      try {
-        r = Optional.of(patchListCache.getDiffSummary(c, ps));
-      } catch (PatchListNotAvailableException e) {
-        r = Optional.empty();
+
+      Change c = change();
+      PatchSet ps = currentPatchSet();
+      if (c == null || ps == null || !loadCommitData()) {
+        return Optional.empty();
       }
-      diffSummaries.put(psId, r);
+
+      ObjectId id = ObjectId.fromString(ps.getRevision().get());
+      Whitespace ws = Whitespace.IGNORE_NONE;
+      PatchListKey pk =
+          parentCount > 1
+              ? PatchListKey.againstParentNum(1, id, ws)
+              : PatchListKey.againstDefaultBase(id, ws);
+      DiffSummaryKey key = DiffSummaryKey.fromPatchListKey(pk);
+      try {
+        diffSummary = Optional.of(patchListCache.getDiffSummary(key, c.getProject()));
+      } catch (PatchListNotAvailableException e) {
+        diffSummary = Optional.empty();
+      }
     }
-    return r;
+    return diffSummary;
   }
 
-  private Optional<ChangedLines> computeChangedLines() throws OrmException {
-    Change c = change();
-    if (c == null) {
-      return Optional.empty();
-    }
-    PatchSet ps = currentPatchSet();
-    if (ps == null) {
-      return Optional.empty();
-    }
-    Optional<DiffSummary> ds = getDiffSummary(c, ps);
+  private Optional<ChangedLines> computeChangedLines() throws OrmException, IOException {
+    Optional<DiffSummary> ds = getDiffSummary();
     if (ds.isPresent()) {
       return Optional.of(ds.get().getChangedLines());
     }
     return Optional.empty();
   }
 
-  public Optional<ChangedLines> changedLines() throws OrmException {
+  public Optional<ChangedLines> changedLines() throws OrmException, IOException {
     if (changedLines == null) {
       if (!lazyLoad) {
         return Optional.empty();
@@ -665,13 +540,7 @@
     return legacyId;
   }
 
-  public Project.NameKey project() throws OrmException {
-    if (project == null) {
-      checkState(
-          !notesMigration.readChanges(),
-          "should not have created  ChangeData without a project when NoteDb is enabled");
-      project = change().getProject();
-    }
+  public Project.NameKey project() {
     return project;
   }
 
@@ -679,58 +548,8 @@
     return visibleTo == user;
   }
 
-  public boolean hasChangeControl() {
-    return changeControl != null;
-  }
-
-  public ChangeControl changeControl() throws OrmException {
-    if (changeControl == null) {
-      Change c = change();
-      try {
-        changeControl = changeControlFactory.controlFor(db, c, userFactory.create(c.getOwner()));
-      } catch (NoSuchChangeException e) {
-        throw new OrmException(e);
-      }
-    }
-    return changeControl;
-  }
-
-  public ChangeControl changeControl(CurrentUser user) throws OrmException {
-    if (changeControl != null) {
-      CurrentUser oldUser = user;
-      if (sameUser(user, oldUser)) {
-        return changeControl;
-      }
-      throw new IllegalStateException("user already specified: " + changeControl.getUser());
-    }
-    try {
-      if (change != null) {
-        changeControl = changeControlFactory.controlFor(db, change, user);
-      } else {
-        changeControl = changeControlFactory.controlFor(db, project(), legacyId, user);
-      }
-    } catch (NoSuchChangeException e) {
-      throw new OrmException(e);
-    }
-    return changeControl;
-  }
-
-  private static boolean sameUser(CurrentUser a, CurrentUser b) {
-    // TODO(dborowitz): This is a hack; general CurrentUser equality would be
-    // better.
-    if (a.isInternalUser() && b.isInternalUser()) {
-      return true;
-    } else if (a instanceof AnonymousUser && b instanceof AnonymousUser) {
-      return true;
-    } else if (a.isIdentifiedUser() && b.isIdentifiedUser()) {
-      return a.getAccountId().equals(b.getAccountId());
-    }
-    return false;
-  }
-
-  void cacheVisibleTo(ChangeControl ctl) {
-    visibleTo = ctl.getUser();
-    changeControl = ctl;
+  void cacheVisibleTo(CurrentUser user) {
+    visibleTo = user;
   }
 
   public Change change() throws OrmException {
@@ -755,6 +574,19 @@
     return change;
   }
 
+  public LabelTypes getLabelTypes() throws OrmException {
+    if (labelTypes == null) {
+      ProjectState state;
+      try {
+        state = projectCache.checkedGet(project());
+      } catch (IOException e) {
+        throw new OrmException("project state not available", e);
+      }
+      labelTypes = state.getLabelTypes(change().getDest(), userFactory.create(change().getOwner()));
+    }
+    return labelTypes;
+  }
+
   public ChangeNotes notes() throws OrmException {
     if (notes == null) {
       if (!lazyLoad) {
@@ -793,7 +625,13 @@
         try {
           currentApprovals =
               ImmutableList.copyOf(
-                  approvalsUtil.byPatchSet(db, changeControl(), c.currentPatchSetId()));
+                  approvalsUtil.byPatchSet(
+                      db,
+                      notes(),
+                      userFactory.create(c.getOwner()),
+                      c.currentPatchSetId(),
+                      null,
+                      null));
         } catch (OrmException e) {
           if (e.getCause() instanceof NoSuchChangeException) {
             currentApprovals = Collections.emptyList();
@@ -828,6 +666,10 @@
     return commitFooters;
   }
 
+  public ListMultimap<String, String> trackingFooters() throws IOException, OrmException {
+    return trackingFooters.extract(commitFooters());
+  }
+
   public PersonIdent getAuthor() throws IOException, OrmException {
     if (author == null) {
       if (!loadCommitData()) {
@@ -861,6 +703,7 @@
       commitFooters = c.getFooterLines();
       author = c.getAuthorIdent();
       committer = c.getCommitterIdent();
+      parentCount = c.getParentCount();
     }
     return true;
   }
@@ -876,22 +719,6 @@
     return patchSets;
   }
 
-  /**
-   * @return patches for the change visible to the current user.
-   * @throws OrmException an error occurred reading the database.
-   */
-  public Collection<PatchSet> visiblePatchSets() throws OrmException {
-    Predicate<? super PatchSet> predicate =
-        ps -> {
-          try {
-            return changeControl().isPatchVisible(ps, db);
-          } catch (OrmException e) {
-            return false;
-          }
-        };
-    return patchSets().stream().filter(predicate).collect(toList());
-  }
-
   public void setPatchSets(Collection<PatchSet> patchSets) {
     this.currentPatchSet = null;
     this.patchSets = patchSets;
@@ -954,6 +781,60 @@
     return reviewers;
   }
 
+  public ReviewerByEmailSet reviewersByEmail() throws OrmException {
+    if (reviewersByEmail == null) {
+      if (!lazyLoad) {
+        return ReviewerByEmailSet.empty();
+      }
+      reviewersByEmail = notes().getReviewersByEmail();
+    }
+    return reviewersByEmail;
+  }
+
+  public void setReviewersByEmail(ReviewerByEmailSet reviewersByEmail) {
+    this.reviewersByEmail = reviewersByEmail;
+  }
+
+  public ReviewerByEmailSet getReviewersByEmail() {
+    return reviewersByEmail;
+  }
+
+  public void setPendingReviewers(ReviewerSet pendingReviewers) {
+    this.pendingReviewers = pendingReviewers;
+  }
+
+  public ReviewerSet getPendingReviewers() {
+    return this.pendingReviewers;
+  }
+
+  public ReviewerSet pendingReviewers() throws OrmException {
+    if (pendingReviewers == null) {
+      if (!lazyLoad) {
+        return ReviewerSet.empty();
+      }
+      pendingReviewers = notes().getPendingReviewers();
+    }
+    return pendingReviewers;
+  }
+
+  public void setPendingReviewersByEmail(ReviewerByEmailSet pendingReviewersByEmail) {
+    this.pendingReviewersByEmail = pendingReviewersByEmail;
+  }
+
+  public ReviewerByEmailSet getPendingReviewersByEmail() {
+    return pendingReviewersByEmail;
+  }
+
+  public ReviewerByEmailSet pendingReviewersByEmail() throws OrmException {
+    if (pendingReviewersByEmail == null) {
+      if (!lazyLoad) {
+        return ReviewerByEmailSet.empty();
+      }
+      pendingReviewersByEmail = notes().getPendingReviewersByEmail();
+    }
+    return pendingReviewersByEmail;
+  }
+
   public List<ReviewerStatusUpdate> reviewerUpdates() throws OrmException {
     if (reviewerUpdates == null) {
       if (!lazyLoad) {
@@ -1000,15 +881,48 @@
 
       List<Comment> comments =
           Stream.concat(publishedComments().stream(), robotComments().stream()).collect(toList());
-      Set<String> nonLeafSet = comments.stream().map(c -> c.parentUuid).collect(toSet());
 
-      Long count =
-          comments.stream().filter(c -> (c.unresolved && !nonLeafSet.contains(c.key.uuid))).count();
-      unresolvedCommentCount = count.intValue();
+      // Build a map of uuid to list of direct descendants.
+      Map<String, List<Comment>> forest = new HashMap<>();
+      for (Comment comment : comments) {
+        List<Comment> siblings = forest.get(comment.parentUuid);
+        if (siblings == null) {
+          siblings = new ArrayList<>();
+          forest.put(comment.parentUuid, siblings);
+        }
+        siblings.add(comment);
+      }
+
+      // Find latest comment in each thread and apply to unresolved counter.
+      int unresolved = 0;
+      if (forest.containsKey(null)) {
+        for (Comment root : forest.get(null)) {
+          if (getLatestComment(forest, root).unresolved) {
+            unresolved++;
+          }
+        }
+      }
+      unresolvedCommentCount = unresolved;
     }
+
     return unresolvedCommentCount;
   }
 
+  protected Comment getLatestComment(Map<String, List<Comment>> forest, Comment root) {
+    List<Comment> children = forest.get(root.key.uuid);
+    if (children == null) {
+      return root;
+    }
+    Comment latest = null;
+    for (Comment comment : children) {
+      Comment branchLatest = getLatestComment(forest, comment);
+      if (latest == null || branchLatest.writtenOn.after(latest.writtenOn)) {
+        latest = branchLatest;
+      }
+    }
+    return latest;
+  }
+
   public void setUnresolvedCommentCount(Integer count) {
     this.unresolvedCommentCount = count;
   }
@@ -1029,7 +943,11 @@
       if (!lazyLoad) {
         return Collections.emptyList();
       }
-      records = new SubmitRuleEvaluator(this).setOptions(options).evaluate();
+      records =
+          submitRuleEvaluatorFactory
+              .create(userFactory.create(change().getOwner()), this)
+              .setOptions(options)
+              .evaluate();
       submitRecords.put(options, records);
     }
     return records;
@@ -1046,7 +964,10 @@
 
   public SubmitTypeRecord submitTypeRecord() throws OrmException {
     if (submitTypeRecord == null) {
-      submitTypeRecord = new SubmitRuleEvaluator(this).getSubmitType();
+      submitTypeRecord =
+          submitRuleEvaluatorFactory
+              .create(userFactory.create(change().getOwner()), this)
+              .getSubmitType();
     }
     return submitTypeRecord;
   }
@@ -1065,21 +986,15 @@
         mergeable = true;
       } else if (c.getStatus() == Change.Status.ABANDONED) {
         return null;
+      } else if (c.isWorkInProgress()) {
+        return null;
       } else {
         if (!lazyLoad) {
           return null;
         }
         PatchSet ps = currentPatchSet();
-        try {
-          if (ps == null
-              || (!changeControl().isOwner() && !changeControl().isPatchVisible(ps, db))) {
-            return null;
-          }
-        } catch (OrmException e) {
-          if (e.getCause() instanceof NoSuchChangeException) {
-            return null;
-          }
-          throw e;
+        if (ps == null) {
+          return null;
         }
 
         try (Repository repo = repoManager.openRepository(project())) {
@@ -1175,6 +1090,23 @@
     return draftsByUser;
   }
 
+  public boolean isReviewedBy(Account.Id accountId) throws OrmException {
+    Collection<String> stars = stars(accountId);
+
+    PatchSet ps = currentPatchSet();
+    if (ps != null) {
+      if (stars.contains(StarredChangesUtil.REVIEWED_LABEL + "/" + ps.getPatchSetId())) {
+        return true;
+      }
+
+      if (stars.contains(StarredChangesUtil.UNREVIEWED_LABEL + "/" + ps.getPatchSetId())) {
+        return false;
+      }
+    }
+
+    return reviewedBy().contains(accountId);
+  }
+
   public Set<Account.Id> reviewedBy() throws OrmException {
     if (reviewedBy == null) {
       if (!lazyLoad) {
@@ -1268,6 +1200,22 @@
     return starsOf.stars();
   }
 
+  /**
+   * @return {@code null} if {@code revertOf} is {@code null}; true if the change is a pure revert;
+   *     false otherwise.
+   */
+  @Nullable
+  public Boolean isPureRevert() throws OrmException {
+    if (change().getRevertOf() == null) {
+      return null;
+    }
+    try {
+      return pureRevert.getPureRevert(notes()).isPureRevert;
+    } catch (IOException | BadRequestException | ResourceConflictException e) {
+      throw new OrmException("could not compute pure revert", e);
+    }
+  }
+
   @Override
   public String toString() {
     MoreObjects.ToStringHelper h = MoreObjects.toStringHelper(this);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java
index c32ff0d..34579a9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeDataSource.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.query.DataSource;
+import com.google.gerrit.index.query.DataSource;
 
 public interface ChangeDataSource extends DataSource<ChangeData> {
   /** @return true if all returned ChangeData.hasChange() will be true. */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
index 85d433a..d541d18 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIdPredicate.java
@@ -19,8 +19,8 @@
 import com.google.gwtorm.server.OrmException;
 
 /** Predicate over Change-Id strings (aka Change.Key). */
-class ChangeIdPredicate extends ChangeIndexPredicate {
-  ChangeIdPredicate(String id) {
+public class ChangeIdPredicate extends ChangeIndexPredicate {
+  public ChangeIdPredicate(String id) {
     super(ChangeField.ID, ChangeQueryBuilder.FIELD_CHANGE, id);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
index 0604f8b..1eb2770 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIndexPredicate.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Matchable;
 
 public abstract class ChangeIndexPredicate extends IndexPredicate<ChangeData>
     implements Matchable<ChangeData> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
index 8db62a7..3ed7e0c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeIsVisibleToPredicate.java
@@ -14,53 +14,72 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.query.IsVisibleToPredicate;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
+import org.eclipse.jgit.errors.RepositoryNotFoundException;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
-  private final Provider<ReviewDb> db;
-  private final ChangeNotes.Factory notesFactory;
-  private final ChangeControl.GenericFactory changeControl;
-  private final CurrentUser user;
+public class ChangeIsVisibleToPredicate extends IsVisibleToPredicate<ChangeData> {
+  private static final Logger log = LoggerFactory.getLogger(ChangeIsVisibleToPredicate.class);
 
-  ChangeIsVisibleToPredicate(
+  protected final Provider<ReviewDb> db;
+  protected final ChangeNotes.Factory notesFactory;
+  protected final CurrentUser user;
+  protected final PermissionBackend permissionBackend;
+
+  public ChangeIsVisibleToPredicate(
       Provider<ReviewDb> db,
       ChangeNotes.Factory notesFactory,
-      ChangeControl.GenericFactory changeControlFactory,
-      CurrentUser user) {
-    super(ChangeQueryBuilder.FIELD_VISIBLETO, describe(user));
+      CurrentUser user,
+      PermissionBackend permissionBackend) {
+    super(ChangeQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(user));
     this.db = db;
     this.notesFactory = notesFactory;
-    this.changeControl = changeControlFactory;
     this.user = user;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
-  public boolean match(final ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) throws OrmException {
     if (cd.fastIsVisibleTo(user)) {
       return true;
     }
+    Change change = cd.change();
+    if (change == null) {
+      return false;
+    }
+
+    ChangeNotes notes = notesFactory.createFromIndexedChange(change);
+    boolean visible;
     try {
-      Change c = cd.change();
-      if (c == null) {
+      visible =
+          permissionBackend
+              .user(user)
+              .indexedChange(cd, notes)
+              .database(db)
+              .test(ChangePermission.READ);
+    } catch (PermissionBackendException e) {
+      Throwable cause = e.getCause();
+      if (cause instanceof RepositoryNotFoundException) {
+        log.warn(
+            "Skipping change {} because the corresponding repository was not found", cd.getId(), e);
         return false;
       }
-
-      ChangeNotes notes = notesFactory.createFromIndexedChange(c);
-      ChangeControl cc = changeControl.controlFor(notes, user);
-      if (cc.isVisible(db.get(), cd)) {
-        cd.cacheVisibleTo(cc);
-        return true;
-      }
-    } catch (NoSuchChangeException e) {
-      // Ignored
+      throw new OrmException("unable to check permissions on change " + cd.getId(), e);
+    }
+    if (visible) {
+      cd.cacheVisibleTo(user);
+      return true;
     }
     return false;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
index 242592e..8b08536 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeOperatorPredicate.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.query.Matchable;
-import com.google.gerrit.server.query.OperatorPredicate;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.OperatorPredicate;
 
 public abstract class ChangeOperatorPredicate extends OperatorPredicate<ChangeData>
     implements Matchable<ChangeData> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 63ec45c..7f4b906 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -30,6 +30,14 @@
 import com.google.gerrit.common.data.SubmitRecord;
 import com.google.gerrit.common.errors.NotSignedInException;
 import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaUtil;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryRequiresAuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -42,7 +50,6 @@
 import com.google.gerrit.server.StarredChangesUtil;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountResolver;
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.VersionedAccountDestinations;
@@ -50,46 +57,40 @@
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.AllUsersName;
-import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.strategy.SubmitDryRun;
 import com.google.gerrit.server.group.ListMembers;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.mail.Address;
 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.patch.PatchListCache;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.query.LimitPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryBuilder;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.QueryRequiresAuthException;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.ProvisionException;
 import com.google.inject.util.Providers;
 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.Map;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Set;
+import java.util.function.Function;
 import java.util.regex.Pattern;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
-import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.Repository;
 
 /** Parses a query string meant to be applied to change objects. */
@@ -117,6 +118,8 @@
   private static final Pattern DEF_CHANGE =
       Pattern.compile("^(?:[1-9][0-9]*|(?:[^~]+~[^~]+~)?[iI][0-9a-f]{4,}.*)$");
 
+  static final int MAX_ACCOUNTS_PER_DEFAULT_FIELD = 10;
+
   // NOTE: As new search operations are added, please keep the
   // SearchSuggestOracle up to date.
 
@@ -124,6 +127,7 @@
   public static final String FIELD_AGE = "age";
   public static final String FIELD_ASSIGNEE = "assignee";
   public static final String FIELD_AUTHOR = "author";
+  public static final String FIELD_EXACTAUTHOR = "exactauthor";
   public static final String FIELD_BEFORE = "before";
   public static final String FIELD_CHANGE = "change";
   public static final String FIELD_CHANGE_ID = "change_id";
@@ -131,6 +135,7 @@
   public static final String FIELD_COMMENTBY = "commentby";
   public static final String FIELD_COMMIT = "commit";
   public static final String FIELD_COMMITTER = "committer";
+  public static final String FIELD_EXACTCOMMITTER = "exactcommitter";
   public static final String FIELD_CONFLICTS = "conflicts";
   public static final String FIELD_DELETED = "deleted";
   public static final String FIELD_DELTA = "delta";
@@ -151,6 +156,9 @@
   public static final String FIELD_OWNERIN = "ownerin";
   public static final String FIELD_PARENTPROJECT = "parentproject";
   public static final String FIELD_PATH = "path";
+  public static final String FIELD_PENDING_REVIEWER = "pendingreviewer";
+  public static final String FIELD_PENDING_REVIEWER_BY_EMAIL = "pendingreviewerbyemail";
+  public static final String FIELD_PRIVATE = "private";
   public static final String FIELD_PROJECT = "project";
   public static final String FIELD_PROJECTS = "projects";
   public static final String FIELD_REF = "ref";
@@ -160,12 +168,15 @@
   public static final String FIELD_STAR = "star";
   public static final String FIELD_STARBY = "starby";
   public static final String FIELD_STARREDBY = "starredby";
+  public static final String FIELD_STARTED = "started";
   public static final String FIELD_STATUS = "status";
   public static final String FIELD_SUBMISSIONID = "submissionid";
   public static final String FIELD_TR = "tr";
   public static final String FIELD_UNRESOLVED_COMMENT_COUNT = "unresolved";
   public static final String FIELD_VISIBLETO = "visibleto";
   public static final String FIELD_WATCHEDBY = "watchedby";
+  public static final String FIELD_WIP = "wip";
+  public static final String FIELD_REVERTOF = "revertof";
 
   public static final String ARG_ID_USER = "user";
   public static final String ARG_ID_GROUP = "group";
@@ -181,8 +192,7 @@
     final AccountResolver accountResolver;
     final AllProjectsName allProjectsName;
     final AllUsersName allUsersName;
-    final CapabilityControl.Factory capabilityControlFactory;
-    final ChangeControl.GenericFactory changeControlGenericFactory;
+    final PermissionBackend permissionBackend;
     final ChangeData.Factory changeDataFactory;
     final ChangeIndex index;
     final ChangeIndexRewriter rewriter;
@@ -191,7 +201,6 @@
     final ConflictsCache conflictsCache;
     final DynamicMap<ChangeHasOperandFactory> hasOperands;
     final DynamicMap<ChangeOperatorFactory> opFactories;
-    final FieldDef.FillArgs fillArgs;
     final GitRepositoryManager repoManager;
     final GroupBackend groupBackend;
     final IdentifiedUser.GenericFactory userFactory;
@@ -205,8 +214,6 @@
     final Provider<ReviewDb> db;
     final StarredChangesUtil starredChangesUtil;
     final SubmitDryRun submitDryRun;
-    final TrackingFooters trackingFooters;
-    final boolean allowsDrafts;
 
     private final Provider<CurrentUser> self;
 
@@ -220,11 +227,9 @@
         DynamicMap<ChangeHasOperandFactory> hasOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
-        CapabilityControl.Factory capabilityControlFactory,
-        ChangeControl.GenericFactory changeControlGenericFactory,
+        PermissionBackend permissionBackend,
         ChangeNotes.Factory notesFactory,
         ChangeData.Factory changeDataFactory,
-        FieldDef.FillArgs fillArgs,
         CommentsUtil commentsUtil,
         AccountResolver accountResolver,
         GroupBackend groupBackend,
@@ -237,12 +242,10 @@
         ChangeIndexCollection indexes,
         SubmitDryRun submitDryRun,
         ConflictsCache conflictsCache,
-        TrackingFooters trackingFooters,
         IndexConfig indexConfig,
         Provider<ListMembers> listMembers,
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
-        @GerritServerConfig Config cfg,
         NotesMigration notesMigration) {
       this(
           db,
@@ -252,11 +255,9 @@
           hasOperands,
           userFactory,
           self,
-          capabilityControlFactory,
-          changeControlGenericFactory,
+          permissionBackend,
           notesFactory,
           changeDataFactory,
-          fillArgs,
           commentsUtil,
           accountResolver,
           groupBackend,
@@ -268,13 +269,11 @@
           listChildProjects,
           submitDryRun,
           conflictsCache,
-          trackingFooters,
           indexes != null ? indexes.getSearchIndex() : null,
           indexConfig,
           listMembers,
           starredChangesUtil,
           accountCache,
-          cfg == null ? true : cfg.getBoolean("change", "allowDrafts", true),
           notesMigration);
     }
 
@@ -286,11 +285,9 @@
         DynamicMap<ChangeHasOperandFactory> hasOperands,
         IdentifiedUser.GenericFactory userFactory,
         Provider<CurrentUser> self,
-        CapabilityControl.Factory capabilityControlFactory,
-        ChangeControl.GenericFactory changeControlGenericFactory,
+        PermissionBackend permissionBackend,
         ChangeNotes.Factory notesFactory,
         ChangeData.Factory changeDataFactory,
-        FieldDef.FillArgs fillArgs,
         CommentsUtil commentsUtil,
         AccountResolver accountResolver,
         GroupBackend groupBackend,
@@ -302,13 +299,11 @@
         Provider<ListChildProjects> listChildProjects,
         SubmitDryRun submitDryRun,
         ConflictsCache conflictsCache,
-        TrackingFooters trackingFooters,
         ChangeIndex index,
         IndexConfig indexConfig,
         Provider<ListMembers> listMembers,
         StarredChangesUtil starredChangesUtil,
         AccountCache accountCache,
-        boolean allowsDrafts,
         NotesMigration notesMigration) {
       this.db = db;
       this.queryProvider = queryProvider;
@@ -316,11 +311,9 @@
       this.opFactories = opFactories;
       this.userFactory = userFactory;
       this.self = self;
-      this.capabilityControlFactory = capabilityControlFactory;
+      this.permissionBackend = permissionBackend;
       this.notesFactory = notesFactory;
-      this.changeControlGenericFactory = changeControlGenericFactory;
       this.changeDataFactory = changeDataFactory;
-      this.fillArgs = fillArgs;
       this.commentsUtil = commentsUtil;
       this.accountResolver = accountResolver;
       this.groupBackend = groupBackend;
@@ -332,13 +325,11 @@
       this.listChildProjects = listChildProjects;
       this.submitDryRun = submitDryRun;
       this.conflictsCache = conflictsCache;
-      this.trackingFooters = trackingFooters;
       this.index = index;
       this.indexConfig = indexConfig;
       this.listMembers = listMembers;
       this.starredChangesUtil = starredChangesUtil;
       this.accountCache = accountCache;
-      this.allowsDrafts = allowsDrafts;
       this.hasOperands = hasOperands;
       this.notesMigration = notesMigration;
     }
@@ -352,11 +343,9 @@
           hasOperands,
           userFactory,
           Providers.of(otherUser),
-          capabilityControlFactory,
-          changeControlGenericFactory,
+          permissionBackend,
           notesFactory,
           changeDataFactory,
-          fillArgs,
           commentsUtil,
           accountResolver,
           groupBackend,
@@ -368,13 +357,11 @@
           listChildProjects,
           submitDryRun,
           conflictsCache,
-          trackingFooters,
           index,
           indexConfig,
           listMembers,
           starredChangesUtil,
           accountCache,
-          allowsDrafts,
           notesMigration);
     }
 
@@ -495,7 +482,7 @@
   }
 
   @Operator
-  public Predicate<ChangeData> status(String statusName) throws QueryParseException {
+  public Predicate<ChangeData> status(String statusName) {
     if ("reviewed".equalsIgnoreCase(statusName)) {
       return IsReviewedPredicate.create();
     }
@@ -563,15 +550,28 @@
     }
 
     if ("reviewer".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.WIP)) {
+        return Predicate.and(
+            Predicate.not(new BooleanPredicate(ChangeField.WIP)),
+            ReviewerPredicate.reviewer(args, self()));
+      }
       return ReviewerPredicate.reviewer(args, self());
     }
 
     if ("cc".equalsIgnoreCase(value)) {
-      return ReviewerPredicate.cc(args, self());
+      return ReviewerPredicate.cc(self());
     }
 
     if ("mergeable".equalsIgnoreCase(value)) {
-      return new IsMergeablePredicate(args.fillArgs);
+      return new BooleanPredicate(ChangeField.MERGEABLE);
+    }
+
+    if ("private".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.PRIVATE)) {
+        return new BooleanPredicate(ChangeField.PRIVATE);
+      }
+      throw new QueryParseException(
+          "'is:private' operator is not supported by change index version");
     }
 
     if ("assigned".equalsIgnoreCase(value)) {
@@ -586,13 +586,26 @@
       return new SubmittablePredicate(SubmitRecord.Status.OK);
     }
 
-    try {
-      return status(value);
-    } catch (IllegalArgumentException e) {
-      // not status: alias?
+    if ("ignored".equalsIgnoreCase(value)) {
+      return star("ignore");
     }
 
-    throw error("Invalid query");
+    if ("started".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.STARTED)) {
+        return new BooleanPredicate(ChangeField.STARTED);
+      }
+      throw new QueryParseException(
+          "'is:started' operator is not supported by change index version");
+    }
+
+    if ("wip".equalsIgnoreCase(value)) {
+      if (args.getSchema().hasField(ChangeField.WIP)) {
+        return new BooleanPredicate(ChangeField.WIP);
+      }
+      throw new QueryParseException("'is:wip' operator is not supported by change index version");
+    }
+
+    return status(value);
   }
 
   @Operator
@@ -602,7 +615,12 @@
 
   @Operator
   public Predicate<ChangeData> conflicts(String value) throws OrmException, QueryParseException {
-    return new ConflictsPredicate(args, value, parseChange(value));
+    List<Change> changes = parseChange(value);
+    List<Predicate<ChangeData>> or = new ArrayList<>(changes.size());
+    for (Change c : changes) {
+      or.add(ConflictsPredicate.create(args, value, c));
+    }
+    return Predicate.or(or);
   }
 
   @Operator
@@ -687,7 +705,8 @@
   }
 
   @Operator
-  public Predicate<ChangeData> label(String name) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> label(String name)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     Set<Account.Id> accounts = null;
     AccountGroup.UUID group = null;
 
@@ -786,7 +805,8 @@
   }
 
   @Operator
-  public Predicate<ChangeData> starredby(String who) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> starredby(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     return starredby(parseAccount(who));
   }
 
@@ -803,7 +823,8 @@
   }
 
   @Operator
-  public Predicate<ChangeData> watchedby(String who) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> watchedby(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     Set<Account.Id> m = parseAccount(who);
     List<IsWatchedByPredicate> p = Lists.newArrayListWithCapacity(m.size());
 
@@ -827,7 +848,8 @@
   }
 
   @Operator
-  public Predicate<ChangeData> draftby(String who) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> draftby(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     Set<Account.Id> m = parseAccount(who);
     List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
     for (Account.Id id : m) {
@@ -840,12 +862,17 @@
     return new HasDraftByPredicate(who);
   }
 
+  private boolean isSelf(String who) {
+    return "self".equals(who) || "me".equals(who);
+  }
+
   @Operator
-  public Predicate<ChangeData> visibleto(String who) throws QueryParseException, OrmException {
-    if ("self".equals(who)) {
+  public Predicate<ChangeData> visibleto(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    if (isSelf(who)) {
       return is_visible();
     }
-    Set<Account.Id> m = args.accountResolver.findAll(args.db.get(), who);
+    Set<Account.Id> m = args.accountResolver.findAll(who);
     if (!m.isEmpty()) {
       List<Predicate<ChangeData>> p = Lists.newArrayListWithCapacity(m.size());
       for (Account.Id id : m) {
@@ -862,15 +889,14 @@
       for (GroupReference ref : suggestions) {
         ids.add(ref.getUUID());
       }
-      return visibleto(new SingleGroupUser(args.capabilityControlFactory, ids));
+      return visibleto(new SingleGroupUser(ids));
     }
 
     throw error("No user or group matches \"" + who + "\".");
   }
 
   public Predicate<ChangeData> visibleto(CurrentUser user) {
-    return new ChangeIsVisibleToPredicate(
-        args.db, args.notesFactory, args.changeControlGenericFactory, user);
+    return new ChangeIsVisibleToPredicate(args.db, args.notesFactory, user, args.permissionBackend);
   }
 
   public Predicate<ChangeData> is_visible() throws QueryParseException {
@@ -878,12 +904,14 @@
   }
 
   @Operator
-  public Predicate<ChangeData> o(String who) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> o(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     return owner(who);
   }
 
   @Operator
-  public Predicate<ChangeData> owner(String who) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> owner(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     return owner(parseAccount(who));
   }
 
@@ -895,8 +923,18 @@
     return Predicate.or(p);
   }
 
+  private Predicate<ChangeData> ownerDefaultField(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Set<Account.Id> accounts = parseAccount(who);
+    if (accounts.size() > MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
+      return Predicate.any();
+    }
+    return owner(accounts);
+  }
+
   @Operator
-  public Predicate<ChangeData> assignee(String who) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> assignee(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     return assignee(parseAccount(who));
   }
 
@@ -930,23 +968,39 @@
   }
 
   @Operator
-  public Predicate<ChangeData> r(String who) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> r(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     return reviewer(who);
   }
 
   @Operator
-  public Predicate<ChangeData> reviewer(String who) throws QueryParseException, OrmException {
-    return Predicate.or(
-        parseAccount(who)
-            .stream()
-            .map(id -> ReviewerPredicate.reviewer(args, id))
-            .collect(toList()));
+  public Predicate<ChangeData> reviewer(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return reviewer(who, false);
+  }
+
+  private Predicate<ChangeData> reviewerDefaultField(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return reviewer(who, true);
+  }
+
+  private Predicate<ChangeData> reviewer(String who, boolean forDefaultField)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Predicate<ChangeData> byState =
+        reviewerByState(who, ReviewerStateInternal.REVIEWER, forDefaultField);
+    if (Objects.equals(byState, Predicate.<ChangeData>any())) {
+      return Predicate.any();
+    }
+    if (args.getSchema().hasField(ChangeField.WIP)) {
+      return Predicate.and(Predicate.not(new BooleanPredicate(ChangeField.WIP)), byState);
+    }
+    return byState;
   }
 
   @Operator
-  public Predicate<ChangeData> cc(String who) throws QueryParseException, OrmException {
-    return Predicate.or(
-        parseAccount(who).stream().map(id -> ReviewerPredicate.cc(args, id)).collect(toList()));
+  public Predicate<ChangeData> cc(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    return reviewerByState(who, ReviewerStateInternal.CC, false);
   }
 
   @Operator
@@ -960,7 +1014,7 @@
 
   @Operator
   public Predicate<ChangeData> tr(String trackingId) {
-    return new TrackingIdPredicate(args.trackingFooters, trackingId);
+    return new TrackingIdPredicate(trackingId);
   }
 
   @Operator
@@ -998,7 +1052,8 @@
   }
 
   @Operator
-  public Predicate<ChangeData> commentby(String who) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> commentby(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     return commentby(parseAccount(who));
   }
 
@@ -1011,7 +1066,8 @@
   }
 
   @Operator
-  public Predicate<ChangeData> from(String who) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> from(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     Set<Account.Id> ownerIds = parseAccount(who);
     return Predicate.or(owner(ownerIds), commentby(ownerIds));
   }
@@ -1035,7 +1091,8 @@
   }
 
   @Operator
-  public Predicate<ChangeData> reviewedby(String who) throws QueryParseException, OrmException {
+  public Predicate<ChangeData> reviewedby(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
     return IsReviewedPredicate.create(parseAccount(who));
   }
 
@@ -1058,13 +1115,21 @@
   }
 
   @Operator
-  public Predicate<ChangeData> author(String who) {
-    return new AuthorPredicate(who);
+  public Predicate<ChangeData> author(String who) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.EXACT_AUTHOR)) {
+      return getAuthorOrCommitterPredicate(
+          who.trim(), ExactAuthorPredicate::new, AuthorPredicate::new);
+    }
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), AuthorPredicate::new);
   }
 
   @Operator
-  public Predicate<ChangeData> committer(String who) {
-    return new CommitterPredicate(who);
+  public Predicate<ChangeData> committer(String who) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.EXACT_COMMITTER)) {
+      return getAuthorOrCommitterPredicate(
+          who.trim(), ExactCommitterPredicate::new, CommitterPredicate::new);
+    }
+    return getAuthorOrCommitterFullTextPredicate(who.trim(), CommitterPredicate::new);
   }
 
   @Operator
@@ -1082,6 +1147,14 @@
     return new IsUnresolvedPredicate(value);
   }
 
+  @Operator
+  public Predicate<ChangeData> revertof(String value) throws QueryParseException {
+    if (args.getSchema().hasField(ChangeField.REVERT_OF)) {
+      return new RevertOfPredicate(value);
+    }
+    throw new QueryParseException("'revertof' operator is not supported by change index version");
+  }
+
   @Override
   protected Predicate<ChangeData> defaultField(String query) throws QueryParseException {
     if (query.startsWith("refs/")) {
@@ -1105,19 +1178,25 @@
     // Adapt the capacity of this list when adding more default predicates.
     List<Predicate<ChangeData>> predicates = Lists.newArrayListWithCapacity(11);
     try {
-      predicates.add(owner(query));
-    } catch (OrmException | QueryParseException e) {
+      Predicate<ChangeData> p = ownerDefaultField(query);
+      if (!Objects.equals(p, Predicate.<ChangeData>any())) {
+        predicates.add(p);
+      }
+    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
       // Skip.
     }
     try {
-      predicates.add(reviewer(query));
-    } catch (OrmException | QueryParseException e) {
+      Predicate<ChangeData> p = reviewerDefaultField(query);
+      if (!Objects.equals(p, Predicate.<ChangeData>any())) {
+        predicates.add(p);
+      }
+    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
       // Skip.
     }
     predicates.add(file(query));
     try {
       predicates.add(label(query));
-    } catch (OrmException | QueryParseException e) {
+    } catch (OrmException | IOException | ConfigInvalidException | QueryParseException e) {
       // Skip.
     }
     predicates.add(commit(query));
@@ -1132,6 +1211,30 @@
     return Predicate.or(predicates);
   }
 
+  private Predicate<ChangeData> getAuthorOrCommitterPredicate(
+      String who,
+      Function<String, Predicate<ChangeData>> exactPredicateFunc,
+      Function<String, Predicate<ChangeData>> fullPredicateFunc)
+      throws QueryParseException {
+    if (Address.tryParse(who) != null) {
+      return exactPredicateFunc.apply(who);
+    }
+    return getAuthorOrCommitterFullTextPredicate(who, fullPredicateFunc);
+  }
+
+  private Predicate<ChangeData> getAuthorOrCommitterFullTextPredicate(
+      String who, Function<String, Predicate<ChangeData>> fullPredicateFunc)
+      throws QueryParseException {
+    Set<String> parts = SchemaUtil.getNameParts(who);
+    if (parts.isEmpty()) {
+      throw error("invalid value");
+    }
+
+    List<Predicate<ChangeData>> predicates =
+        parts.stream().map(fullPredicateFunc).collect(toList());
+    return Predicate.and(predicates);
+  }
+
   private Set<Account.Id> getMembers(AccountGroup.UUID g) throws OrmException {
     Set<Account.Id> accounts;
     Set<Account.Id> allMembers =
@@ -1152,11 +1255,12 @@
     return accounts;
   }
 
-  private Set<Account.Id> parseAccount(String who) throws QueryParseException, OrmException {
-    if ("self".equals(who)) {
+  private Set<Account.Id> parseAccount(String who)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    if (isSelf(who)) {
       return Collections.singleton(self());
     }
-    Set<Account.Id> matches = args.accountResolver.findAll(args.db.get(), who);
+    Set<Account.Id> matches = args.accountResolver.findAll(who);
     if (matches.isEmpty()) {
       throw error("User " + who + " not found");
     }
@@ -1195,4 +1299,44 @@
   private Account.Id self() throws QueryParseException {
     return args.getIdentifiedUser().getAccountId();
   }
+
+  public Predicate<ChangeData> reviewerByState(
+      String who, ReviewerStateInternal state, boolean forDefaultField)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Predicate<ChangeData> reviewerByEmailPredicate = null;
+    if (args.index.getSchema().hasField(ChangeField.REVIEWER_BY_EMAIL)) {
+      Address address = Address.tryParse(who);
+      if (address != null) {
+        reviewerByEmailPredicate = ReviewerByEmailPredicate.forState(address, state);
+      }
+    }
+
+    Predicate<ChangeData> reviewerPredicate = null;
+    try {
+      Set<Account.Id> accounts = parseAccount(who);
+      if (!forDefaultField || accounts.size() <= MAX_ACCOUNTS_PER_DEFAULT_FIELD) {
+        reviewerPredicate =
+            Predicate.or(
+                accounts
+                    .stream()
+                    .map(id -> ReviewerPredicate.forState(id, state))
+                    .collect(toList()));
+      }
+    } catch (QueryParseException e) {
+      // Propagate this exception only if we can't use 'who' to query by email
+      if (reviewerByEmailPredicate == null) {
+        throw e;
+      }
+    }
+
+    if (reviewerPredicate != null && reviewerByEmailPredicate != null) {
+      return Predicate.or(reviewerPredicate, reviewerByEmailPredicate);
+    } else if (reviewerPredicate != null) {
+      return reviewerPredicate;
+    } else if (reviewerByEmailPredicate != null) {
+      return reviewerByEmailPredicate;
+    } else {
+      return Predicate.any();
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
index 91a37d5..b190cd2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryProcessor.java
@@ -17,27 +17,52 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_LIMIT;
 
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryProcessor;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexPredicate;
-import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexRewriter;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryProcessor;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Set;
 
-public class ChangeQueryProcessor extends QueryProcessor<ChangeData> {
+/**
+ * Query processor for the change index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public class ChangeQueryProcessor extends QueryProcessor<ChangeData>
+    implements PluginDefinedAttributesFactory {
+  /**
+   * Register a ChangeAttributeFactory in a config Module like this:
+   *
+   * <p>bind(ChangeAttributeFactory.class) .annotatedWith(Exports.named("export-name"))
+   * .to(YourClass.class);
+   */
+  public interface ChangeAttributeFactory {
+    PluginDefinedInfo create(ChangeData a, ChangeQueryProcessor qp, String plugin);
+  }
+
   private final Provider<ReviewDb> db;
-  private final ChangeControl.GenericFactory changeControlFactory;
+  private final Provider<CurrentUser> userProvider;
   private final ChangeNotes.Factory notesFactory;
+  private final DynamicMap<ChangeAttributeFactory> attributeFactories;
+  private final PermissionBackend permissionBackend;
 
   static {
     // It is assumed that basic rewrites do not touch visibleto predicates.
@@ -49,24 +74,28 @@
   @Inject
   ChangeQueryProcessor(
       Provider<CurrentUser> userProvider,
-      Metrics metrics,
+      AccountLimits.Factory limitsFactory,
+      MetricMaker metricMaker,
       IndexConfig indexConfig,
       ChangeIndexCollection indexes,
       ChangeIndexRewriter rewriter,
       Provider<ReviewDb> db,
-      ChangeControl.GenericFactory changeControlFactory,
-      ChangeNotes.Factory notesFactory) {
+      ChangeNotes.Factory notesFactory,
+      DynamicMap<ChangeAttributeFactory> attributeFactories,
+      PermissionBackend permissionBackend) {
     super(
-        userProvider,
-        metrics,
+        metricMaker,
         ChangeSchemaDefinitions.INSTANCE,
         indexConfig,
         indexes,
         rewriter,
-        FIELD_LIMIT);
+        FIELD_LIMIT,
+        () -> limitsFactory.create(userProvider.get()).getQueryLimit());
     this.db = db;
-    this.changeControlFactory = changeControlFactory;
+    this.userProvider = userProvider;
     this.notesFactory = notesFactory;
+    this.attributeFactories = attributeFactories;
+    this.permissionBackend = permissionBackend;
   }
 
   @Override
@@ -82,10 +111,34 @@
   }
 
   @Override
+  public List<PluginDefinedInfo> create(ChangeData cd) {
+    List<PluginDefinedInfo> plugins = new ArrayList<>(attributeFactories.plugins().size());
+    for (String plugin : attributeFactories.plugins()) {
+      for (Provider<ChangeAttributeFactory> provider :
+          attributeFactories.byPlugin(plugin).values()) {
+        PluginDefinedInfo pda = null;
+        try {
+          pda = provider.get().create(cd, this, plugin);
+        } catch (RuntimeException e) {
+          /* Eat runtime exceptions so that queries don't fail. */
+        }
+        if (pda != null) {
+          pda.name = plugin;
+          plugins.add(pda);
+        }
+      }
+    }
+    if (plugins.isEmpty()) {
+      plugins = null;
+    }
+    return plugins;
+  }
+
+  @Override
   protected Predicate<ChangeData> enforceVisibility(Predicate<ChangeData> pred) {
     return new AndChangeSource(
         pred,
-        new ChangeIsVisibleToPredicate(db, notesFactory, changeControlFactory, userProvider.get()),
+        new ChangeIsVisibleToPredicate(db, notesFactory, userProvider.get(), permissionBackend),
         start);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
index f421985..24b8b7a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeRegexPredicate.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.RegexPredicate;
-import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.RegexPredicate;
 
 public abstract class ChangeRegexPredicate extends RegexPredicate<ChangeData>
     implements Matchable<ChangeData> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 9c16777..155b016 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -14,16 +14,20 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
 import java.util.NavigableMap;
+import java.util.Objects;
 import java.util.TreeMap;
 
 /**
@@ -36,6 +40,9 @@
  * <p>Status names are looked up by prefix case-insensitively.
  */
 public final class ChangeStatusPredicate extends ChangeIndexPredicate {
+  private static final String INVALID_STATUS = "__invalid__";
+  private static final Predicate<ChangeData> NONE = new ChangeStatusPredicate(null);
+
   private static final TreeMap<String, Predicate<ChangeData>> PREDICATES;
   private static final Predicate<ChangeData> CLOSED;
   private static final Predicate<ChangeData> OPEN;
@@ -46,8 +53,14 @@
     List<Predicate<ChangeData>> closed = new ArrayList<>();
 
     for (Change.Status s : Change.Status.values()) {
-      ChangeStatusPredicate p = new ChangeStatusPredicate(s);
-      PREDICATES.put(canonicalize(s), p);
+      ChangeStatusPredicate p = forStatus(s);
+      String str = canonicalize(s);
+      checkState(
+          !INVALID_STATUS.equals(str),
+          "invalid status sentinel %s cannot match canonicalized status string %s",
+          INVALID_STATUS,
+          str);
+      PREDICATES.put(str, p);
       (s.isOpen() ? open : closed).add(p);
     }
 
@@ -63,7 +76,7 @@
     return status.name().toLowerCase();
   }
 
-  public static Predicate<ChangeData> parse(String value) throws QueryParseException {
+  public static Predicate<ChangeData> parse(String value) {
     String lower = value.toLowerCase();
     NavigableMap<String, Predicate<ChangeData>> head = PREDICATES.tailMap(lower, true);
     if (!head.isEmpty()) {
@@ -73,7 +86,7 @@
         return e.getValue();
       }
     }
-    throw new QueryParseException("invalid change status: " + value);
+    return NONE;
   }
 
   public static Predicate<ChangeData> open() {
@@ -84,21 +97,31 @@
     return CLOSED;
   }
 
-  private final Change.Status status;
+  public static ChangeStatusPredicate forStatus(Change.Status status) {
+    return new ChangeStatusPredicate(checkNotNull(status));
+  }
 
-  ChangeStatusPredicate(Change.Status status) {
-    super(ChangeField.STATUS, canonicalize(status));
+  @Nullable private final Change.Status status;
+
+  private ChangeStatusPredicate(@Nullable Change.Status status) {
+    super(ChangeField.STATUS, status != null ? canonicalize(status) : INVALID_STATUS);
     this.status = status;
   }
 
+  /**
+   * Get the status for this predicate.
+   *
+   * @return the status, or null if this predicate is intended to never match any changes.
+   */
+  @Nullable
   public Change.Status getStatus() {
     return status;
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     Change change = object.change();
-    return change != null && status.equals(change.getStatus());
+    return change != null && Objects.equals(status, change.getStatus());
   }
 
   @Override
@@ -108,16 +131,13 @@
 
   @Override
   public int hashCode() {
-    return status.hashCode();
+    return Objects.hashCode(status);
   }
 
   @Override
   public boolean equals(Object other) {
-    if (other instanceof ChangeStatusPredicate) {
-      final ChangeStatusPredicate p = (ChangeStatusPredicate) other;
-      return status.equals(p.status);
-    }
-    return false;
+    return (other instanceof ChangeStatusPredicate)
+        && Objects.equals(status, ((ChangeStatusPredicate) other).status);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
index 668c6f2..7ad7afe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentByPredicate.java
@@ -21,10 +21,10 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.Objects;
 
-class CommentByPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class CommentByPredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  CommentByPredicate(Account.Id id) {
+  public CommentByPredicate(Account.Id id) {
     super(ChangeField.COMMENTBY, id.toString());
     this.id = id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
index 4779a16..5a6d186 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommentPredicate.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-class CommentPredicate extends ChangeIndexPredicate {
-  private final ChangeIndex index;
+public class CommentPredicate extends ChangeIndexPredicate {
+  protected final ChangeIndex index;
 
-  CommentPredicate(ChangeIndex index, String value) {
+  public CommentPredicate(ChangeIndex index, String value) {
     super(ChangeField.COMMENT, value);
     this.index = index;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
index 1188d5d..d1ae529 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitPredicate.java
@@ -18,11 +18,11 @@
 import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMIT;
 import static org.eclipse.jgit.lib.Constants.OBJECT_ID_STRING_LENGTH;
 
+import com.google.gerrit.index.FieldDef;
 import com.google.gerrit.reviewdb.client.PatchSet;
-import com.google.gerrit.server.index.FieldDef;
 import com.google.gwtorm.server.OrmException;
 
-class CommitPredicate extends ChangeIndexPredicate {
+public class CommitPredicate extends ChangeIndexPredicate {
   static FieldDef<ChangeData, ?> commitField(String id) {
     if (id.length() == OBJECT_ID_STRING_LENGTH) {
       return EXACT_COMMIT;
@@ -30,12 +30,12 @@
     return COMMIT;
   }
 
-  CommitPredicate(String id) {
+  public CommitPredicate(String id) {
     super(commitField(id), id);
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     String id = getValue().toLowerCase();
     for (PatchSet p : object.patchSets()) {
       if (equals(p, id)) {
@@ -45,7 +45,7 @@
     return false;
   }
 
-  private boolean equals(PatchSet p, String id) {
+  protected boolean equals(PatchSet p, String id) {
     boolean exact = getField() == EXACT_COMMIT;
     String rev = p.getRevision() != null ? p.getRevision().get() : null;
     return (exact && id.equals(rev)) || (!exact && rev != null && rev.startsWith(id));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
index cd1f3b2..797cb9d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/CommitterPredicate.java
@@ -22,7 +22,7 @@
 import java.io.IOException;
 
 public class CommitterPredicate extends ChangeIndexPredicate {
-  CommitterPredicate(String value) {
+  public CommitterPredicate(String value) {
     super(COMMITTER, FIELD_COMMITTER, value.toLowerCase());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
index 9b45890..dbcb879 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ConflictsPredicate.java
@@ -14,10 +14,11 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.common.collect.Lists;
 import com.google.gerrit.common.data.SubmitTypeRecord;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.git.CodeReviewCommit;
 import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
 import com.google.gerrit.server.git.IntegrationException;
@@ -25,222 +26,171 @@
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.OrPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
-import com.google.inject.Provider;
 import java.io.IOException;
 import java.util.ArrayList;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Set;
 import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.eclipse.jgit.revwalk.RevWalk;
-import org.eclipse.jgit.revwalk.filter.RevFilter;
-import org.eclipse.jgit.treewalk.TreeWalk;
-import org.eclipse.jgit.treewalk.filter.TreeFilter;
 
-class ConflictsPredicate extends OrPredicate<ChangeData> {
+public class ConflictsPredicate {
   // UI code may depend on this string, so use caution when changing.
-  private static final String TOO_MANY_FILES = "too many files to find conflicts";
+  protected static final String TOO_MANY_FILES = "too many files to find conflicts";
 
-  private final String value;
+  private ConflictsPredicate() {}
 
-  ConflictsPredicate(Arguments args, String value, List<Change> changes)
+  public static Predicate<ChangeData> create(Arguments args, String value, Change c)
       throws QueryParseException, OrmException {
-    super(predicates(args, value, changes));
-    this.value = value;
-  }
-
-  private static List<Predicate<ChangeData>> predicates(
-      final Arguments args, String value, List<Change> changes)
-      throws QueryParseException, OrmException {
-    int indexTerms = 0;
-
-    List<Predicate<ChangeData>> changePredicates = Lists.newArrayListWithCapacity(changes.size());
-    final Provider<ReviewDb> db = args.db;
-    for (final Change c : changes) {
-      final ChangeDataCache changeDataCache =
-          new ChangeDataCache(c, db, args.changeDataFactory, args.projectCache);
-      List<String> files = listFiles(c, args, changeDataCache);
-      indexTerms += 3 + files.size();
-      if (indexTerms > args.indexConfig.maxTerms()) {
-        // Short-circuit with a nice error message if we exceed the index
-        // backend's term limit. This assumes that "conflicts:foo" is the entire
-        // query; if there are more terms in the input, we might not
-        // short-circuit here, which will result in a more generic error message
-        // later on in the query parsing.
-        throw new QueryParseException(TOO_MANY_FILES);
-      }
-
-      List<Predicate<ChangeData>> filePredicates = Lists.newArrayListWithCapacity(files.size());
-      for (String file : files) {
-        filePredicates.add(new EqualsPathPredicate(ChangeQueryBuilder.FIELD_PATH, file));
-      }
-
-      List<Predicate<ChangeData>> predicatesForOneChange = Lists.newArrayListWithCapacity(5);
-      predicatesForOneChange.add(not(new LegacyChangeIdPredicate(c.getId())));
-      predicatesForOneChange.add(new ProjectPredicate(c.getProject().get()));
-      predicatesForOneChange.add(new RefPredicate(c.getDest().get()));
-
-      predicatesForOneChange.add(or(or(filePredicates), new IsMergePredicate(args, value)));
-
-      predicatesForOneChange.add(
-          new ChangeOperatorPredicate(ChangeQueryBuilder.FIELD_CONFLICTS, value) {
-
-            @Override
-            public boolean match(ChangeData object) throws OrmException {
-              Change otherChange = object.change();
-              if (otherChange == null) {
-                return false;
-              }
-              if (!otherChange.getDest().equals(c.getDest())) {
-                return false;
-              }
-              SubmitTypeRecord str = object.submitTypeRecord();
-              if (!str.isOk()) {
-                return false;
-              }
-              ObjectId other = ObjectId.fromString(object.currentPatchSet().getRevision().get());
-              ConflictKey conflictsKey =
-                  new ConflictKey(
-                      changeDataCache.getTestAgainst(),
-                      other,
-                      str.type,
-                      changeDataCache.getProjectState().isUseContentMerge());
-              Boolean conflicts = args.conflictsCache.getIfPresent(conflictsKey);
-              if (conflicts != null) {
-                return conflicts;
-              }
-              try (Repository repo = args.repoManager.openRepository(otherChange.getProject());
-                  CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-                conflicts =
-                    !args.submitDryRun.run(
-                        str.type,
-                        repo,
-                        rw,
-                        otherChange.getDest(),
-                        changeDataCache.getTestAgainst(),
-                        other,
-                        getAlreadyAccepted(repo, rw));
-                args.conflictsCache.put(conflictsKey, conflicts);
-                return conflicts;
-              } catch (IntegrationException | NoSuchProjectException | IOException e) {
-                throw new OrmException(e);
-              }
-            }
-
-            @Override
-            public int getCost() {
-              return 5;
-            }
-
-            private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
-                throws IntegrationException {
-              try {
-                Set<RevCommit> accepted = new HashSet<>();
-                SubmitDryRun.addCommits(changeDataCache.getAlreadyAccepted(repo), rw, accepted);
-                ObjectId tip = changeDataCache.getTestAgainst();
-                if (tip != null) {
-                  accepted.add(rw.parseCommit(tip));
-                }
-                return accepted;
-              } catch (OrmException | IOException e) {
-                throw new IntegrationException("Failed to determine already accepted commits.", e);
-              }
-            }
-          });
-      changePredicates.add(and(predicatesForOneChange));
-    }
-    return changePredicates;
-  }
-
-  private static List<String> listFiles(Change c, Arguments args, ChangeDataCache changeDataCache)
-      throws OrmException {
-    try (Repository repo = args.repoManager.openRepository(c.getProject());
-        RevWalk rw = new RevWalk(repo)) {
-      RevCommit ps = rw.parseCommit(changeDataCache.getTestAgainst());
-      if (ps.getParentCount() > 1) {
-        String dest = c.getDest().get();
-        Ref destBranch = repo.getRefDatabase().getRef(dest);
-        destBranch.getObjectId();
-        rw.setRevFilter(RevFilter.MERGE_BASE);
-        rw.markStart(rw.parseCommit(destBranch.getObjectId()));
-        rw.markStart(ps);
-        RevCommit base = rw.next();
-        // TODO(zivkov): handle the case with multiple merge bases
-
-        List<String> files = new ArrayList<>();
-        try (TreeWalk tw = new TreeWalk(repo)) {
-          if (base != null) {
-            tw.setFilter(TreeFilter.ANY_DIFF);
-            tw.addTree(base.getTree());
-          }
-          tw.addTree(ps.getTree());
-          tw.setRecursive(true);
-          while (tw.next()) {
-            files.add(tw.getPathString());
-          }
-        }
-        return files;
-      }
-      return args.changeDataFactory.create(args.db.get(), c).currentFilePaths();
+    ChangeData cd;
+    List<String> files;
+    try {
+      cd = args.changeDataFactory.create(args.db.get(), c);
+      files = cd.currentFilePaths();
     } catch (IOException e) {
       throw new OrmException(e);
     }
+
+    if (3 + files.size() > args.indexConfig.maxTerms()) {
+      // Short-circuit with a nice error message if we exceed the index
+      // backend's term limit. This assumes that "conflicts:foo" is the entire
+      // query; if there are more terms in the input, we might not
+      // short-circuit here, which will result in a more generic error message
+      // later on in the query parsing.
+      throw new QueryParseException(TOO_MANY_FILES);
+    }
+
+    List<Predicate<ChangeData>> filePredicates = new ArrayList<>(files.size());
+    for (String file : files) {
+      filePredicates.add(new EqualsPathPredicate(ChangeQueryBuilder.FIELD_PATH, file));
+    }
+
+    List<Predicate<ChangeData>> and = new ArrayList<>(5);
+    and.add(new ProjectPredicate(c.getProject().get()));
+    and.add(new RefPredicate(c.getDest().get()));
+    and.add(Predicate.not(new LegacyChangeIdPredicate(c.getId())));
+    and.add(Predicate.or(filePredicates));
+
+    ChangeDataCache changeDataCache = new ChangeDataCache(cd, args.projectCache);
+    and.add(new CheckConflict(ChangeQueryBuilder.FIELD_CONFLICTS, value, args, c, changeDataCache));
+    return Predicate.and(and);
   }
 
-  @Override
-  public String toString() {
-    return ChangeQueryBuilder.FIELD_CONFLICTS + ":" + value;
+  private static final class CheckConflict extends ChangeOperatorPredicate {
+    private final Arguments args;
+    private final Branch.NameKey dest;
+    private final ChangeDataCache changeDataCache;
+
+    CheckConflict(
+        String field, String value, Arguments args, Change c, ChangeDataCache changeDataCache) {
+      super(field, value);
+      this.args = args;
+      this.dest = c.getDest();
+      this.changeDataCache = changeDataCache;
+    }
+
+    @Override
+    public boolean match(ChangeData object) throws OrmException {
+      Change otherChange = object.change();
+      if (otherChange == null || !otherChange.getDest().equals(dest)) {
+        return false;
+      }
+
+      SubmitTypeRecord str = object.submitTypeRecord();
+      if (!str.isOk()) {
+        return false;
+      }
+
+      ProjectState projectState;
+      try {
+        projectState = changeDataCache.getProjectState();
+      } catch (NoSuchProjectException e) {
+        return false;
+      }
+
+      ObjectId other = ObjectId.fromString(object.currentPatchSet().getRevision().get());
+      ConflictKey conflictsKey =
+          new ConflictKey(
+              changeDataCache.getTestAgainst(), other, str.type, projectState.isUseContentMerge());
+      Boolean conflicts = args.conflictsCache.getIfPresent(conflictsKey);
+      if (conflicts != null) {
+        return conflicts;
+      }
+
+      try (Repository repo = args.repoManager.openRepository(otherChange.getProject());
+          CodeReviewRevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
+        conflicts =
+            !args.submitDryRun.run(
+                str.type,
+                repo,
+                rw,
+                otherChange.getDest(),
+                changeDataCache.getTestAgainst(),
+                other,
+                getAlreadyAccepted(repo, rw));
+        args.conflictsCache.put(conflictsKey, conflicts);
+        return conflicts;
+      } catch (IntegrationException | NoSuchProjectException | IOException e) {
+        throw new OrmException(e);
+      }
+    }
+
+    @Override
+    public int getCost() {
+      return 5;
+    }
+
+    private Set<RevCommit> getAlreadyAccepted(Repository repo, RevWalk rw)
+        throws IntegrationException {
+      try {
+        Set<RevCommit> accepted = new HashSet<>();
+        SubmitDryRun.addCommits(changeDataCache.getAlreadyAccepted(repo), rw, accepted);
+        ObjectId tip = changeDataCache.getTestAgainst();
+        if (tip != null) {
+          accepted.add(rw.parseCommit(tip));
+        }
+        return accepted;
+      } catch (OrmException | IOException e) {
+        throw new IntegrationException("Failed to determine already accepted commits.", e);
+      }
+    }
   }
 
   private static class ChangeDataCache {
-    private final Change change;
-    private final Provider<ReviewDb> db;
-    private final ChangeData.Factory changeDataFactory;
+    private final ChangeData cd;
     private final ProjectCache projectCache;
 
     private ObjectId testAgainst;
     private ProjectState projectState;
-    private Iterable<ObjectId> alreadyAccepted;
+    private Set<ObjectId> alreadyAccepted;
 
-    ChangeDataCache(
-        Change change,
-        Provider<ReviewDb> db,
-        ChangeData.Factory changeDataFactory,
-        ProjectCache projectCache) {
-      this.change = change;
-      this.db = db;
-      this.changeDataFactory = changeDataFactory;
+    ChangeDataCache(ChangeData cd, ProjectCache projectCache) {
+      this.cd = cd;
       this.projectCache = projectCache;
     }
 
     ObjectId getTestAgainst() throws OrmException {
       if (testAgainst == null) {
-        testAgainst =
-            ObjectId.fromString(
-                changeDataFactory.create(db.get(), change).currentPatchSet().getRevision().get());
+        testAgainst = ObjectId.fromString(cd.currentPatchSet().getRevision().get());
       }
       return testAgainst;
     }
 
-    ProjectState getProjectState() {
+    ProjectState getProjectState() throws NoSuchProjectException {
       if (projectState == null) {
-        projectState = projectCache.get(change.getProject());
+        projectState = projectCache.get(cd.project());
         if (projectState == null) {
-          throw new IllegalStateException(new NoSuchProjectException(change.getProject()));
+          throw new NoSuchProjectException(cd.project());
         }
       }
       return projectState;
     }
 
-    Iterable<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
+    Set<ObjectId> getAlreadyAccepted(Repository repo) throws IOException {
       if (alreadyAccepted == null) {
         alreadyAccepted = SubmitDryRun.getAlreadyAccepted(repo);
       }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
index 9e49269..6232fc5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeletedPredicate.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 public class DeletedPredicate extends IntegerRangeChangePredicate {
-  DeletedPredicate(String value) throws QueryParseException {
+  public DeletedPredicate(String value) throws QueryParseException {
     super(ChangeField.DELETED, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) throws OrmException {
-    return ChangeField.DELETED.get(changeData, null);
+    return ChangeField.DELETED.get(changeData);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
index ce33225..aae0a20 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DeltaPredicate.java
@@ -14,17 +14,17 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 public class DeltaPredicate extends IntegerRangeChangePredicate {
-  DeltaPredicate(String value) throws QueryParseException {
+  public DeltaPredicate(String value) throws QueryParseException {
     super(ChangeField.DELTA, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) throws OrmException {
-    return ChangeField.DELTA.get(changeData, null);
+    return ChangeField.DELTA.get(changeData);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
index 809e7a1..7f969e1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/DestinationPredicate.java
@@ -19,16 +19,16 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.Set;
 
-class DestinationPredicate extends ChangeOperatorPredicate {
-  Set<Branch.NameKey> destinations;
+public class DestinationPredicate extends ChangeOperatorPredicate {
+  protected Set<Branch.NameKey> destinations;
 
-  DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
+  public DestinationPredicate(Set<Branch.NameKey> destinations, String value) {
     super(ChangeQueryBuilder.FIELD_DESTINATION, value);
     this.destinations = destinations;
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     Change change = object.change();
     if (change == null) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
index 8be5235..3238dc9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EditByPredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class EditByPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class EditByPredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  EditByPredicate(Account.Id id) {
+  public EditByPredicate(Account.Id id) {
     super(ChangeField.EDITBY, id.toString());
     this.id = id;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
index fb6c56b..b5a2d05 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsFilePredicate.java
@@ -14,13 +14,13 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 
-class EqualsFilePredicate extends ChangeIndexPredicate {
-  static Predicate<ChangeData> create(Arguments args, String value) {
+public class EqualsFilePredicate extends ChangeIndexPredicate {
+  public static Predicate<ChangeData> create(Arguments args, String value) {
     Predicate<ChangeData> eqPath = new EqualsPathPredicate(ChangeQueryBuilder.FIELD_FILE, value);
     if (!args.getSchema().hasField(ChangeField.FILE_PART)) {
       return eqPath;
@@ -28,11 +28,8 @@
     return Predicate.or(eqPath, new EqualsFilePredicate(value));
   }
 
-  private final String value;
-
   private EqualsFilePredicate(String value) {
     super(ChangeField.FILE_PART, ChangeQueryBuilder.FIELD_FILE, value);
-    this.value = value;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
index 1189e87..785ae38 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsLabelPredicate.java
@@ -16,7 +16,6 @@
 
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Change;
@@ -24,26 +23,28 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.permissions.ChangePermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Provider;
 
-class EqualsLabelPredicate extends ChangeIndexPredicate {
-  private final ProjectCache projectCache;
-  private final ChangeControl.GenericFactory ccFactory;
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final Provider<ReviewDb> dbProvider;
-  private final String label;
-  private final int expVal;
-  private final Account.Id account;
-  private final AccountGroup.UUID group;
+public class EqualsLabelPredicate extends ChangeIndexPredicate {
+  protected final ProjectCache projectCache;
+  protected final PermissionBackend permissionBackend;
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final Provider<ReviewDb> dbProvider;
+  protected final String label;
+  protected final int expVal;
+  protected final Account.Id account;
+  protected final AccountGroup.UUID group;
 
-  EqualsLabelPredicate(LabelPredicate.Args args, String label, int expVal, Account.Id account) {
-    super(args.field, ChangeField.formatLabel(label, expVal, account));
-    this.ccFactory = args.ccFactory;
+  public EqualsLabelPredicate(
+      LabelPredicate.Args args, String label, int expVal, Account.Id account) {
+    super(ChangeField.LABEL, ChangeField.formatLabel(label, expVal, account));
+    this.permissionBackend = args.permissionBackend;
     this.projectCache = args.projectCache;
     this.userFactory = args.userFactory;
     this.dbProvider = args.dbProvider;
@@ -78,7 +79,7 @@
     for (PatchSetApproval p : object.currentApprovals()) {
       if (labelType.matches(p)) {
         hasVote = true;
-        if (match(c, p.getValue(), p.getAccountId(), labelType)) {
+        if (match(object, p.getValue(), p.getAccountId())) {
           return true;
         }
       }
@@ -91,7 +92,7 @@
     return false;
   }
 
-  private static LabelType type(LabelTypes types, String toFind) {
+  protected static LabelType type(LabelTypes types, String toFind) {
     if (types.byLabel(toFind) != null) {
       return types.byLabel(toFind);
     }
@@ -104,40 +105,28 @@
     return null;
   }
 
-  private boolean match(Change change, int value, Account.Id approver, LabelType type)
-      throws OrmException {
-    int psVal = value;
-    if (psVal == expVal) {
-      // Double check the value is still permitted for the user.
-      //
-      IdentifiedUser reviewer = userFactory.create(approver);
-      try {
-        ChangeControl cc = ccFactory.controlFor(dbProvider.get(), change, reviewer);
-        if (!cc.isVisible(dbProvider.get())) {
-          // The user can't see the change anymore.
-          //
-          return false;
-        }
-        psVal = cc.getRange(Permission.forLabel(type.getName())).squash(psVal);
-      } catch (NoSuchChangeException e) {
-        // The project has disappeared.
-        //
-        return false;
-      }
-
-      if (account != null && !account.equals(approver)) {
-        return false;
-      }
-
-      if (group != null && !reviewer.getEffectiveGroups().contains(group)) {
-        return false;
-      }
-
-      if (psVal == expVal) {
-        return true;
-      }
+  protected boolean match(ChangeData cd, short value, Account.Id approver) {
+    if (value != expVal) {
+      return false;
     }
-    return false;
+
+    if (account != null && !account.equals(approver)) {
+      return false;
+    }
+
+    IdentifiedUser reviewer = userFactory.create(approver);
+    if (group != null && !reviewer.getEffectiveGroups().contains(group)) {
+      return false;
+    }
+
+    // Check the user has 'READ' permission.
+    try {
+      PermissionBackend.ForChange perm =
+          permissionBackend.user(reviewer).database(dbProvider).change(cd);
+      return perm.test(ChangePermission.READ);
+    } catch (PermissionBackendException e) {
+      return false;
+    }
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
index 9d841f3..fc00283 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/EqualsPathPredicate.java
@@ -16,21 +16,24 @@
 
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
 import java.util.Collections;
 import java.util.List;
 
-class EqualsPathPredicate extends ChangeIndexPredicate {
-  private final String value;
-
-  EqualsPathPredicate(String fieldName, String value) {
+public class EqualsPathPredicate extends ChangeIndexPredicate {
+  public EqualsPathPredicate(String fieldName, String value) {
     super(ChangeField.PATH, fieldName, value);
-    this.value = value;
   }
 
   @Override
   public boolean match(ChangeData object) throws OrmException {
-    List<String> files = object.currentFilePaths();
-    return files != null && Collections.binarySearch(files, value) >= 0;
+    List<String> files;
+    try {
+      files = object.currentFilePaths();
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+    return Collections.binarySearch(files, value) >= 0;
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
new file mode 100644
index 0000000..bca5d3b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactAuthorPredicate.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.gerrit.server.index.change.ChangeField.EXACT_AUTHOR;
+import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTAUTHOR;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.Locale;
+
+public class ExactAuthorPredicate extends ChangeIndexPredicate {
+  public ExactAuthorPredicate(String value) {
+    super(EXACT_AUTHOR, FIELD_EXACTAUTHOR, value.toLowerCase(Locale.US));
+  }
+
+  @Override
+  public boolean match(ChangeData object) throws OrmException {
+    try {
+      return ChangeField.getAuthorNameAndEmail(object).contains(getValue());
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
new file mode 100644
index 0000000..3fae5e5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactCommitterPredicate.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.gerrit.server.index.change.ChangeField.EXACT_COMMITTER;
+import static com.google.gerrit.server.query.change.ChangeQueryBuilder.FIELD_EXACTCOMMITTER;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
+import java.util.Locale;
+
+public class ExactCommitterPredicate extends ChangeIndexPredicate {
+  public ExactCommitterPredicate(String value) {
+    super(EXACT_COMMITTER, FIELD_EXACTCOMMITTER, value.toLowerCase(Locale.US));
+  }
+
+  @Override
+  public boolean match(ChangeData object) throws OrmException {
+    try {
+      return ChangeField.getCommitterNameAndEmail(object).contains(getValue());
+    } catch (IOException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
index 510910e..138cce5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ExactTopicPredicate.java
@@ -19,13 +19,13 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
 
-class ExactTopicPredicate extends ChangeIndexPredicate {
-  ExactTopicPredicate(String topic) {
+public class ExactTopicPredicate extends ChangeIndexPredicate {
+  public ExactTopicPredicate(String topic) {
     super(EXACT_TOPIC, topic);
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     Change change = object.change();
     if (change == null) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
index 5651544..545b668 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/FuzzyTopicPredicate.java
@@ -17,23 +17,23 @@
 import static com.google.gerrit.server.index.change.ChangeField.FUZZY_TOPIC;
 
 import com.google.common.collect.Iterables;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
-class FuzzyTopicPredicate extends ChangeIndexPredicate {
-  private final ChangeIndex index;
+public class FuzzyTopicPredicate extends ChangeIndexPredicate {
+  protected final ChangeIndex index;
 
-  FuzzyTopicPredicate(String topic, ChangeIndex index) {
+  public FuzzyTopicPredicate(String topic, ChangeIndex index) {
     super(FUZZY_TOPIC, topic);
     this.index = index;
   }
 
   @Override
-  public boolean match(final ChangeData cd) throws OrmException {
+  public boolean match(ChangeData cd) throws OrmException {
     Change change = cd.change();
     if (change == null) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
index 54e1c97..d2645dc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/GroupPredicate.java
@@ -19,8 +19,8 @@
 import com.google.gwtorm.server.OrmException;
 import java.util.List;
 
-class GroupPredicate extends ChangeIndexPredicate {
-  GroupPredicate(String group) {
+public class GroupPredicate extends ChangeIndexPredicate {
+  public GroupPredicate(String group) {
     super(ChangeField.GROUP, group);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
index 244589c..e422b74 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasDraftByPredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class HasDraftByPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
+public class HasDraftByPredicate extends ChangeIndexPredicate {
+  protected final Account.Id accountId;
 
-  HasDraftByPredicate(Account.Id accountId) {
+  public HasDraftByPredicate(Account.Id accountId) {
     super(ChangeField.DRAFTBY, accountId.toString());
     this.accountId = accountId;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
index eb3a137..b17fffd 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HasStarsPredicate.java
@@ -19,9 +19,9 @@
 import com.google.gwtorm.server.OrmException;
 
 public class HasStarsPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
+  protected final Account.Id accountId;
 
-  HasStarsPredicate(Account.Id accountId) {
+  public HasStarsPredicate(Account.Id accountId) {
     super(ChangeField.STARBY, accountId.toString());
     this.accountId = accountId;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
index 4fd4156..bea5688 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/HashtagPredicate.java
@@ -18,13 +18,13 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class HashtagPredicate extends ChangeIndexPredicate {
-  HashtagPredicate(String hashtag) {
+public class HashtagPredicate extends ChangeIndexPredicate {
+  public HashtagPredicate(String hashtag) {
     super(ChangeField.HASHTAG, HashtagsUtil.cleanupHashtag(hashtag));
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     for (String hashtag : object.notes().load().getHashtags()) {
       if (hashtag.equalsIgnoreCase(getValue())) {
         return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
index d4f5620..312c04e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IntegerRangeChangePredicate.java
@@ -14,10 +14,10 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IntegerRangePredicate;
-import com.google.gerrit.server.query.Matchable;
-import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.IntegerRangePredicate;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.QueryParseException;
 
 public abstract class IntegerRangeChangePredicate extends IntegerRangePredicate<ChangeData>
     implements Matchable<ChangeData> {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
index fa2f5fe..4d10c0e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/InternalChangeQuery.java
@@ -15,25 +15,25 @@
 package com.google.gerrit.server.query.change;
 
 import static com.google.common.base.Preconditions.checkArgument;
-import static com.google.gerrit.server.query.Predicate.and;
-import static com.google.gerrit.server.query.Predicate.not;
-import static com.google.gerrit.server.query.Predicate.or;
+import static com.google.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.index.query.Predicate.not;
+import static com.google.gerrit.index.query.Predicate.or;
 import static com.google.gerrit.server.query.change.ChangeStatusPredicate.open;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Sets;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.InternalQuery;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.index.IndexConfig;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.query.InternalQuery;
-import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -46,6 +46,12 @@
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
 
+/**
+ * Query wrapper for the change index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
 public class InternalChangeQuery extends InternalQuery<ChangeData> {
   private static Predicate<ChangeData> ref(Branch.NameKey branch) {
     return new RefPredicate(branch.get());
@@ -60,7 +66,7 @@
   }
 
   private static Predicate<ChangeData> status(Change.Status status) {
-    return new ChangeStatusPredicate(status);
+    return ChangeStatusPredicate.forStatus(status);
   }
 
   private static Predicate<ChangeData> commit(String id) {
@@ -169,7 +175,7 @@
   }
 
   private Iterable<ChangeData> byCommitsOnBranchNotMergedFromDatabase(
-      Repository repo, final ReviewDb db, final Branch.NameKey branch, Collection<String> hashes)
+      Repository repo, ReviewDb db, Branch.NameKey branch, Collection<String> hashes)
       throws OrmException, IOException {
     Set<Change.Id> changeIds = Sets.newHashSetWithExpectedSize(hashes.size());
     String lastPrefix = null;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
deleted file mode 100644
index 50e5bd9..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergePredicate.java
+++ /dev/null
@@ -1,50 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.git.CodeReviewCommit;
-import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
-import com.google.gwtorm.server.OrmException;
-import java.io.IOException;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-
-public class IsMergePredicate extends ChangeOperatorPredicate {
-  private final Arguments args;
-
-  public IsMergePredicate(Arguments args, String value) {
-    super(ChangeQueryBuilder.FIELD_MERGE, value);
-    this.args = args;
-  }
-
-  @Override
-  public boolean match(ChangeData cd) throws OrmException {
-    ObjectId id = ObjectId.fromString(cd.currentPatchSet().getRevision().get());
-    try (Repository repo = args.repoManager.openRepository(cd.change().getProject());
-        RevWalk rw = CodeReviewCommit.newRevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(id);
-      return commit.getParentCount() > 1;
-    } catch (IOException e) {
-      throw new OrmException(e);
-    }
-  }
-
-  @Override
-  public int getCost() {
-    return 2;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
deleted file mode 100644
index d998fa3..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsMergeablePredicate.java
+++ /dev/null
@@ -1,38 +0,0 @@
-// Copyright (C) 2013 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query.change;
-
-import com.google.gerrit.server.index.FieldDef.FillArgs;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gwtorm.server.OrmException;
-
-class IsMergeablePredicate extends ChangeIndexPredicate {
-  private final FillArgs args;
-
-  IsMergeablePredicate(FillArgs args) {
-    super(ChangeField.MERGEABLE, "1");
-    this.args = args;
-  }
-
-  @Override
-  public boolean match(ChangeData object) throws OrmException {
-    return getValue().equals(getField().get(object, args));
-  }
-
-  @Override
-  public int getCost() {
-    return 1;
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
index 92de09a..7ff5a28 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsReviewedPredicate.java
@@ -16,23 +16,23 @@
 
 import static com.google.gerrit.server.index.change.ChangeField.REVIEWEDBY;
 
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.OrmException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.List;
 import java.util.Set;
 
-class IsReviewedPredicate extends ChangeIndexPredicate {
-  private static final Account.Id NOT_REVIEWED = new Account.Id(ChangeField.NOT_REVIEWED);
+public class IsReviewedPredicate extends ChangeIndexPredicate {
+  protected static final Account.Id NOT_REVIEWED = new Account.Id(ChangeField.NOT_REVIEWED);
 
-  static Predicate<ChangeData> create() {
+  public static Predicate<ChangeData> create() {
     return Predicate.not(new IsReviewedPredicate(NOT_REVIEWED));
   }
 
-  static Predicate<ChangeData> create(Collection<Account.Id> ids) {
+  public static Predicate<ChangeData> create(Collection<Account.Id> ids) {
     List<Predicate<ChangeData>> predicates = new ArrayList<>(ids.size());
     for (Account.Id id : ids) {
       predicates.add(new IsReviewedPredicate(id));
@@ -40,7 +40,7 @@
     return Predicate.or(predicates);
   }
 
-  private final Account.Id id;
+  protected final Account.Id id;
 
   private IsReviewedPredicate(Account.Id id) {
     super(REVIEWEDBY, Integer.toString(id.get()));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
index 17a6347..225dc454 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsUnresolvedPredicate.java
@@ -14,21 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 public class IsUnresolvedPredicate extends IntegerRangeChangePredicate {
-  IsUnresolvedPredicate() throws QueryParseException {
+  public IsUnresolvedPredicate() throws QueryParseException {
     this(">0");
   }
 
-  IsUnresolvedPredicate(String value) throws QueryParseException {
+  public IsUnresolvedPredicate(String value) throws QueryParseException {
     super(ChangeField.UNRESOLVED_COMMENT_COUNT, value);
   }
 
   @Override
   protected Integer getValueInt(ChangeData changeData) throws OrmException {
-    return ChangeField.UNRESOLVED_COMMENT_COUNT.get(changeData, null);
+    return ChangeField.UNRESOLVED_COMMENT_COUNT.get(changeData);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
index dda834b..90eb8e4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/IsWatchedByPredicate.java
@@ -15,34 +15,34 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.query.AndPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.WatchConfig.ProjectWatchKey;
-import com.google.gerrit.server.query.AndPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryBuilder;
-import com.google.gerrit.server.query.QueryParseException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.List;
 
-class IsWatchedByPredicate extends AndPredicate<ChangeData> {
-  private static String describe(CurrentUser user) {
+public class IsWatchedByPredicate extends AndPredicate<ChangeData> {
+  protected static String describe(CurrentUser user) {
     if (user.isIdentifiedUser()) {
       return user.getAccountId().toString();
     }
     return user.toString();
   }
 
-  private final CurrentUser user;
+  protected final CurrentUser user;
 
-  IsWatchedByPredicate(ChangeQueryBuilder.Arguments args, boolean checkIsVisible)
+  public IsWatchedByPredicate(ChangeQueryBuilder.Arguments args, boolean checkIsVisible)
       throws QueryParseException {
     super(filters(args, checkIsVisible));
     this.user = args.getUser();
   }
 
-  private static List<Predicate<ChangeData>> filters(
+  protected static List<Predicate<ChangeData>> filters(
       ChangeQueryBuilder.Arguments args, boolean checkIsVisible) throws QueryParseException {
     List<Predicate<ChangeData>> r = new ArrayList<>();
     ChangeQueryBuilder builder = new ChangeQueryBuilder(args);
@@ -89,7 +89,7 @@
     }
   }
 
-  private static Collection<ProjectWatchKey> getWatches(ChangeQueryBuilder.Arguments args)
+  protected static Collection<ProjectWatchKey> getWatches(ChangeQueryBuilder.Arguments args)
       throws QueryParseException {
     CurrentUser user = args.getUser();
     if (user.isIdentifiedUser()) {
@@ -98,7 +98,7 @@
     return Collections.<ProjectWatchKey>emptySet();
   }
 
-  private static List<Predicate<ChangeData>> none() {
+  protected static List<Predicate<ChangeData>> none() {
     Predicate<ChangeData> any = any();
     return ImmutableList.of(not(any));
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
index 2fbaa1e..f8bd2e3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LabelPredicate.java
@@ -15,49 +15,44 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.common.collect.Lists;
+import com.google.gerrit.index.query.OrPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.RangeUtil;
+import com.google.gerrit.index.query.RangeUtil.Range;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.query.OrPredicate;
-import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.util.LabelVote;
-import com.google.gerrit.server.util.RangeUtil;
-import com.google.gerrit.server.util.RangeUtil.Range;
 import com.google.inject.Provider;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
 
 public class LabelPredicate extends OrPredicate<ChangeData> {
-  private static final int MAX_LABEL_VALUE = 4;
+  protected static final int MAX_LABEL_VALUE = 4;
 
-  static class Args {
-    final FieldDef<ChangeData, ?> field;
-    final ProjectCache projectCache;
-    final ChangeControl.GenericFactory ccFactory;
-    final IdentifiedUser.GenericFactory userFactory;
-    final Provider<ReviewDb> dbProvider;
-    final String value;
-    final Set<Account.Id> accounts;
-    final AccountGroup.UUID group;
+  protected static class Args {
+    protected final ProjectCache projectCache;
+    protected final PermissionBackend permissionBackend;
+    protected final IdentifiedUser.GenericFactory userFactory;
+    protected final Provider<ReviewDb> dbProvider;
+    protected final String value;
+    protected final Set<Account.Id> accounts;
+    protected final AccountGroup.UUID group;
 
-    private Args(
-        FieldDef<ChangeData, ?> field,
+    protected Args(
         ProjectCache projectCache,
-        ChangeControl.GenericFactory ccFactory,
+        PermissionBackend permissionBackend,
         IdentifiedUser.GenericFactory userFactory,
         Provider<ReviewDb> dbProvider,
         String value,
         Set<Account.Id> accounts,
         AccountGroup.UUID group) {
-      this.field = field;
       this.projectCache = projectCache;
-      this.ccFactory = ccFactory;
+      this.permissionBackend = permissionBackend;
       this.userFactory = userFactory;
       this.dbProvider = dbProvider;
       this.value = value;
@@ -66,22 +61,21 @@
     }
   }
 
-  private static class Parsed {
-    private final String label;
-    private final String test;
-    private final int expVal;
+  protected static class Parsed {
+    protected final String label;
+    protected final String test;
+    protected final int expVal;
 
-    private Parsed(String label, String test, int expVal) {
+    protected Parsed(String label, String test, int expVal) {
       this.label = label;
       this.test = test;
       this.expVal = expVal;
     }
   }
 
-  private final String value;
+  protected final String value;
 
-  @SuppressWarnings("deprecation")
-  LabelPredicate(
+  public LabelPredicate(
       ChangeQueryBuilder.Arguments a,
       String value,
       Set<Account.Id> accounts,
@@ -89,18 +83,11 @@
     super(
         predicates(
             new Args(
-                a.getSchema().getField(ChangeField.LABEL2, ChangeField.LABEL).get(),
-                a.projectCache,
-                a.changeControlGenericFactory,
-                a.userFactory,
-                a.db,
-                value,
-                accounts,
-                group)));
+                a.projectCache, a.permissionBackend, a.userFactory, a.db, value, accounts, group)));
     this.value = value;
   }
 
-  private static List<Predicate<ChangeData>> predicates(Args args) {
+  protected static List<Predicate<ChangeData>> predicates(Args args) {
     String v = args.value;
     Parsed parsed = null;
 
@@ -140,14 +127,14 @@
     return r;
   }
 
-  private static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> onePredicate(Args args, String label, int expVal) {
     if (expVal != 0) {
       return equalsLabelPredicate(args, label, expVal);
     }
     return noLabelQuery(args, label);
   }
 
-  private static Predicate<ChangeData> noLabelQuery(Args args, String label) {
+  protected static Predicate<ChangeData> noLabelQuery(Args args, String label) {
     List<Predicate<ChangeData>> r = Lists.newArrayListWithCapacity(2 * MAX_LABEL_VALUE);
     for (int i = 1; i <= MAX_LABEL_VALUE; i++) {
       r.add(equalsLabelPredicate(args, label, i));
@@ -156,7 +143,7 @@
     return not(or(r));
   }
 
-  private static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
+  protected static Predicate<ChangeData> equalsLabelPredicate(Args args, String label, int expVal) {
     if (args.accounts == null || args.accounts.isEmpty()) {
       return new EqualsLabelPredicate(args, label, expVal, null);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
index f7f98d5..fe4d4e1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/LegacyChangeIdPredicate.java
@@ -20,7 +20,7 @@
 
 /** Predicate over change number (aka legacy ID or Change.Id). */
 public class LegacyChangeIdPredicate extends ChangeIndexPredicate {
-  private final Change.Id id;
+  protected final Change.Id id;
 
   public LegacyChangeIdPredicate(Change.Id id) {
     super(LEGACY_ID, ChangeQueryBuilder.FIELD_CHANGE, id.toString());
@@ -28,7 +28,7 @@
   }
 
   @Override
-  public boolean match(final ChangeData object) {
+  public boolean match(ChangeData object) {
     return id.equals(object.getId());
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
index 9e525c2..0cfcedb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/MessagePredicate.java
@@ -14,18 +14,18 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndex;
 import com.google.gerrit.server.index.change.IndexedChangeQuery;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 /** Predicate to match changes that contains specified text in commit messages body. */
-class MessagePredicate extends ChangeIndexPredicate {
-  private final ChangeIndex index;
+public class MessagePredicate extends ChangeIndexPredicate {
+  protected final ChangeIndex index;
 
-  MessagePredicate(ChangeIndex index, String value) {
+  public MessagePredicate(ChangeIndex index, String value) {
     super(ChangeField.COMMIT_MESSAGE, value);
     this.index = index;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
index 90c2fb3..a703852 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OrSource.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.gerrit.index.query.OrPredicate;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.query.OrPredicate;
-import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.ListResultSet;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.ResultSet;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
index cd98087..eef79b2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OutputStreamQuery.java
@@ -19,6 +19,8 @@
 
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.LabelTypes;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -29,10 +31,7 @@
 import com.google.gerrit.server.data.QueryStatsAttribute;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.SubmitRuleEvaluator;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.QueryResult;
 import com.google.gson.Gson;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -56,7 +55,12 @@
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-/** Change query implementation that outputs to a stream in the style of an SSH command. */
+/**
+ * Change query implementation that outputs to a stream in the style of an SSH command.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
 public class OutputStreamQuery {
   private static final Logger log = LoggerFactory.getLogger(OutputStreamQuery.class);
 
@@ -74,6 +78,7 @@
   private final EventFactory eventFactory;
   private final TrackingFooters trackingFooters;
   private final CurrentUser user;
+  private final SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory;
 
   private OutputFormat outputFormat = OutputFormat.TEXT;
   private boolean includePatchSets;
@@ -97,7 +102,8 @@
       ChangeQueryProcessor queryProcessor,
       EventFactory eventFactory,
       TrackingFooters trackingFooters,
-      CurrentUser user) {
+      CurrentUser user,
+      SubmitRuleEvaluator.Factory submitRuleEvaluatorFactory) {
     this.db = db;
     this.repoManager = repoManager;
     this.queryBuilder = queryBuilder;
@@ -105,10 +111,11 @@
     this.eventFactory = eventFactory;
     this.trackingFooters = trackingFooters;
     this.user = user;
+    this.submitRuleEvaluatorFactory = submitRuleEvaluatorFactory;
   }
 
   void setLimit(int n) {
-    queryProcessor.setLimit(n);
+    queryProcessor.setUserProvidedLimit(n);
   }
 
   public void setStart(int n) {
@@ -228,14 +235,12 @@
   private ChangeAttribute buildChangeAttribute(
       ChangeData d, Map<Project.NameKey, Repository> repos, Map<Project.NameKey, RevWalk> revWalks)
       throws OrmException, IOException {
-    ChangeControl cc = d.changeControl().forUser(user);
-
-    LabelTypes labelTypes = cc.getLabelTypes();
+    LabelTypes labelTypes = d.getLabelTypes();
     ChangeAttribute c = eventFactory.asChangeAttribute(db, d.change());
     eventFactory.extend(c, d.change());
 
     if (!trackingFooters.isEmpty()) {
-      eventFactory.addTrackingIds(c, trackingFooters.extract(d.commitFooters()));
+      eventFactory.addTrackingIds(c, d.trackingFooters());
     }
 
     if (includeAllReviewers) {
@@ -244,7 +249,7 @@
 
     if (includeSubmitRecords) {
       eventFactory.addSubmitRecords(
-          c, new SubmitRuleEvaluator(d).setAllowClosed(true).setAllowDraft(true).evaluate());
+          c, submitRuleEvaluatorFactory.create(user, d).setAllowClosed(true).evaluate());
     }
 
     if (includeCommitMessage) {
@@ -269,7 +274,7 @@
           db,
           rw,
           c,
-          d.visiblePatchSets(),
+          d.patchSets(),
           includeApprovals ? d.approvals().asMap() : null,
           includeFiles,
           d.change(),
@@ -278,7 +283,7 @@
 
     if (includeCurrentPatchSet) {
       PatchSet current = d.currentPatchSet();
-      if (current != null && cc.isPatchVisible(current, d.db())) {
+      if (current != null) {
         c.currentPatchSet = eventFactory.asPatchSetAttribute(db, rw, d.change(), current);
         eventFactory.addApprovals(c.currentPatchSet, d.currentApprovals(), labelTypes);
 
@@ -298,7 +303,7 @@
             db,
             rw,
             c,
-            d.visiblePatchSets(),
+            d.patchSets(),
             includeApprovals ? d.approvals().asMap() : null,
             includeFiles,
             d.change(),
@@ -313,6 +318,7 @@
       eventFactory.addDependencies(rw, c, d.change(), d.currentPatchSet());
     }
 
+    c.plugins = queryProcessor.create(d);
     return c;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
index dfaac08..ff494fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerPredicate.java
@@ -19,20 +19,20 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class OwnerPredicate extends ChangeIndexPredicate {
-  private final Account.Id id;
+public class OwnerPredicate extends ChangeIndexPredicate {
+  protected final Account.Id id;
 
-  OwnerPredicate(Account.Id id) {
+  public OwnerPredicate(Account.Id id) {
     super(ChangeField.OWNER, id.toString());
     this.id = id;
   }
 
-  Account.Id getAccountId() {
+  protected Account.Id getAccountId() {
     return id;
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     Change change = object.change();
     return change != null && id.equals(change.getOwner());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
index f3239af..fec7f26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/OwnerinPredicate.java
@@ -19,22 +19,22 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gwtorm.server.OrmException;
 
-class OwnerinPredicate extends ChangeOperatorPredicate {
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final AccountGroup.UUID uuid;
+public class OwnerinPredicate extends ChangeOperatorPredicate {
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final AccountGroup.UUID uuid;
 
-  OwnerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+  public OwnerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_OWNERIN, uuid.toString());
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
 
-  AccountGroup.UUID getAccountGroupUUID() {
+  protected AccountGroup.UUID getAccountGroupUUID() {
     return uuid;
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     final Change change = object.change();
     if (change == null) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
index d3a3f20..19c0515 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ParentProjectPredicate.java
@@ -15,23 +15,28 @@
 package com.google.gerrit.server.query.change;
 
 import com.google.gerrit.extensions.common.ProjectInfo;
+import com.google.gerrit.index.query.OrPredicate;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectResource;
 import com.google.gerrit.server.project.ProjectState;
-import com.google.gerrit.server.query.OrPredicate;
-import com.google.gerrit.server.query.Predicate;
 import com.google.inject.Provider;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
 
-class ParentProjectPredicate extends OrPredicate<ChangeData> {
-  private final String value;
+public class ParentProjectPredicate extends OrPredicate<ChangeData> {
+  private static final Logger log = LoggerFactory.getLogger(ParentProjectPredicate.class);
 
-  ParentProjectPredicate(
+  protected final String value;
+
+  public ParentProjectPredicate(
       ProjectCache projectCache,
       Provider<ListChildProjects> listChildProjects,
       Provider<CurrentUser> self,
@@ -40,7 +45,7 @@
     this.value = value;
   }
 
-  private static List<Predicate<ChangeData>> predicates(
+  protected static List<Predicate<ChangeData>> predicates(
       ProjectCache projectCache,
       Provider<ListChildProjects> listChildProjects,
       Provider<CurrentUser> self,
@@ -51,11 +56,16 @@
     }
 
     List<Predicate<ChangeData>> r = new ArrayList<>();
-    r.add(new ProjectPredicate(projectState.getProject().getName()));
-    ListChildProjects children = listChildProjects.get();
-    children.setRecursive(true);
-    for (ProjectInfo p : children.apply(new ProjectResource(projectState.controlFor(self.get())))) {
-      r.add(new ProjectPredicate(p.name));
+    r.add(new ProjectPredicate(projectState.getName()));
+    try {
+      ProjectResource proj = new ProjectResource(projectState.controlFor(self.get()));
+      ListChildProjects children = listChildProjects.get();
+      children.setRecursive(true);
+      for (ProjectInfo p : children.apply(proj)) {
+        r.add(new ProjectPredicate(p.name));
+      }
+    } catch (PermissionBackendException e) {
+      log.warn("cannot check permissions to expand child projects", e);
     }
     return r;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
new file mode 100644
index 0000000..a795025
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PluginDefinedAttributesFactory.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import com.google.gerrit.extensions.common.PluginDefinedInfo;
+import java.util.List;
+
+public interface PluginDefinedAttributesFactory {
+  List<PluginDefinedInfo> create(ChangeData cd);
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java
index 1fbc1aa..ad7a57d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/PredicateArgs.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.index.query.QueryParseException;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
index 644870d..09a46a4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPredicate.java
@@ -19,17 +19,17 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class ProjectPredicate extends ChangeIndexPredicate {
-  ProjectPredicate(String id) {
+public class ProjectPredicate extends ChangeIndexPredicate {
+  public ProjectPredicate(String id) {
     super(ChangeField.PROJECT, id);
   }
 
-  Project.NameKey getValueKey() {
+  protected Project.NameKey getValueKey() {
     return new Project.NameKey(getValue());
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     Change change = object.change();
     if (change == null) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
index 4c06d1b..28b1302 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ProjectPrefixPredicate.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class ProjectPrefixPredicate extends ChangeIndexPredicate {
-  ProjectPrefixPredicate(String prefix) {
+public class ProjectPrefixPredicate extends ChangeIndexPredicate {
+  public ProjectPrefixPredicate(String prefix) {
     super(ChangeField.PROJECTS, prefix);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
index cc1b95d..73bb1d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/QueryChanges.java
@@ -14,9 +14,6 @@
 
 package com.google.gerrit.server.query.change;
 
-import static com.google.gerrit.extensions.client.ListChangesOption.DETAILED_LABELS;
-import static com.google.gerrit.extensions.client.ListChangesOption.LABELS;
-
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
@@ -26,11 +23,10 @@
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestReadView;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.index.query.QueryRequiresAuthException;
+import com.google.gerrit.index.query.QueryResult;
 import com.google.gerrit.server.change.ChangeJson;
-import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.QueryParseException;
-import com.google.gerrit.server.query.QueryRequiresAuthException;
-import com.google.gerrit.server.query.QueryResult;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.util.ArrayList;
@@ -58,7 +54,7 @@
       metaVar = "CNT",
       usage = "Maximum number of results to return")
   public void setLimit(int limit) {
-    imp.setLimit(limit);
+    imp.setUserProvidedLimit(limit);
   }
 
   @Option(name = "-o", usage = "Output options per change")
@@ -128,13 +124,14 @@
 
     int cnt = queries.size();
     List<QueryResult<ChangeData>> results = imp.query(qb.parse(queries));
-    boolean requireLazyLoad =
-        containsAnyOf(options, ImmutableSet.of(DETAILED_LABELS, LABELS))
-            && !qb.getArgs().getSchema().hasField(ChangeField.STORED_SUBMIT_RECORD_LENIENT);
+
+    ChangeJson cjson = json.create(options);
+    cjson.setPluginDefinedAttributesFactory(this.imp);
     List<List<ChangeInfo>> res =
-        json.create(options)
-            .lazyLoad(requireLazyLoad || containsAnyOf(options, ChangeJson.REQUIRE_LAZY_LOAD))
+        cjson
+            .lazyLoad(containsAnyOf(options, ChangeJson.REQUIRE_LAZY_LOAD))
             .formatQueryResults(results);
+
     for (int n = 0; n < cnt; n++) {
       List<ChangeInfo> info = res.get(n);
       if (results.get(n).more() && !info.isEmpty()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
index 491aed9..c9314e4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RefPredicate.java
@@ -18,13 +18,13 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class RefPredicate extends ChangeIndexPredicate {
-  RefPredicate(String ref) {
+public class RefPredicate extends ChangeIndexPredicate {
+  public RefPredicate(String ref) {
     super(ChangeField.REF, ref);
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     Change change = object.change();
     if (change == null) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
index 5b9774c..46b4cd5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexPathPredicate.java
@@ -17,24 +17,23 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.util.RegexListSearcher;
 import com.google.gwtorm.server.OrmException;
+import java.io.IOException;
 import java.util.List;
 
-class RegexPathPredicate extends ChangeRegexPredicate {
-  RegexPathPredicate(String re) {
+public class RegexPathPredicate extends ChangeRegexPredicate {
+  public RegexPathPredicate(String re) {
     super(ChangeField.PATH, re);
   }
 
   @Override
   public boolean match(ChangeData object) throws OrmException {
-    List<String> files = object.currentFilePaths();
-    if (files != null) {
-      return RegexListSearcher.ofStrings(getValue()).hasMatch(files);
+    List<String> files;
+    try {
+      files = object.currentFilePaths();
+    } catch (IOException e) {
+      throw new OrmException(e);
     }
-    // The ChangeData can't do expensive lookups right now. Bypass
-    // them and include the result anyway. We might be able to do
-    // a narrow later on to a smaller set.
-    //
-    return true;
+    return RegexListSearcher.ofStrings(getValue()).hasMatch(files);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
index 1284e88..1efc77d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexProjectPredicate.java
@@ -21,10 +21,10 @@
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexProjectPredicate extends ChangeRegexPredicate {
-  private final RunAutomaton pattern;
+public class RegexProjectPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
 
-  RegexProjectPredicate(String re) {
+  public RegexProjectPredicate(String re) {
     super(ChangeField.PROJECT, re);
 
     if (re.startsWith("^")) {
@@ -39,7 +39,7 @@
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     Change change = object.change();
     if (change == null) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
index 671d4cc..92abafb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexRefPredicate.java
@@ -20,10 +20,10 @@
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexRefPredicate extends ChangeRegexPredicate {
-  private final RunAutomaton pattern;
+public class RegexRefPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
 
-  RegexRefPredicate(String re) {
+  public RegexRefPredicate(String re) {
     super(ChangeField.REF, re);
 
     if (re.startsWith("^")) {
@@ -38,7 +38,7 @@
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     Change change = object.change();
     if (change == null) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
index a4ba059..2b58c88 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RegexTopicPredicate.java
@@ -21,10 +21,10 @@
 import dk.brics.automaton.RegExp;
 import dk.brics.automaton.RunAutomaton;
 
-class RegexTopicPredicate extends ChangeRegexPredicate {
-  private final RunAutomaton pattern;
+public class RegexTopicPredicate extends ChangeRegexPredicate {
+  protected final RunAutomaton pattern;
 
-  RegexTopicPredicate(String re) {
+  public RegexTopicPredicate(String re) {
     super(EXACT_TOPIC, re);
 
     if (re.startsWith("^")) {
@@ -39,7 +39,7 @@
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
+  public boolean match(ChangeData object) throws OrmException {
     Change change = object.change();
     if (change == null || change.getTopic() == null) {
       return false;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RevertOfPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
new file mode 100644
index 0000000..7f4ade0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/RevertOfPredicate.java
@@ -0,0 +1,37 @@
+// 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.change;
+
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gwtorm.server.OrmException;
+
+public class RevertOfPredicate extends ChangeIndexPredicate {
+  public RevertOfPredicate(String revertOf) {
+    super(ChangeField.REVERT_OF, revertOf);
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    if (cd.change().getRevertOf() == null) {
+      return false;
+    }
+    return cd.change().getRevertOf().toString().equals(value);
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
new file mode 100644
index 0000000..f4e979c
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerByEmailPredicate.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.query.change;
+
+import static com.google.common.base.Preconditions.checkArgument;
+
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.mail.Address;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+import com.google.gwtorm.server.OrmException;
+
+class ReviewerByEmailPredicate extends ChangeIndexPredicate {
+
+  static Predicate<ChangeData> forState(Address adr, ReviewerStateInternal state) {
+    checkArgument(state != ReviewerStateInternal.REMOVED, "can't query by removed reviewer");
+    return new ReviewerByEmailPredicate(state, adr);
+  }
+
+  private final ReviewerStateInternal state;
+  private final Address adr;
+
+  private ReviewerByEmailPredicate(ReviewerStateInternal state, Address adr) {
+    super(ChangeField.REVIEWER_BY_EMAIL, ChangeField.getReviewerByEmailFieldValue(state, adr));
+    this.state = state;
+    this.adr = adr;
+  }
+
+  Address getAddress() {
+    return adr;
+  }
+
+  @Override
+  public boolean match(ChangeData cd) throws OrmException {
+    return cd.reviewersByEmail().asTable().get(state, adr) != null;
+  }
+
+  @Override
+  public int getCost() {
+    return 1;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
index 6ce02fb..5364a66 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerPredicate.java
@@ -14,39 +14,41 @@
 
 package com.google.gerrit.server.query.change;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static java.util.stream.Collectors.toList;
 
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.notedb.ReviewerStateInternal;
-import com.google.gerrit.server.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder.Arguments;
 import com.google.gwtorm.server.OrmException;
 import java.util.stream.Stream;
 
-class ReviewerPredicate extends ChangeIndexPredicate {
-  static Predicate<ChangeData> reviewer(Arguments args, Account.Id id) {
-    Predicate<ChangeData> p;
-    if (args.notesMigration.readChanges()) {
-      // With NoteDb, Reviewer/CC are clearly distinct states, so only choose reviewer.
-      p = new ReviewerPredicate(ReviewerStateInternal.REVIEWER, id);
-    } else {
-      // Without NoteDb, Reviewer/CC are a bit unpredictable; maintain the old behavior of matching
-      // any reviewer state.
-      p = anyReviewerState(id);
-    }
-    return create(args, p);
+public class ReviewerPredicate extends ChangeIndexPredicate {
+  protected static Predicate<ChangeData> forState(Account.Id id, ReviewerStateInternal state) {
+    checkArgument(state != ReviewerStateInternal.REMOVED, "can't query by removed reviewer");
+    return new ReviewerPredicate(state, id);
   }
 
-  static Predicate<ChangeData> cc(Arguments args, Account.Id id) {
+  protected static Predicate<ChangeData> reviewer(Arguments args, Account.Id id) {
+    if (args.notesMigration.readChanges()) {
+      // With NoteDb, Reviewer/CC are clearly distinct states, so only choose reviewer.
+      return new ReviewerPredicate(ReviewerStateInternal.REVIEWER, id);
+    }
+    // Without NoteDb, Reviewer/CC are a bit unpredictable; maintain the old behavior of matching
+    // any reviewer state.
+    return anyReviewerState(id);
+  }
+
+  protected static Predicate<ChangeData> cc(Account.Id id) {
     // As noted above, CC is nebulous without NoteDb, but it certainly doesn't make sense to return
     // Reviewers for cc:foo. Most likely this will just not match anything, but let the index sort
     // it out.
-    return create(args, new ReviewerPredicate(ReviewerStateInternal.CC, id));
+    return new ReviewerPredicate(ReviewerStateInternal.CC, id);
   }
 
-  private static Predicate<ChangeData> anyReviewerState(Account.Id id) {
+  protected static Predicate<ChangeData> anyReviewerState(Account.Id id) {
     return Predicate.or(
         Stream.of(ReviewerStateInternal.values())
             .filter(s -> s != ReviewerStateInternal.REMOVED)
@@ -54,17 +56,8 @@
             .collect(toList()));
   }
 
-  private static Predicate<ChangeData> create(Arguments args, Predicate<ChangeData> p) {
-    if (!args.allowsDrafts) {
-      // TODO(dborowitz): This really belongs much higher up e.g. QueryProcessor. Also, why are we
-      // even doing this?
-      return Predicate.and(p, Predicate.not(new ChangeStatusPredicate(Change.Status.DRAFT)));
-    }
-    return p;
-  }
-
-  private final ReviewerStateInternal state;
-  private final Account.Id id;
+  protected final ReviewerStateInternal state;
+  protected final Account.Id id;
 
   private ReviewerPredicate(ReviewerStateInternal state, Account.Id id) {
     super(ChangeField.REVIEWER, ChangeField.getReviewerFieldValue(state, id));
@@ -72,7 +65,7 @@
     this.id = id;
   }
 
-  Account.Id getAccountId() {
+  protected Account.Id getAccountId() {
     return id;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
index 63e7859..38f6561 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ReviewerinPredicate.java
@@ -17,25 +17,26 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
 import com.google.gwtorm.server.OrmException;
 
-class ReviewerinPredicate extends ChangeOperatorPredicate {
-  private final IdentifiedUser.GenericFactory userFactory;
-  private final AccountGroup.UUID uuid;
+public class ReviewerinPredicate extends ChangeOperatorPredicate {
+  protected final IdentifiedUser.GenericFactory userFactory;
+  protected final AccountGroup.UUID uuid;
 
-  ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
+  public ReviewerinPredicate(IdentifiedUser.GenericFactory userFactory, AccountGroup.UUID uuid) {
     super(ChangeQueryBuilder.FIELD_REVIEWERIN, uuid.toString());
     this.userFactory = userFactory;
     this.uuid = uuid;
   }
 
-  AccountGroup.UUID getAccountGroupUUID() {
+  protected AccountGroup.UUID getAccountGroupUUID() {
     return uuid;
   }
 
   @Override
-  public boolean match(final ChangeData object) throws OrmException {
-    for (Account.Id accountId : object.reviewers().all()) {
+  public boolean match(ChangeData object) throws OrmException {
+    for (Account.Id accountId : object.reviewers().byState(ReviewerStateInternal.REVIEWER)) {
       IdentifiedUser reviewer = userFactory.create(accountId);
       if (reviewer.getEffectiveGroups().contains(uuid)) {
         return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java
index 2661b8b..a084b35 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SingleGroupUser.java
@@ -14,25 +14,21 @@
 
 package com.google.gerrit.server.query.change;
 
+import com.google.common.collect.ImmutableSet;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
-import java.util.Collections;
 import java.util.Set;
 
 public final class SingleGroupUser extends CurrentUser {
   private final GroupMembership groups;
 
-  public SingleGroupUser(
-      CapabilityControl.Factory capabilityControlFactory, AccountGroup.UUID groupId) {
-    this(capabilityControlFactory, Collections.singleton(groupId));
+  public SingleGroupUser(AccountGroup.UUID groupId) {
+    this(ImmutableSet.of(groupId));
   }
 
-  public SingleGroupUser(
-      CapabilityControl.Factory capabilityControlFactory, Set<AccountGroup.UUID> groups) {
-    super(capabilityControlFactory);
+  public SingleGroupUser(Set<AccountGroup.UUID> groups) {
     this.groups = new ListGroupMembership(groups);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
index 98965bf..12d4753 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/StarPredicate.java
@@ -20,10 +20,10 @@
 import com.google.gwtorm.server.OrmException;
 
 public class StarPredicate extends ChangeIndexPredicate {
-  private final Account.Id accountId;
-  private final String label;
+  protected final Account.Id accountId;
+  protected final String label;
 
-  StarPredicate(Account.Id accountId, String label) {
+  public StarPredicate(Account.Id accountId, String label) {
     super(ChangeField.STAR, StarredChangesUtil.StarField.create(accountId, label).toString());
     this.accountId = accountId;
     this.label = label;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
index d8d5258..5fdeb68 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmissionIdPredicate.java
@@ -18,9 +18,8 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class SubmissionIdPredicate extends ChangeIndexPredicate {
-
-  SubmissionIdPredicate(String changeSet) {
+public class SubmissionIdPredicate extends ChangeIndexPredicate {
+  public SubmissionIdPredicate(String changeSet) {
     super(ChangeField.SUBMISSIONID, changeSet);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
index 5b01ea2..17034df 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmitRecordPredicate.java
@@ -17,14 +17,14 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.gerrit.common.data.SubmitRecord;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.index.change.ChangeField;
-import com.google.gerrit.server.query.Predicate;
 import com.google.gwtorm.server.OrmException;
 import java.util.Set;
 
-class SubmitRecordPredicate extends ChangeIndexPredicate {
-  static Predicate<ChangeData> create(
+public class SubmitRecordPredicate extends ChangeIndexPredicate {
+  public static Predicate<ChangeData> create(
       String label, SubmitRecord.Label.Status status, Set<Account.Id> accounts) {
     String lowerLabel = label.toLowerCase();
     if (accounts == null || accounts.isEmpty()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
index 0812c6a..df78315 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/SubmittablePredicate.java
@@ -18,10 +18,10 @@
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 
-class SubmittablePredicate extends ChangeIndexPredicate {
-  private final SubmitRecord.Status status;
+public class SubmittablePredicate extends ChangeIndexPredicate {
+  protected final SubmitRecord.Status status;
 
-  SubmittablePredicate(SubmitRecord.Status status) {
+  public SubmittablePredicate(SubmitRecord.Status status) {
     super(ChangeField.SUBMIT_RECORD, status.name());
     this.status = status;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
index f0ac127..abbd0c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TimestampRangeChangePredicate.java
@@ -14,9 +14,9 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.TimestampRangePredicate;
-import com.google.gerrit.server.query.Matchable;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.Matchable;
+import com.google.gerrit.index.query.TimestampRangePredicate;
 import java.sql.Timestamp;
 
 public abstract class TimestampRangeChangePredicate extends TimestampRangePredicate<ChangeData>
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
index afaea5c..a3566a5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/TrackingIdPredicate.java
@@ -14,37 +14,25 @@
 
 package com.google.gerrit.server.query.change;
 
-import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.config.TrackingFooters;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
-import java.util.List;
-import org.eclipse.jgit.revwalk.FooterLine;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
-class TrackingIdPredicate extends ChangeIndexPredicate {
+public class TrackingIdPredicate extends ChangeIndexPredicate {
   private static final Logger log = LoggerFactory.getLogger(TrackingIdPredicate.class);
 
-  private final TrackingFooters trackingFooters;
-
-  TrackingIdPredicate(TrackingFooters trackingFooters, String trackingId) {
+  public TrackingIdPredicate(String trackingId) {
     super(ChangeField.TR, trackingId);
-    this.trackingFooters = trackingFooters;
   }
 
   @Override
-  public boolean match(ChangeData object) throws OrmException {
-    Change c = object.change();
-    if (c != null) {
-      try {
-        List<FooterLine> footers = object.commitFooters();
-        return footers != null
-            && trackingFooters.extract(object.commitFooters()).values().contains(getValue());
-      } catch (IOException e) {
-        log.warn("Cannot extract footers from " + c.getChangeId(), e);
-      }
+  public boolean match(ChangeData cd) throws OrmException {
+    try {
+      return cd.trackingFooters().containsValue(getValue());
+    } catch (IOException e) {
+      log.warn("Cannot extract footers from " + cd.getId(), e);
     }
     return false;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
index 8f72945..ffa59c2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupIsVisibleToPredicate.java
@@ -15,25 +15,27 @@
 package com.google.gerrit.server.query.group;
 
 import com.google.gerrit.common.errors.NoSuchGroupException;
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.index.query.IsVisibleToPredicate;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.query.IsVisibleToPredicate;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.IndexUtils;
 import com.google.gerrit.server.query.account.AccountQueryBuilder;
 import com.google.gwtorm.server.OrmException;
 
-public class GroupIsVisibleToPredicate extends IsVisibleToPredicate<AccountGroup> {
-  private final GroupControl.GenericFactory groupControlFactory;
-  private final CurrentUser user;
+public class GroupIsVisibleToPredicate extends IsVisibleToPredicate<InternalGroup> {
+  protected final GroupControl.GenericFactory groupControlFactory;
+  protected final CurrentUser user;
 
-  GroupIsVisibleToPredicate(GroupControl.GenericFactory groupControlFactory, CurrentUser user) {
-    super(AccountQueryBuilder.FIELD_VISIBLETO, describe(user));
+  public GroupIsVisibleToPredicate(
+      GroupControl.GenericFactory groupControlFactory, CurrentUser user) {
+    super(AccountQueryBuilder.FIELD_VISIBLETO, IndexUtils.describe(user));
     this.groupControlFactory = groupControlFactory;
     this.user = user;
   }
 
   @Override
-  public boolean match(AccountGroup group) throws OrmException {
+  public boolean match(InternalGroup group) throws OrmException {
     try {
       return groupControlFactory.controlFor(user, group.getGroupUUID()).isVisible();
     } catch (NoSuchGroupException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupPredicates.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupPredicates.java
index 650024c..d02f6a4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupPredicates.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupPredicates.java
@@ -14,48 +14,61 @@
 
 package com.google.gerrit.server.query.group;
 
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupField;
-import com.google.gerrit.server.query.Predicate;
 import java.util.Locale;
 
 public class GroupPredicates {
-  public static Predicate<AccountGroup> uuid(AccountGroup.UUID uuid) {
+  public static Predicate<InternalGroup> id(AccountGroup.Id groupId) {
+    return new GroupPredicate(GroupField.ID, groupId.toString());
+  }
+
+  public static Predicate<InternalGroup> uuid(AccountGroup.UUID uuid) {
     return new GroupPredicate(GroupField.UUID, GroupQueryBuilder.FIELD_UUID, uuid.get());
   }
 
-  public static Predicate<AccountGroup> description(String description) {
+  public static Predicate<InternalGroup> description(String description) {
     return new GroupPredicate(
         GroupField.DESCRIPTION, GroupQueryBuilder.FIELD_DESCRIPTION, description);
   }
 
-  public static Predicate<AccountGroup> inname(String name) {
+  public static Predicate<InternalGroup> inname(String name) {
     return new GroupPredicate(
         GroupField.NAME_PART, GroupQueryBuilder.FIELD_INNAME, name.toLowerCase(Locale.US));
   }
 
-  public static Predicate<AccountGroup> name(String name) {
-    return new GroupPredicate(
-        GroupField.NAME, GroupQueryBuilder.FIELD_NAME, name.toLowerCase(Locale.US));
+  public static Predicate<InternalGroup> name(String name) {
+    return new GroupPredicate(GroupField.NAME, GroupQueryBuilder.FIELD_NAME, name);
   }
 
-  public static Predicate<AccountGroup> owner(AccountGroup.UUID ownerUuid) {
+  public static Predicate<InternalGroup> owner(AccountGroup.UUID ownerUuid) {
     return new GroupPredicate(
         GroupField.OWNER_UUID, GroupQueryBuilder.FIELD_OWNER, ownerUuid.get());
   }
 
-  public static Predicate<AccountGroup> isVisibleToAll() {
+  public static Predicate<InternalGroup> isVisibleToAll() {
     return new GroupPredicate(GroupField.IS_VISIBLE_TO_ALL, "1");
   }
 
-  static class GroupPredicate extends IndexPredicate<AccountGroup> {
-    GroupPredicate(FieldDef<AccountGroup, ?> def, String value) {
+  public static Predicate<InternalGroup> member(Account.Id memberId) {
+    return new GroupPredicate(GroupField.MEMBER, memberId.toString());
+  }
+
+  public static Predicate<InternalGroup> subgroup(AccountGroup.UUID subgroupUuid) {
+    return new GroupPredicate(GroupField.SUBGROUP, subgroupUuid.get());
+  }
+
+  static class GroupPredicate extends IndexPredicate<InternalGroup> {
+    GroupPredicate(FieldDef<InternalGroup, ?> def, String value) {
       super(def, value);
     }
 
-    GroupPredicate(FieldDef<AccountGroup, ?> def, String name, String value) {
+    GroupPredicate(FieldDef<InternalGroup, ?> def, String name, String value) {
       super(def, name, value);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
index 3197ab7..057cc44 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryBuilder.java
@@ -14,23 +14,37 @@
 
 package com.google.gerrit.server.query.group;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
 import com.google.common.base.Strings;
 import com.google.common.collect.Lists;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.query.LimitPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryBuilder;
+import com.google.gerrit.index.query.QueryParseException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupBackends;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.query.LimitPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryBuilder;
-import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.group.GroupField;
+import com.google.gerrit.server.index.group.GroupIndex;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import java.io.IOException;
 import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 
 /** Parses a query string meant to be applied to group objects. */
-public class GroupQueryBuilder extends QueryBuilder<AccountGroup> {
+public class GroupQueryBuilder extends QueryBuilder<InternalGroup> {
   public static final String FIELD_UUID = "uuid";
   public static final String FIELD_DESCRIPTION = "description";
   public static final String FIELD_INNAME = "inname";
@@ -38,17 +52,25 @@
   public static final String FIELD_OWNER = "owner";
   public static final String FIELD_LIMIT = "limit";
 
-  private static final QueryBuilder.Definition<AccountGroup, GroupQueryBuilder> mydef =
+  private static final QueryBuilder.Definition<InternalGroup, GroupQueryBuilder> mydef =
       new QueryBuilder.Definition<>(GroupQueryBuilder.class);
 
   public static class Arguments {
+    final GroupIndex groupIndex;
     final GroupCache groupCache;
     final GroupBackend groupBackend;
+    final AccountResolver accountResolver;
 
     @Inject
-    Arguments(GroupCache groupCache, GroupBackend groupBackend) {
+    Arguments(
+        GroupIndexCollection groupIndexCollection,
+        GroupCache groupCache,
+        GroupBackend groupBackend,
+        AccountResolver accountResolver) {
+      this.groupIndex = groupIndexCollection.getSearchIndex();
       this.groupCache = groupCache;
       this.groupBackend = groupBackend;
+      this.accountResolver = accountResolver;
     }
   }
 
@@ -61,12 +83,12 @@
   }
 
   @Operator
-  public Predicate<AccountGroup> uuid(String uuid) {
+  public Predicate<InternalGroup> uuid(String uuid) {
     return GroupPredicates.uuid(new AccountGroup.UUID(uuid));
   }
 
   @Operator
-  public Predicate<AccountGroup> description(String description) throws QueryParseException {
+  public Predicate<InternalGroup> description(String description) throws QueryParseException {
     if (Strings.isNullOrEmpty(description)) {
       throw error("description operator requires a value");
     }
@@ -75,7 +97,7 @@
   }
 
   @Operator
-  public Predicate<AccountGroup> inname(String namePart) {
+  public Predicate<InternalGroup> inname(String namePart) {
     if (namePart.isEmpty()) {
       return name(namePart);
     }
@@ -83,25 +105,18 @@
   }
 
   @Operator
-  public Predicate<AccountGroup> name(String name) {
+  public Predicate<InternalGroup> name(String name) {
     return GroupPredicates.name(name);
   }
 
   @Operator
-  public Predicate<AccountGroup> owner(String owner) throws QueryParseException {
-    AccountGroup group = args.groupCache.get(new AccountGroup.UUID(owner));
-    if (group != null) {
-      return GroupPredicates.owner(group.getGroupUUID());
-    }
-    GroupReference g = GroupBackends.findBestSuggestion(args.groupBackend, owner);
-    if (g == null) {
-      throw error("Group " + owner + " not found");
-    }
-    return GroupPredicates.owner(g.getUUID());
+  public Predicate<InternalGroup> owner(String owner) throws QueryParseException {
+    AccountGroup.UUID groupUuid = parseGroup(owner);
+    return GroupPredicates.owner(groupUuid);
   }
 
   @Operator
-  public Predicate<AccountGroup> is(String value) throws QueryParseException {
+  public Predicate<InternalGroup> is(String value) throws QueryParseException {
     if ("visibletoall".equalsIgnoreCase(value)) {
       return GroupPredicates.isVisibleToAll();
     }
@@ -109,9 +124,9 @@
   }
 
   @Override
-  protected Predicate<AccountGroup> defaultField(String query) throws QueryParseException {
+  protected Predicate<InternalGroup> defaultField(String query) throws QueryParseException {
     // Adapt the capacity of this list when adding more default predicates.
-    List<Predicate<AccountGroup>> preds = Lists.newArrayListWithCapacity(5);
+    List<Predicate<InternalGroup>> preds = Lists.newArrayListWithCapacity(5);
     preds.add(uuid(query));
     preds.add(name(query));
     preds.add(inname(query));
@@ -127,11 +142,65 @@
   }
 
   @Operator
-  public Predicate<AccountGroup> limit(String query) throws QueryParseException {
+  public Predicate<InternalGroup> member(String query)
+      throws QueryParseException, OrmException, ConfigInvalidException, IOException {
+    if (isFieldAbsentFromIndex(GroupField.MEMBER)) {
+      throw getExceptionForUnsupportedOperator("member");
+    }
+
+    Set<Account.Id> accounts = parseAccount(query);
+    List<Predicate<InternalGroup>> predicates =
+        accounts.stream().map(GroupPredicates::member).collect(toImmutableList());
+    return Predicate.or(predicates);
+  }
+
+  @Operator
+  public Predicate<InternalGroup> subgroup(String query) throws QueryParseException {
+    if (isFieldAbsentFromIndex(GroupField.SUBGROUP)) {
+      throw getExceptionForUnsupportedOperator("subgroup");
+    }
+
+    AccountGroup.UUID groupUuid = parseGroup(query);
+    return GroupPredicates.subgroup(groupUuid);
+  }
+
+  @Operator
+  public Predicate<InternalGroup> limit(String query) throws QueryParseException {
     Integer limit = Ints.tryParse(query);
     if (limit == null) {
       throw error("Invalid limit: " + query);
     }
     return new LimitPredicate<>(FIELD_LIMIT, limit);
   }
+
+  private boolean isFieldAbsentFromIndex(FieldDef<InternalGroup, ?> field) {
+    return !args.groupIndex.getSchema().hasField(field);
+  }
+
+  private static QueryParseException getExceptionForUnsupportedOperator(String operatorName) {
+    return new QueryParseException(
+        String.format("'%s' operator is not supported by group index version", operatorName));
+  }
+
+  private Set<Account.Id> parseAccount(String nameOrEmail)
+      throws QueryParseException, OrmException, IOException, ConfigInvalidException {
+    Set<Account.Id> foundAccounts = args.accountResolver.findAll(nameOrEmail);
+    if (foundAccounts.isEmpty()) {
+      throw error("User " + nameOrEmail + " not found");
+    }
+    return foundAccounts;
+  }
+
+  private AccountGroup.UUID parseGroup(String groupNameOrUuid) throws QueryParseException {
+    Optional<InternalGroup> group = args.groupCache.get(new AccountGroup.UUID(groupNameOrUuid));
+    if (group.isPresent()) {
+      return group.get().getGroupUUID();
+    }
+    GroupReference groupReference =
+        GroupBackends.findBestSuggestion(args.groupBackend, groupNameOrUuid);
+    if (groupReference == null) {
+      throw error("Group " + groupNameOrUuid + " not found");
+    }
+    return groupReference.getUUID();
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
index 1cfab20..8554ecf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/GroupQueryProcessor.java
@@ -17,21 +17,30 @@
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.gerrit.server.query.group.GroupQueryBuilder.FIELD_LIMIT;
 
-import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.AndSource;
+import com.google.gerrit.index.query.IndexPredicate;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryProcessor;
+import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gerrit.server.index.group.GroupIndexRewriter;
 import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
-import com.google.gerrit.server.query.AndSource;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryProcessor;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
-public class GroupQueryProcessor extends QueryProcessor<AccountGroup> {
+/**
+ * Query processor for the group index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public class GroupQueryProcessor extends QueryProcessor<InternalGroup> {
+  private final Provider<CurrentUser> userProvider;
   private final GroupControl.GenericFactory groupControlFactory;
 
   static {
@@ -44,24 +53,26 @@
   @Inject
   protected GroupQueryProcessor(
       Provider<CurrentUser> userProvider,
-      Metrics metrics,
+      AccountLimits.Factory limitsFactory,
+      MetricMaker metricMaker,
       IndexConfig indexConfig,
       GroupIndexCollection indexes,
       GroupIndexRewriter rewriter,
       GroupControl.GenericFactory groupControlFactory) {
     super(
-        userProvider,
-        metrics,
+        metricMaker,
         GroupSchemaDefinitions.INSTANCE,
         indexConfig,
         indexes,
         rewriter,
-        FIELD_LIMIT);
+        FIELD_LIMIT,
+        () -> limitsFactory.create(userProvider.get()).getQueryLimit());
+    this.userProvider = userProvider;
     this.groupControlFactory = groupControlFactory;
   }
 
   @Override
-  protected Predicate<AccountGroup> enforceVisibility(Predicate<AccountGroup> pred) {
+  protected Predicate<InternalGroup> enforceVisibility(Predicate<InternalGroup> pred) {
     return new AndSource<>(
         pred, new GroupIsVisibleToPredicate(groupControlFactory, userProvider.get()), start);
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/group/InternalGroupQuery.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
new file mode 100644
index 0000000..b1d44e4
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/group/InternalGroupQuery.java
@@ -0,0 +1,82 @@
+// 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.group;
+
+import static com.google.common.collect.ImmutableList.toImmutableList;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.query.InternalQuery;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.index.group.GroupIndexCollection;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import java.util.List;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Query wrapper for the group index.
+ *
+ * <p>Instances are one-time-use. Other singleton classes should inject a Provider rather than
+ * holding on to a single instance.
+ */
+public class InternalGroupQuery extends InternalQuery<InternalGroup> {
+  private static final Logger log = LoggerFactory.getLogger(InternalGroupQuery.class);
+
+  @Inject
+  InternalGroupQuery(
+      GroupQueryProcessor queryProcessor, GroupIndexCollection indexes, IndexConfig indexConfig) {
+    super(queryProcessor, indexes, indexConfig);
+  }
+
+  public Optional<InternalGroup> byName(AccountGroup.NameKey groupName) throws OrmException {
+    return getOnlyGroup(GroupPredicates.name(groupName.get()), "group name '" + groupName + "'");
+  }
+
+  public Optional<InternalGroup> byId(AccountGroup.Id groupId) throws OrmException {
+    return getOnlyGroup(GroupPredicates.id(groupId), "group id '" + groupId + "'");
+  }
+
+  public List<InternalGroup> byMember(Account.Id memberId) throws OrmException {
+    return query(GroupPredicates.member(memberId));
+  }
+
+  public List<InternalGroup> bySubgroup(AccountGroup.UUID subgroupId) throws OrmException {
+    return query(GroupPredicates.subgroup(subgroupId));
+  }
+
+  private Optional<InternalGroup> getOnlyGroup(
+      Predicate<InternalGroup> predicate, String groupDescription) throws OrmException {
+    List<InternalGroup> groups = query(predicate);
+    if (groups.isEmpty()) {
+      return Optional.empty();
+    }
+
+    if (groups.size() == 1) {
+      return Optional.of(Iterables.getOnlyElement(groups));
+    }
+
+    ImmutableList<AccountGroup.UUID> groupUuids =
+        groups.stream().map(InternalGroup::getGroupUUID).collect(toImmutableList());
+    log.warn("Ambiguous {} for groups {}.", groupDescription, groupUuids);
+    return Optional.empty();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index 9a56aa4..dfcacb7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -222,11 +222,11 @@
 
   private void initSequences(Repository git, BatchRefUpdate bru) throws IOException {
     if (notesMigration.readChangeSequence()
-        && git.exactRef(REFS_SEQUENCES + Sequences.CHANGES) == null) {
+        && git.exactRef(REFS_SEQUENCES + Sequences.NAME_CHANGES) == null) {
       // Can't easily reuse the inserter from MetaDataUpdate, but this shouldn't slow down site
       // initialization unduly.
       try (ObjectInserter ins = git.newObjectInserter()) {
-        bru.addCommand(RepoSequence.storeNew(ins, Sequences.CHANGES, firstChangeId));
+        bru.addCommand(RepoSequence.storeNew(ins, Sequences.NAME_CHANGES, firstChangeId));
         ins.flush();
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java
index 9b8b736..fcf8c1f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DB2.java
@@ -26,7 +26,7 @@
   private Config cfg;
 
   @Inject
-  public DB2(@GerritServerConfig final Config cfg) {
+  public DB2(@GerritServerConfig Config cfg) {
     super("com.ibm.db2.jcc.DB2Driver");
     this.cfg = cfg;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
index 9b6073e..d4cfaa6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/DataSourceProvider.java
@@ -168,8 +168,8 @@
     }
   }
 
-  private void exportPoolMetrics(final BasicDataSource pool) {
-    final CallbackMetric1<Boolean, Integer> cnt =
+  private void exportPoolMetrics(BasicDataSource pool) {
+    CallbackMetric1<Boolean, Integer> cnt =
         metrics.newCallbackMetric(
             "sql/connection_pool/connections",
             Integer.class,
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java
index 3cffdb1..840eaf0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2.java
@@ -26,7 +26,7 @@
   private final SitePaths site;
 
   @Inject
-  H2(final SitePaths site, @GerritServerConfig final Config cfg) {
+  H2(SitePaths site, @GerritServerConfig Config cfg) {
     super("org.h2.Driver");
     this.cfg = cfg;
     this.site = site;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java
index 26c94e0..f9811c6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/HANA.java
@@ -17,6 +17,7 @@
 import static com.google.gerrit.server.schema.JdbcUtil.hostname;
 import static com.google.gerrit.server.schema.JdbcUtil.port;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.server.config.ConfigSection;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.inject.Inject;
@@ -28,7 +29,7 @@
   private Config cfg;
 
   @Inject
-  HANA(@GerritServerConfig final Config cfg) {
+  HANA(@GerritServerConfig Config cfg) {
     super("com.sap.db.jdbc.Driver");
     this.cfg = cfg;
   }
@@ -39,9 +40,11 @@
     final ConfigSection dbs = new ConfigSection(cfg, "database");
     b.append("jdbc:sap://");
     b.append(hostname(dbs.required("hostname")));
-    int instance = Integer.parseInt(dbs.required("instance"));
-    String port = "3" + String.format("%02d", instance) + "15";
-    b.append(port(port));
+    b.append(port(dbs.optional("port")));
+    String database = dbs.optional("database");
+    if (!Strings.isNullOrEmpty(database)) {
+      b.append("?databaseName=").append(database);
+    }
     return b.toString();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java
index a1df850..d188df4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/JDBC.java
@@ -24,7 +24,7 @@
   protected final Config cfg;
 
   @Inject
-  JDBC(@GerritServerConfig final Config cfg) {
+  JDBC(@GerritServerConfig Config cfg) {
     super(ConfigUtil.getRequired(cfg, "database", "driver"));
     this.cfg = cfg;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java
index ed18a86..6c5dd35 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MariaDb.java
@@ -40,6 +40,7 @@
     b.append(port(dbs.optional("port")));
     b.append("/");
     b.append(dbs.required("database"));
+    b.append("?useBulkStmts=false");
     return b.toString();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java
index ca5a60d..d552eb65 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MaxDb.java
@@ -27,7 +27,7 @@
   private Config cfg;
 
   @Inject
-  MaxDb(@GerritServerConfig final Config cfg) {
+  MaxDb(@GerritServerConfig Config cfg) {
     super("com.sap.dbtech.jdbc.DriverSapDB");
     this.cfg = cfg;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java
index fc8e176..e5f59d7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/MySql.java
@@ -27,7 +27,7 @@
   private Config cfg;
 
   @Inject
-  MySql(@GerritServerConfig final Config cfg) {
+  MySql(@GerritServerConfig Config cfg) {
     super("com.mysql.jdbc.Driver");
     this.cfg = cfg;
   }
@@ -41,6 +41,9 @@
     b.append(port(dbs.optional("port")));
     b.append("/");
     b.append(dbs.required("database"));
+    // See
+    // https://stackoverflow.com/questions/42084633/table-name-pattern-can-not-be-null-or-empty-in-java
+    b.append("?nullNamePatternMatchesAll=true");
     return b.toString();
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
index b30c49a..fd0c7fc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/NoChangesReviewDbWrapper.java
@@ -74,6 +74,11 @@
   }
 
   @Override
+  public boolean changesTablesEnabled() {
+    return false;
+  }
+
+  @Override
   public ChangeAccess changes() {
     return changes;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java
index e86f788..4ff7243 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Oracle.java
@@ -26,7 +26,7 @@
   private Config cfg;
 
   @Inject
-  public Oracle(@GerritServerConfig final Config cfg) {
+  public Oracle(@GerritServerConfig Config cfg) {
     super("oracle.jdbc.driver.OracleDriver");
     this.cfg = cfg;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java
index 23e7625..d6aee94 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/PostgreSQL.java
@@ -28,7 +28,7 @@
   private Config cfg;
 
   @Inject
-  PostgreSQL(@GerritServerConfig final Config cfg) {
+  PostgreSQL(@GerritServerConfig Config cfg) {
     super("org.postgresql.Driver");
     this.cfg = cfg;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java
index 62d0f42..8c1ccd2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaCreator.java
@@ -14,9 +14,10 @@
 
 package com.google.gerrit.server.schema;
 
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.AccountGroupName;
 import com.google.gerrit.reviewdb.client.CurrentSchemaVersion;
 import com.google.gerrit.reviewdb.client.SystemConfig;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -24,6 +25,8 @@
 import com.google.gerrit.server.account.GroupUUID;
 import com.google.gerrit.server.config.SitePath;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.group.GroupsUpdate;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
 import com.google.gwtorm.jdbc.JdbcExecutor;
@@ -75,7 +78,7 @@
     indexCollection = ic;
   }
 
-  public void create(final ReviewDb db) throws OrmException, IOException, ConfigInvalidException {
+  public void create(ReviewDb db) throws OrmException, IOException, ConfigInvalidException {
     final JdbcSchema jdbc = (JdbcSchema) db;
     try (JdbcExecutor e = new JdbcExecutor(jdbc)) {
       jdbc.updateSchema(e);
@@ -96,35 +99,31 @@
   }
 
   private void createDefaultGroups(ReviewDb db) throws OrmException, IOException {
-    admin = newGroup(db, "Administrators", null);
+    admin = newGroup(db, "Administrators");
     admin.setDescription("Gerrit Site Administrators");
-    db.accountGroups().insert(Collections.singleton(admin));
-    db.accountGroupNames().insert(Collections.singleton(new AccountGroupName(admin)));
-    index(admin);
+    GroupsUpdate.addNewGroup(db, admin);
+    index(InternalGroup.create(admin, ImmutableSet.of(), ImmutableSet.of()));
 
-    batch = newGroup(db, "Non-Interactive Users", null);
+    batch = newGroup(db, "Non-Interactive Users");
     batch.setDescription("Users who perform batch actions on Gerrit");
     batch.setOwnerGroupUUID(admin.getGroupUUID());
-    db.accountGroups().insert(Collections.singleton(batch));
-    db.accountGroupNames().insert(Collections.singleton(new AccountGroupName(batch)));
-    index(batch);
+    GroupsUpdate.addNewGroup(db, batch);
+    index(InternalGroup.create(batch, ImmutableSet.of(), ImmutableSet.of()));
   }
 
-  private void index(AccountGroup group) throws IOException {
+  private void index(InternalGroup group) throws IOException {
     for (GroupIndex groupIndex : indexCollection.getWriteIndexes()) {
       groupIndex.replace(group);
     }
   }
 
-  private AccountGroup newGroup(ReviewDb c, String name, AccountGroup.UUID uuid)
-      throws OrmException {
-    if (uuid == null) {
-      uuid = GroupUUID.make(name, serverUser);
-    }
+  private AccountGroup newGroup(ReviewDb c, String name) throws OrmException {
+    AccountGroup.UUID uuid = GroupUUID.make(name, serverUser);
     return new AccountGroup( //
         new AccountGroup.NameKey(name), //
         new AccountGroup.Id(c.nextAccountGroupId()), //
-        uuid);
+        uuid,
+        TimeUtil.nowTs());
   }
 
   private SystemConfig initSystemConfig(ReviewDb db) throws OrmException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
index b60b1f7..266fbaa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaUpdater.java
@@ -52,14 +52,17 @@
 
   @Inject
   SchemaUpdater(
-      SchemaFactory<ReviewDb> schema, SitePaths site, SchemaCreator creator, Injector parent) {
+      @ReviewDbFactory SchemaFactory<ReviewDb> schema,
+      SitePaths site,
+      SchemaCreator creator,
+      Injector parent) {
     this.schema = schema;
     this.site = site;
     this.creator = creator;
     this.updater = buildInjector(parent).getProvider(SchemaVersion.class);
   }
 
-  private static Injector buildInjector(final Injector parent) {
+  private static Injector buildInjector(Injector parent) {
     // Use DEVELOPMENT mode to allow lazy initialization of the
     // graph. This avoids touching ancient schema versions that
     // are behind this installation's current version.
@@ -98,7 +101,7 @@
         });
   }
 
-  public void update(final UpdateUI ui) throws OrmException {
+  public void update(UpdateUI ui) throws OrmException {
     try (ReviewDb db = ReviewDbUtil.unwrapDb(schema.open())) {
 
       final SchemaVersion u = updater.get();
@@ -127,7 +130,7 @@
     return updater.get();
   }
 
-  private CurrentSchemaVersion getSchemaVersion(final ReviewDb db) {
+  private CurrentSchemaVersion getSchemaVersion(ReviewDb db) {
     try {
       return db.schemaVersion().get(new CurrentSchemaVersion.Key());
     } catch (OrmException e) {
@@ -135,7 +138,7 @@
     }
   }
 
-  private void updateSystemConfig(final ReviewDb db) throws OrmException {
+  private void updateSystemConfig(ReviewDb db) throws OrmException {
     final SystemConfig sc = db.systemConfig().get(new SystemConfig.Key());
     if (sc == null) {
       throw new OrmException("No record in system_config table");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index a67a8a9..d1cbad6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -35,7 +35,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_142> C = Schema_142.class;
+  public static final Class<Schema_161> C = Schema_161.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
@@ -44,7 +44,7 @@
   private final Provider<? extends SchemaVersion> prior;
   private final int versionNbr;
 
-  protected SchemaVersion(final Provider<? extends SchemaVersion> prior) {
+  protected SchemaVersion(Provider<? extends SchemaVersion> prior) {
     this.prior = prior;
     this.versionNbr = guessVersion(getClass());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersionCheck.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersionCheck.java
index 2f3d09f..bdc15f4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersionCheck.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersionCheck.java
@@ -85,7 +85,7 @@
   @Override
   public void stop() {}
 
-  private CurrentSchemaVersion getSchemaVersion(final ReviewDb db) {
+  private CurrentSchemaVersion getSchemaVersion(ReviewDb db) {
     try {
       return db.schemaVersion().get(new CurrentSchemaVersion.Key());
     } catch (OrmException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
index ec63141..31cfd5d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_123.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gwtorm.jdbc.JdbcSchema;
@@ -78,7 +79,7 @@
                 ObjectId.zeroId(), id, RefNames.refsStarredChanges(e.getValue(), e.getKey())));
       }
       bru.execute(rw, new TextProgressMonitor());
-    } catch (IOException ex) {
+    } catch (IOException | IllegalLabelException ex) {
       throw new OrmException(ex);
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
index df808df..e67ae2f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_142.java
@@ -14,16 +14,24 @@
 
 package com.google.gerrit.server.schema;
 
-import com.google.gerrit.reviewdb.client.AccountExternalId;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
+
+import com.google.common.base.Strings;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.account.HashedPassword;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gwtorm.jdbc.JdbcSchema;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
 import java.sql.SQLException;
-import java.util.List;
+import java.sql.Statement;
 
 public class Schema_142 extends SchemaVersion {
+  private static final int MAX_BATCH_SIZE = 1000;
+
   @Inject
   Schema_142(Provider<Schema_141> prior) {
     super(prior);
@@ -31,19 +39,39 @@
 
   @Override
   protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
-    List<AccountExternalId> newIds = db.accountExternalIds().all().toList();
-    for (AccountExternalId id : newIds) {
-      if (!id.isScheme(AccountExternalId.SCHEME_USERNAME)) {
-        continue;
+    try (PreparedStatement updateStmt =
+        ((JdbcSchema) db)
+            .getConnection()
+            .prepareStatement(
+                "UPDATE account_external_ids " + "SET password = ? " + "WHERE external_id = ?")) {
+      int batchCount = 0;
+
+      try (Statement stmt = newStatement(db);
+          ResultSet rs =
+              stmt.executeQuery("SELECT external_id, password FROM account_external_ids")) {
+        while (rs.next()) {
+          String externalId = rs.getString("external_id");
+          String password = rs.getString("password");
+          if (!ExternalId.Key.parse(externalId).isScheme(SCHEME_USERNAME)
+              || Strings.isNullOrEmpty(password)) {
+            continue;
+          }
+
+          HashedPassword hashed = HashedPassword.fromPassword(password);
+          updateStmt.setString(1, hashed.encode());
+          updateStmt.setString(2, externalId);
+          updateStmt.addBatch();
+          batchCount++;
+          if (batchCount >= MAX_BATCH_SIZE) {
+            updateStmt.executeBatch();
+            batchCount = 0;
+          }
+        }
       }
 
-      String password = id.getPassword();
-      if (password != null) {
-        HashedPassword hashed = HashedPassword.fromPassword(password);
-        id.setPassword(hashed.encode());
+      if (batchCount > 0) {
+        updateStmt.executeBatch();
       }
     }
-
-    db.accountExternalIds().upsert(newIds);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.java
new file mode 100644
index 0000000..b190b29
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_143.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.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Add isPrivate field to change. */
+public class Schema_143 extends SchemaVersion {
+  @Inject
+  Schema_143(Provider<Schema_142> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
new file mode 100644
index 0000000..d43b887
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_144.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.HashSet;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class Schema_144 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Import external IDs from ReviewDb";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_144(
+      Provider<Schema_143> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    Set<ExternalId> toAdd = new HashSet<>();
+    try (Statement stmt = ((JdbcSchema) db).getConnection().createStatement();
+        ResultSet rs =
+            stmt.executeQuery(
+                "SELECT "
+                    + "account_id, "
+                    + "email_address, "
+                    + "password, "
+                    + "external_id "
+                    + "FROM account_external_ids")) {
+      while (rs.next()) {
+        Account.Id accountId = new Account.Id(rs.getInt(1));
+        String email = rs.getString(2);
+        String password = rs.getString(3);
+        String externalId = rs.getString(4);
+
+        toAdd.add(ExternalId.create(ExternalId.Key.parse(externalId), accountId, email, password));
+      }
+    }
+
+    try {
+      try (Repository repo = repoManager.openRepository(allUsersName);
+          RevWalk rw = new RevWalk(repo);
+          ObjectInserter ins = repo.newObjectInserter()) {
+        ObjectId rev = ExternalIdReader.readRevision(repo);
+
+        NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+
+        for (ExternalId extId : toAdd) {
+          ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
+        }
+
+        ExternalIdsUpdate.commit(
+            allUsersName,
+            repo,
+            rw,
+            ins,
+            rev,
+            noteMap,
+            COMMIT_MSG,
+            serverIdent,
+            serverIdent,
+            null,
+            GitReferenceUpdated.DISABLED);
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Failed to migrate external IDs to NoteDb", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java
new file mode 100644
index 0000000..6ccb5d8
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_145.java
@@ -0,0 +1,50 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.schema.sql.SqlDialect;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.SQLException;
+
+/** Create account_external_ids_byEmail index. */
+public class Schema_145 extends SchemaVersion {
+
+  @Inject
+  Schema_145(Provider<Schema_144> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    SqlDialect dialect = schema.getDialect();
+    try (StatementExecutor e = newExecutor(db)) {
+      try {
+        dialect.dropIndex(e, "account_external_ids", "account_external_ids_byEmail");
+      } catch (OrmException ex) {
+        // Ignore.  The index did not exist.
+      }
+      e.execute(
+          "CREATE INDEX account_external_ids_byEmail"
+              + " ON account_external_ids"
+              + " (email_address)");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java
new file mode 100644
index 0000000..503ed7b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_146.java
@@ -0,0 +1,196 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.sql.Timestamp;
+import java.util.HashMap;
+import java.util.Map;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Constants;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.RefUpdate;
+import org.eclipse.jgit.lib.RefUpdate.Result;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevSort;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Make sure that for every account a user branch exists that has an initial empty commit with the
+ * registration date as commit time.
+ *
+ * <p>For accounts that don't have a user branch yet the user branch is created with an initial
+ * empty commit that has the registration date as commit time.
+ *
+ * <p>For accounts that already have a user branch the user branch is rewritten and an initial empty
+ * commit with the registration date as commit time is inserted (if such a commit doesn't exist
+ * yet).
+ */
+public class Schema_146 extends SchemaVersion {
+  private static final String CREATE_ACCOUNT_MSG = "Create Account";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_146(
+      Provider<Schema_145> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter oi = repo.newObjectInserter()) {
+      ObjectId emptyTree = emptyTree(oi);
+
+      for (Map.Entry<Account.Id, Timestamp> e : scanAccounts(db).entrySet()) {
+        String refName = RefNames.refsUsers(e.getKey());
+        Ref ref = repo.exactRef(refName);
+        if (ref != null) {
+          rewriteUserBranch(repo, rw, oi, emptyTree, ref, e.getValue());
+        } else {
+          createUserBranch(repo, oi, emptyTree, e.getKey(), e.getValue());
+        }
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to rewrite user branches.", e);
+    }
+  }
+
+  private void rewriteUserBranch(
+      Repository repo,
+      RevWalk rw,
+      ObjectInserter oi,
+      ObjectId emptyTree,
+      Ref ref,
+      Timestamp registeredOn)
+      throws IOException {
+    ObjectId current = createInitialEmptyCommit(oi, emptyTree, registeredOn);
+
+    rw.reset();
+    rw.sort(RevSort.TOPO);
+    rw.sort(RevSort.REVERSE, true);
+    rw.markStart(rw.parseCommit(ref.getObjectId()));
+
+    RevCommit c;
+    while ((c = rw.next()) != null) {
+      if (isInitialEmptyCommit(emptyTree, c)) {
+        return;
+      }
+
+      CommitBuilder cb = new CommitBuilder();
+      cb.setParentId(current);
+      cb.setTreeId(c.getTree());
+      cb.setAuthor(c.getAuthorIdent());
+      cb.setCommitter(c.getCommitterIdent());
+      cb.setMessage(c.getFullMessage());
+      cb.setEncoding(c.getEncoding());
+      current = oi.insert(cb);
+    }
+
+    oi.flush();
+
+    RefUpdate ru = repo.updateRef(ref.getName());
+    ru.setExpectedOldObjectId(ref.getObjectId());
+    ru.setNewObjectId(current);
+    ru.setForceUpdate(true);
+    ru.setRefLogIdent(serverIdent);
+    ru.setRefLogMessage(getClass().getSimpleName(), true);
+    Result result = ru.update();
+    if (result != Result.FORCED) {
+      throw new IOException(
+          String.format("Failed to update ref %s: %s", ref.getName(), result.name()));
+    }
+  }
+
+  public void createUserBranch(
+      Repository repo,
+      ObjectInserter oi,
+      ObjectId emptyTree,
+      Account.Id accountId,
+      Timestamp registeredOn)
+      throws IOException {
+    ObjectId id = createInitialEmptyCommit(oi, emptyTree, registeredOn);
+
+    String refName = RefNames.refsUsers(accountId);
+    RefUpdate ru = repo.updateRef(refName);
+    ru.setExpectedOldObjectId(ObjectId.zeroId());
+    ru.setNewObjectId(id);
+    ru.setRefLogIdent(serverIdent);
+    ru.setRefLogMessage(CREATE_ACCOUNT_MSG, false);
+    Result result = ru.update();
+    if (result != Result.NEW) {
+      throw new IOException(String.format("Failed to update ref %s: %s", refName, result.name()));
+    }
+  }
+
+  private ObjectId createInitialEmptyCommit(
+      ObjectInserter oi, ObjectId emptyTree, Timestamp registrationDate) throws IOException {
+    PersonIdent ident = new PersonIdent(serverIdent, registrationDate);
+
+    CommitBuilder cb = new CommitBuilder();
+    cb.setTreeId(emptyTree);
+    cb.setCommitter(ident);
+    cb.setAuthor(ident);
+    cb.setMessage(CREATE_ACCOUNT_MSG);
+    return oi.insert(cb);
+  }
+
+  private boolean isInitialEmptyCommit(ObjectId emptyTree, RevCommit c) {
+    return c.getParentCount() == 0
+        && c.getTree().equals(emptyTree)
+        && c.getShortMessage().equals(CREATE_ACCOUNT_MSG);
+  }
+
+  private static ObjectId emptyTree(ObjectInserter oi) throws IOException {
+    return oi.insert(Constants.OBJ_TREE, new byte[] {});
+  }
+
+  private Map<Account.Id, Timestamp> scanAccounts(ReviewDb db) throws SQLException {
+    try (Statement stmt = newStatement(db);
+        ResultSet rs = stmt.executeQuery("SELECT account_id, registered_on FROM accounts")) {
+      HashMap<Account.Id, Timestamp> m = new HashMap<>();
+      while (rs.next()) {
+        m.put(new Account.Id(rs.getInt(1)), rs.getTimestamp(2));
+      }
+      return m;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java
new file mode 100644
index 0000000..29ae7d5
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_147.java
@@ -0,0 +1,90 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static java.util.stream.Collectors.toSet;
+
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.HashSet;
+import java.util.Objects;
+import java.util.Set;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+
+/** Delete user branches for which no account exists. */
+public class Schema_147 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverIdent;
+
+  @Inject
+  Schema_147(
+      Provider<Schema_146> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      Set<Account.Id> accountIdsFromReviewDb = scanAccounts(db);
+      Set<Account.Id> accountIdsFromUserBranches =
+          repo.getRefDatabase()
+              .getRefs(RefNames.REFS_USERS)
+              .values()
+              .stream()
+              .map(r -> Account.Id.fromRef(r.getName()))
+              .filter(Objects::nonNull)
+              .collect(toSet());
+      accountIdsFromUserBranches.removeAll(accountIdsFromReviewDb);
+      for (Account.Id accountId : accountIdsFromUserBranches) {
+        AccountsUpdate.deleteUserBranch(
+            repo, allUsersName, GitReferenceUpdated.DISABLED, null, serverIdent, accountId);
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to delete user branches for non-existing accounts.", e);
+    }
+  }
+
+  private Set<Account.Id> scanAccounts(ReviewDb db) throws SQLException {
+    try (Statement stmt = newStatement(db);
+        ResultSet rs = stmt.executeQuery("SELECT account_id FROM accounts")) {
+      Set<Account.Id> ids = new HashSet<>();
+      while (rs.next()) {
+        ids.add(new Account.Id(rs.getInt(1)));
+      }
+      return ids;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
new file mode 100644
index 0000000..47751cd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_148.java
@@ -0,0 +1,111 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdReader;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.SQLException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.notes.Note;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+public class Schema_148 extends SchemaVersion {
+  private static final String COMMIT_MSG = "Make account IDs of external IDs human-readable";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final PersonIdent serverUser;
+
+  @Inject
+  Schema_148(
+      Provider<Schema_147> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent PersonIdent serverUser) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverUser = serverUser;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try (Repository repo = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(repo);
+        ObjectInserter ins = repo.newObjectInserter()) {
+      ObjectId rev = ExternalIdReader.readRevision(repo);
+      NoteMap noteMap = ExternalIdReader.readNoteMap(rw, rev);
+      boolean dirty = false;
+      for (Note note : noteMap) {
+        byte[] raw =
+            rw.getObjectReader()
+                .open(note.getData(), OBJ_BLOB)
+                .getCachedBytes(ExternalIdReader.MAX_NOTE_SZ);
+        try {
+          ExternalId extId = ExternalId.parse(note.getName(), raw, note.getData());
+
+          if (needsUpdate(extId)) {
+            ExternalIdsUpdate.upsert(rw, ins, noteMap, extId);
+            dirty = true;
+          }
+        } catch (ConfigInvalidException e) {
+          ui.message(
+              String.format("Warning: Ignoring invalid external ID note %s", note.getName()));
+        }
+      }
+      if (dirty) {
+        ExternalIdsUpdate.commit(
+            allUsersName,
+            repo,
+            rw,
+            ins,
+            rev,
+            noteMap,
+            COMMIT_MSG,
+            serverUser,
+            serverUser,
+            null,
+            GitReferenceUpdated.DISABLED);
+      }
+    } catch (IOException e) {
+      throw new OrmException("Failed to update external IDs", e);
+    }
+  }
+
+  private static boolean needsUpdate(ExternalId extId) {
+    Config cfg = new Config();
+    cfg.setInt("externalId", extId.key().get(), "accountId", extId.accountId().get());
+    return Ints.tryParse(cfg.getString("externalId", extId.key().get(), "accountId")) == null;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.java
new file mode 100644
index 0000000..f1ccaa6
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_149.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.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Add workInProgress field to change. */
+public class Schema_149 extends SchemaVersion {
+  @Inject
+  Schema_149(Provider<Schema_148> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_150.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_150.java
new file mode 100644
index 0000000..456a01a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_150.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.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Drop ACCOUNT_EXTERNAL_IDS table. */
+public class Schema_150 extends SchemaVersion {
+  @Inject
+  Schema_150(Provider<Schema_149> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_151.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_151.java
new file mode 100644
index 0000000..2015c14
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_151.java
@@ -0,0 +1,56 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.common.collect.Streams;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit.Key;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.Timestamp;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+
+/** A schema which adds the 'created on' field to groups. */
+public class Schema_151 extends SchemaVersion {
+  @Inject
+  protected Schema_151(Provider<Schema_150> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    List<AccountGroup> accountGroups = db.accountGroups().all().toList();
+    for (AccountGroup accountGroup : accountGroups) {
+      ResultSet<AccountGroupMemberAudit> groupMemberAudits =
+          db.accountGroupMembersAudit().byGroup(accountGroup.getId());
+      Optional<Timestamp> firstTimeMentioned =
+          Streams.stream(groupMemberAudits)
+              .map(AccountGroupMemberAudit::getKey)
+              .map(Key::getAddedOn)
+              .min(Comparator.naturalOrder());
+      Timestamp createdOn =
+          firstTimeMentioned.orElseGet(() -> AccountGroup.auditCreationInstantTs());
+
+      accountGroup.setCreatedOn(createdOn);
+    }
+    db.accountGroups().update(accountGroups);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_152.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_152.java
new file mode 100644
index 0000000..c5150a0
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_152.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.schema.sql.SqlDialect;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.SQLException;
+
+/** Drop unused indexes from accounts table. */
+public class Schema_152 extends SchemaVersion {
+  @Inject
+  Schema_152(Provider<Schema_151> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    SqlDialect dialect = schema.getDialect();
+    try (StatementExecutor e = newExecutor(db)) {
+      dialect.dropIndex(e, "accounts", "accounts_byFullName");
+    } catch (OrmException ex) {
+      // Ignore. The index did not exist.
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_153.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_153.java
new file mode 100644
index 0000000..28aeb17
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_153.java
@@ -0,0 +1,41 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Add reviewStarted field to change. */
+public class Schema_153 extends SchemaVersion {
+  @Inject
+  Schema_153(Provider<Schema_152> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    try (StatementExecutor e = newExecutor(db)) {
+      // Initialize review_started to a sensible default value according to
+      // whether change is currently WIP. No migration is needed in NoteDb,
+      // where the value of review_started is always derived from the history
+      // of assignments to work_in_progress.
+      e.execute(
+          "UPDATE changes SET review_started = 'Y', created_on = created_on WHERE work_in_progress = 'N'");
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_154.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_154.java
new file mode 100644
index 0000000..596999d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_154.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.server.schema;
+
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.AccountConfig;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Statement;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Migrate accounts to NoteDb. */
+public class Schema_154 extends SchemaVersion {
+  private static final Logger log = LoggerFactory.getLogger(Schema_154.class);
+  private static final String TABLE = "accounts";
+  private static final Map<String, AccountSetter> ACCOUNT_FIELDS_MAP =
+      ImmutableMap.<String, AccountSetter>builder()
+          .put("full_name", (a, rs, field) -> a.setFullName(rs.getString(field)))
+          .put("preferred_email", (a, rs, field) -> a.setPreferredEmail(rs.getString(field)))
+          .put("status", (a, rs, field) -> a.setStatus(rs.getString(field)))
+          .put("inactive", (a, rs, field) -> a.setActive(rs.getString(field).equals("N")))
+          .build();
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final Provider<PersonIdent> serverIdent;
+
+  @Inject
+  Schema_154(
+      Provider<Schema_153> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    try {
+      try (Repository repo = repoManager.openRepository(allUsersName)) {
+        ProgressMonitor pm = new TextProgressMonitor();
+        pm.beginTask("Collecting accounts", ProgressMonitor.UNKNOWN);
+        Set<Account> accounts = scanAccounts(db, pm);
+        pm.endTask();
+        pm.beginTask("Migrating accounts to NoteDb", accounts.size());
+        for (Account account : accounts) {
+          updateAccountInNoteDb(repo, account);
+          pm.update(1);
+        }
+        pm.endTask();
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Migrating accounts to NoteDb failed", e);
+    }
+  }
+
+  private Set<Account> scanAccounts(ReviewDb db, ProgressMonitor pm) throws SQLException {
+    Map<String, AccountSetter> fields = getFields(db);
+    if (fields.isEmpty()) {
+      log.warn("Only account_id and registered_on fields are migrated for accounts");
+    }
+
+    List<String> queryFields = new ArrayList<>();
+    queryFields.add("account_id");
+    queryFields.add("registered_on");
+    queryFields.addAll(fields.keySet());
+    String query = "SELECT " + String.join(", ", queryFields) + String.format(" FROM %s", TABLE);
+    try (Statement stmt = newStatement(db);
+        ResultSet rs = stmt.executeQuery(query)) {
+      Set<Account> s = new HashSet<>();
+      while (rs.next()) {
+        Account a = new Account(new Account.Id(rs.getInt(1)), rs.getTimestamp(2));
+        for (Map.Entry<String, AccountSetter> field : fields.entrySet()) {
+          field.getValue().set(a, rs, field.getKey());
+        }
+        s.add(a);
+        pm.update(1);
+      }
+      return s;
+    }
+  }
+
+  private Map<String, AccountSetter> getFields(ReviewDb db) throws SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    Connection connection = schema.getConnection();
+    Set<String> columns = schema.getDialect().listColumns(connection, TABLE);
+    return ACCOUNT_FIELDS_MAP
+        .entrySet()
+        .stream()
+        .filter(e -> columns.contains(e.getKey()))
+        .collect(toMap(Map.Entry::getKey, Map.Entry::getValue));
+  }
+
+  private void updateAccountInNoteDb(Repository allUsersRepo, Account account)
+      throws IOException, ConfigInvalidException {
+    MetaDataUpdate md =
+        new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, allUsersRepo);
+    PersonIdent ident = serverIdent.get();
+    md.getCommitBuilder().setAuthor(ident);
+    md.getCommitBuilder().setCommitter(ident);
+    AccountConfig accountConfig = new AccountConfig(null, account.getId());
+    accountConfig.load(allUsersRepo);
+    accountConfig.setAccount(account);
+    accountConfig.commit(md);
+  }
+
+  @FunctionalInterface
+  private interface AccountSetter {
+    void set(Account a, ResultSet rs, String field) throws SQLException;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_155.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_155.java
new file mode 100644
index 0000000..2bb2a33
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_155.java
@@ -0,0 +1,57 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.Sequences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.notedb.RepoSequence;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.SQLException;
+
+/** Create account sequence in NoteDb */
+public class Schema_155 extends SchemaVersion {
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  Schema_155(
+      Provider<Schema_154> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    @SuppressWarnings("deprecation")
+    RepoSequence.Seed accountSeed = () -> db.nextAccountId();
+    RepoSequence accountSeq =
+        new RepoSequence(
+            repoManager,
+            GitReferenceUpdated.DISABLED,
+            allUsersName,
+            Sequences.NAME_ACCOUNTS,
+            accountSeed,
+            1);
+
+    // consume one account ID to ensure that the account sequence is initialized in NoteDb
+    accountSeq.next();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_156.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_156.java
new file mode 100644
index 0000000..fd8fc00
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_156.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.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Add revertOf field to change. */
+public class Schema_156 extends SchemaVersion {
+  @Inject
+  Schema_156(Provider<Schema_155> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_157.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_157.java
new file mode 100644
index 0000000..f5c5b59
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_157.java
@@ -0,0 +1,43 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.jdbc.JdbcSchema;
+import com.google.gwtorm.schema.sql.SqlDialect;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.sql.SQLException;
+
+/** Drop unused indexes from accounts table. */
+public class Schema_157 extends SchemaVersion {
+  @Inject
+  Schema_157(Provider<Schema_156> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException, SQLException {
+    JdbcSchema schema = (JdbcSchema) db;
+    SqlDialect dialect = schema.getDialect();
+    try (StatementExecutor e = newExecutor(db)) {
+      dialect.dropIndex(e, "accounts", "accounts_byPreferredEmail");
+    } catch (OrmException ex) {
+      // Ignore. The index did not exist.
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_158.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_158.java
new file mode 100644
index 0000000..ea85444
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_158.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.server.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Drop ACCOUNTS table. */
+public class Schema_158 extends SchemaVersion {
+  @Inject
+  Schema_158(Provider<Schema_157> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_159.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_159.java
new file mode 100644
index 0000000..f97453e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_159.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.server.schema;
+
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+/** Migrate draft changes to private or wip changes. */
+public class Schema_159 extends SchemaVersion {
+
+  private static enum DraftWorkflowMigrationStrategy {
+    PRIVATE,
+    WORK_IN_PROGRESS
+  }
+
+  @Inject
+  Schema_159(Provider<Schema_158> prior) {
+    super(prior);
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    DraftWorkflowMigrationStrategy strategy = DraftWorkflowMigrationStrategy.WORK_IN_PROGRESS;
+    if (ui.yesno(false, "Migrate draft changes to private changes (default is work-in-progress)")) {
+      strategy = DraftWorkflowMigrationStrategy.PRIVATE;
+    }
+    ui.message(
+        String.format("Replace draft changes with %s changes ...", strategy.name().toLowerCase()));
+    try (StatementExecutor e = newExecutor(db)) {
+      String column =
+          strategy == DraftWorkflowMigrationStrategy.PRIVATE ? "is_private" : "work_in_progress";
+      // Mark changes private/WIP and NEW if either:
+      // * they have status DRAFT
+      // * they have status NEW and have any draft patch sets
+      e.execute(
+          String.format(
+              "UPDATE changes "
+                  + "SET %s = 'Y', "
+                  + "    status = 'n', "
+                  + "    created_on = created_on "
+                  + "WHERE status = 'd' "
+                  + "  OR (status = 'n' "
+                  + "      AND EXISTS "
+                  + "        (SELECT * "
+                  + "         FROM patch_sets "
+                  + "         WHERE patch_sets.change_id = changes.change_id "
+                  + "           AND patch_sets.draft = 'Y')) ",
+              column));
+    }
+    ui.message("done");
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_160.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_160.java
new file mode 100644
index 0000000..b78e333
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_160.java
@@ -0,0 +1,153 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
+import static com.google.gerrit.server.git.UserConfigSections.MY;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.ProgressMonitor;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+
+/**
+ * Remove "My Drafts" menu items for all users and server-wide default preferences.
+ *
+ * <p>Since draft changes no longer exist, these menu items are obsolete.
+ *
+ * <p>Only matches menu items (with any name) where the URL exactly matches one of the following,
+ * with or without leading {@code #}:
+ *
+ * <ul>
+ *   <li>/q/is:draft
+ *   <li>/q/owner:self+is:draft
+ * </ul>
+ *
+ * In particular, this includes the <a
+ * href="https://gerrit.googlesource.com/gerrit/+/v2.14.4/gerrit-server/src/main/java/com/google/gerrit/server/account/GeneralPreferencesLoader.java#144">default
+ * from version 2.14 and earlier</a>.
+ *
+ * <p>Other menus containing {@code is:draft} in other positions are not affected; this is still a
+ * valid predicate that matches no changes.
+ */
+public class Schema_160 extends SchemaVersion {
+  @VisibleForTesting static final ImmutableList<String> DEFAULT_DRAFT_ITEMS;
+
+  static {
+    String ownerSelfIsDraft = "/q/owner:self+is:draft";
+    String isDraft = "/q/is:draft";
+    DEFAULT_DRAFT_ITEMS =
+        ImmutableList.of(ownerSelfIsDraft, '#' + ownerSelfIsDraft, isDraft, '#' + isDraft);
+  }
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+  private final Provider<PersonIdent> serverIdent;
+
+  @Inject
+  Schema_160(
+      Provider<Schema_159> prior,
+      GitRepositoryManager repoManager,
+      AllUsersName allUsersName,
+      @GerritPersonIdent Provider<PersonIdent> serverIdent) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+    this.serverIdent = serverIdent;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    try {
+      try (Repository repo = repoManager.openRepository(allUsersName)) {
+        ProgressMonitor pm = new TextProgressMonitor();
+        pm.beginTask("Removing \"My Drafts\" menu items", ProgressMonitor.UNKNOWN);
+        for (Account.Id id : (Iterable<Account.Id>) Accounts.readUserRefs(repo)::iterator) {
+          removeMyDrafts(repo, RefNames.refsUsers(id), pm);
+        }
+        removeMyDrafts(repo, RefNames.REFS_USERS_DEFAULT, pm);
+        pm.endTask();
+      }
+    } catch (IOException | ConfigInvalidException e) {
+      throw new OrmException("Removing \"My Drafts\" menu items failed", e);
+    }
+  }
+
+  private void removeMyDrafts(Repository repo, String ref, ProgressMonitor pm)
+      throws IOException, ConfigInvalidException {
+    MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsersName, repo);
+    PersonIdent ident = serverIdent.get();
+    md.getCommitBuilder().setAuthor(ident);
+    md.getCommitBuilder().setCommitter(ident);
+    Prefs prefs = new Prefs(ref);
+    prefs.load(repo);
+    prefs.removeMyDrafts();
+    prefs.commit(md);
+    if (prefs.dirty()) {
+      pm.update(1);
+    }
+  }
+
+  private static class Prefs extends VersionedAccountPreferences {
+    private boolean dirty;
+
+    Prefs(String ref) {
+      super(ref);
+    }
+
+    @Override
+    protected boolean onSave(CommitBuilder commit) throws IOException, ConfigInvalidException {
+      if (!dirty) {
+        return false;
+      }
+      commit.setMessage("Remove \"My Drafts\" menu items");
+      return super.onSave(commit);
+    }
+
+    void removeMyDrafts() {
+      Config cfg = getConfig();
+      for (String item : cfg.getSubsections(MY)) {
+        String value = cfg.getString(MY, item, KEY_URL);
+        if (DEFAULT_DRAFT_ITEMS.contains(value)) {
+          cfg.unsetSection(MY, item);
+          dirty = true;
+        }
+      }
+    }
+
+    boolean dirty() {
+      return dirty;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_161.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_161.java
new file mode 100644
index 0000000..febe80e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_161.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static java.util.stream.Collectors.toList;
+
+import com.google.common.primitives.Ints;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.StarredChangesUtil.IllegalLabelException;
+import com.google.gerrit.server.StarredChangesUtil.StarRef;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+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.Objects;
+import java.util.Set;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.TextProgressMonitor;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+public class Schema_161 extends SchemaVersion {
+  private static final String MUTE_LABEL = "mute";
+
+  private final GitRepositoryManager repoManager;
+  private final AllUsersName allUsersName;
+
+  @Inject
+  Schema_161(
+      Provider<Schema_160> prior, GitRepositoryManager repoManager, AllUsersName allUsersName) {
+    super(prior);
+    this.repoManager = repoManager;
+    this.allUsersName = allUsersName;
+  }
+
+  @Override
+  protected void migrateData(ReviewDb db, UpdateUI ui) throws OrmException {
+    try (Repository git = repoManager.openRepository(allUsersName);
+        RevWalk rw = new RevWalk(git)) {
+      BatchRefUpdate bru = git.getRefDatabase().newBatchUpdate();
+      bru.setAllowNonFastForwards(true);
+
+      for (Ref ref : git.getRefDatabase().getRefs(RefNames.REFS_STARRED_CHANGES).values()) {
+        StarRef starRef = StarredChangesUtil.readLabels(git, ref.getName());
+
+        Set<Integer> mutedPatchSets =
+            StarredChangesUtil.getStarredPatchSets(starRef.labels(), MUTE_LABEL);
+        if (mutedPatchSets.isEmpty()) {
+          continue;
+        }
+
+        Set<Integer> reviewedPatchSets =
+            StarredChangesUtil.getStarredPatchSets(
+                starRef.labels(), StarredChangesUtil.REVIEWED_LABEL);
+        Set<Integer> unreviewedPatchSets =
+            StarredChangesUtil.getStarredPatchSets(
+                starRef.labels(), StarredChangesUtil.UNREVIEWED_LABEL);
+
+        List<String> newLabels =
+            starRef
+                .labels()
+                .stream()
+                .map(
+                    l -> {
+                      if (l.startsWith(MUTE_LABEL)) {
+                        Integer mutedPatchSet = Ints.tryParse(l.substring(MUTE_LABEL.length() + 1));
+                        if (mutedPatchSet == null) {
+                          // unexpected format of mute label, must be a label that was manually
+                          // set, just leave it alone
+                          return l;
+                        }
+                        if (!reviewedPatchSets.contains(mutedPatchSet)
+                            && !unreviewedPatchSets.contains(mutedPatchSet)) {
+                          // convert mute label to reviewed label
+                          return StarredChangesUtil.REVIEWED_LABEL + "/" + mutedPatchSet;
+                        }
+                        // else patch set is muted but has either reviewed or unreviewed label
+                        // -> just drop the mute label
+                        return null;
+                      }
+                      return l;
+                    })
+                .filter(Objects::nonNull)
+                .collect(toList());
+
+        ObjectId id = StarredChangesUtil.writeLabels(git, newLabels);
+        bru.addCommand(new ReceiveCommand(ref.getTarget().getObjectId(), id, ref.getName()));
+      }
+      bru.execute(rw, new TextProgressMonitor());
+    } catch (IOException | IllegalLabelException ex) {
+      throw new OrmException(ex);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ScriptRunner.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ScriptRunner.java
index adee5fc..f4cba98 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/ScriptRunner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/ScriptRunner.java
@@ -39,10 +39,10 @@
   static final ScriptRunner NOOP =
       new ScriptRunner(null, null) {
         @Override
-        void run(final ReviewDb db) {}
+        void run(ReviewDb db) {}
       };
 
-  ScriptRunner(final String scriptName, final InputStream script) {
+  ScriptRunner(String scriptName, InputStream script) {
     this.name = scriptName;
     try {
       this.commands = script != null ? parse(script) : null;
@@ -51,7 +51,7 @@
     }
   }
 
-  void run(final ReviewDb db) throws OrmException {
+  void run(ReviewDb db) throws OrmException {
     try {
       final JdbcSchema schema = (JdbcSchema) db;
       final Connection c = schema.getConnection();
@@ -73,7 +73,7 @@
     }
   }
 
-  private List<String> parse(final InputStream in) throws IOException {
+  private List<String> parse(InputStream in) throws IOException {
     try (BufferedReader br = new BufferedReader(new InputStreamReader(in, UTF_8))) {
       String delimiter = ";";
       List<String> commands = new ArrayList<>();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/UpdateUI.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/UpdateUI.java
index b43aaa6..0c02607 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/UpdateUI.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/UpdateUI.java
@@ -17,11 +17,24 @@
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.StatementExecutor;
 import java.util.List;
+import java.util.Set;
 
 public interface UpdateUI {
-  void message(String msg);
 
-  boolean yesno(boolean def, String msg);
+  void message(String message);
+
+  /** Requests the user to answer a yes/no question. */
+  boolean yesno(boolean defaultValue, String message);
+
+  /** Prints a message asking the user to let us know when it's safe to continue. */
+  void waitForUser();
+
+  /**
+   * Prompts the user for a string, suggesting a default.
+   *
+   * @return the chosen string from the list of allowed values.
+   */
+  String readString(String defaultValue, Set<String> allowedValues, String message);
 
   boolean isBatch();
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
index b729b09..02ff159 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/securestore/DefaultSecureStore.java
@@ -130,7 +130,7 @@
     }
   }
 
-  private static void saveSecure(final FileBasedConfig sec) throws IOException {
+  private static void saveSecure(FileBasedConfig sec) throws IOException {
     if (FileUtil.modified(sec)) {
       final byte[] out = Constants.encode(sec.toText());
       final File path = sec.getFile();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java
index 70a6fce..0e5b2f8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ssh/SshAddressesModule.java
@@ -53,7 +53,7 @@
       return listen;
     }
 
-    for (final String desc : want) {
+    for (String desc : want) {
       try {
         listen.add(SocketUtil.resolve(desc, DEFAULT_PORT));
       } catch (IllegalArgumentException e) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
index e7a7013..cf88be0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdate.java
@@ -17,19 +17,31 @@
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.collect.ImmutableMultiset.toImmutableMultiset;
 
+import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.Multiset;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.validators.OnSubmitValidators;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.gerrit.server.project.InvalidChangeOperationException;
+import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.inject.Inject;
 import com.google.inject.Module;
@@ -41,12 +53,14 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Optional;
 import java.util.TimeZone;
 import org.eclipse.jgit.lib.BatchRefUpdate;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.PushCertificate;
 import org.eclipse.jgit.transport.ReceiveCommand;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -81,43 +95,86 @@
       @Override
       public void configure() {
         factory(ReviewDbBatchUpdate.AssistedFactory.class);
+        factory(NoteDbBatchUpdate.AssistedFactory.class);
       }
     };
   }
 
   @Singleton
   public static class Factory {
+    private final NotesMigration migration;
     private final ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory;
+    private final NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory;
 
+    // TODO(dborowitz): Make this non-injectable to force all callers to use RetryHelper.
     @Inject
-    Factory(ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory) {
+    Factory(
+        NotesMigration migration,
+        ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
+        NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory) {
+      this.migration = migration;
       this.reviewDbBatchUpdateFactory = reviewDbBatchUpdateFactory;
+      this.noteDbBatchUpdateFactory = noteDbBatchUpdateFactory;
     }
 
     public BatchUpdate create(
         ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when) {
+      if (migration.disableChangeReviewDb()) {
+        return noteDbBatchUpdateFactory.create(db, project, user, when);
+      }
       return reviewDbBatchUpdateFactory.create(db, project, user, when);
     }
 
+    @SuppressWarnings({"rawtypes", "unchecked"})
     public void execute(
         Collection<BatchUpdate> updates,
         BatchUpdateListener listener,
         @Nullable RequestId requestId,
         boolean dryRun)
         throws UpdateException, RestApiException {
+      checkNotNull(listener);
+      checkDifferentProject(updates);
       // It's safe to downcast all members of the input collection in this case, because the only
       // way a caller could have gotten any BatchUpdates in the first place is to call the create
       // method above, which always returns instances of the type we expect. Just to be safe,
       // copy them into an ImmutableList so there is no chance the callee can pollute the input
       // collection.
-      @SuppressWarnings({"rawtypes", "unchecked"})
-      ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
-          (ImmutableList) ImmutableList.copyOf(updates);
-      ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+      if (migration.disableChangeReviewDb()) {
+        ImmutableList<NoteDbBatchUpdate> noteDbUpdates =
+            (ImmutableList) ImmutableList.copyOf(updates);
+        NoteDbBatchUpdate.execute(noteDbUpdates, listener, requestId, dryRun);
+      } else {
+        ImmutableList<ReviewDbBatchUpdate> reviewDbUpdates =
+            (ImmutableList) ImmutableList.copyOf(updates);
+        ReviewDbBatchUpdate.execute(reviewDbUpdates, listener, requestId, dryRun);
+      }
+    }
+
+    private static void checkDifferentProject(Collection<BatchUpdate> updates) {
+      Multiset<Project.NameKey> projectCounts =
+          updates.stream().map(u -> u.project).collect(toImmutableMultiset());
+      checkArgument(
+          projectCounts.entrySet().size() == updates.size(),
+          "updates must all be for different projects, got: %s",
+          projectCounts);
     }
   }
 
-  protected static Order getOrder(Collection<? extends BatchUpdate> updates) {
+  static void setRequestIds(
+      Collection<? extends BatchUpdate> updates, @Nullable RequestId requestId) {
+    if (requestId != null) {
+      for (BatchUpdate u : updates) {
+        checkArgument(
+            u.requestId == null || u.requestId == requestId,
+            "refusing to overwrite RequestId %s in update with %s",
+            u.requestId,
+            requestId);
+        u.setRequestId(requestId);
+      }
+    }
+  }
+
+  static Order getOrder(Collection<? extends BatchUpdate> updates, BatchUpdateListener listener) {
     Order o = null;
     for (BatchUpdate u : updates) {
       if (o == null) {
@@ -126,10 +183,16 @@
         throw new IllegalArgumentException("cannot mix execution orders");
       }
     }
+    if (o != Order.REPO_BEFORE_DB) {
+      checkArgument(
+          listener == BatchUpdateListener.NONE,
+          "BatchUpdateListener not supported for order %s",
+          o);
+    }
     return o;
   }
 
-  protected static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
+  static boolean getUpdateChangesInParallel(Collection<? extends BatchUpdate> updates) {
     checkArgument(!updates.isEmpty());
     Boolean p = null;
     for (BatchUpdate u : updates) {
@@ -148,6 +211,28 @@
     return p;
   }
 
+  static void wrapAndThrowException(Exception e) throws UpdateException, RestApiException {
+    Throwables.throwIfUnchecked(e);
+
+    // Propagate REST API exceptions thrown by operations; they commonly throw exceptions like
+    // ResourceConflictException to indicate an atomic update failure.
+    Throwables.throwIfInstanceOf(e, UpdateException.class);
+    Throwables.throwIfInstanceOf(e, RestApiException.class);
+
+    // Convert other common non-REST exception types with user-visible messages to corresponding
+    // REST exception types
+    if (e instanceof InvalidChangeOperationException) {
+      throw new ResourceConflictException(e.getMessage(), e);
+    } else if (e instanceof NoSuchChangeException
+        || e instanceof NoSuchRefException
+        || e instanceof NoSuchProjectException) {
+      throw new ResourceNotFoundException(e.getMessage(), e);
+    }
+
+    // Otherwise, wrap in a generic UpdateException, which does not include a user-visible message.
+    throw new UpdateException(e);
+  }
+
   protected GitRepositoryManager repoManager;
 
   protected final Project.NameKey project;
@@ -160,18 +245,15 @@
   protected final Map<Change.Id, Change> newChanges = new HashMap<>();
   protected final List<RepoOnlyOp> repoOnlyOps = new ArrayList<>();
 
-  protected Repository repo;
-  protected ObjectInserter inserter;
-  protected RevWalk revWalk;
-  protected ChainedReceiveCommands commands;
+  protected RepoView repoView;
   protected BatchRefUpdate batchRefUpdate;
   protected Order order;
   protected OnSubmitValidators onSubmitValidators;
   protected RequestId requestId;
+  protected PushCertificate pushCert;
   protected String refLogMessage;
 
   private boolean updateChangesInParallel;
-  private boolean closeRepo;
 
   protected BatchUpdate(
       GitRepositoryManager repoManager,
@@ -189,18 +271,17 @@
 
   @Override
   public void close() {
-    if (closeRepo) {
-      revWalk.getObjectReader().close();
-      revWalk.close();
-      inserter.close();
-      repo.close();
+    if (repoView != null) {
+      repoView.close();
     }
   }
 
   public abstract void execute(BatchUpdateListener listener)
       throws UpdateException, RestApiException;
 
-  public abstract void execute() throws UpdateException, RestApiException;
+  public void execute() throws UpdateException, RestApiException {
+    execute(BatchUpdateListener.NONE);
+  }
 
   protected abstract Context newContext();
 
@@ -210,16 +291,17 @@
   }
 
   public BatchUpdate setRepository(Repository repo, RevWalk revWalk, ObjectInserter inserter) {
-    checkState(this.repo == null, "repo already set");
-    closeRepo = false;
-    this.repo = checkNotNull(repo, "repo");
-    this.revWalk = checkNotNull(revWalk, "revWalk");
-    this.inserter = checkNotNull(inserter, "inserter");
-    commands = new ChainedReceiveCommands(repo);
+    checkState(this.repoView == null, "repo already set");
+    repoView = new RepoView(repo, revWalk, inserter);
     return this;
   }
 
-  public BatchUpdate setRefLogMessage(String refLogMessage) {
+  public BatchUpdate setPushCertificate(@Nullable PushCertificate pushCert) {
+    this.pushCert = pushCert;
+    return this;
+  }
+
+  public BatchUpdate setRefLogMessage(@Nullable String refLogMessage) {
     this.refLogMessage = refLogMessage;
     return this;
   }
@@ -238,43 +320,46 @@
     return this;
   }
 
-  /** Execute {@link BatchUpdateOp#updateChange(ChangeContext)} in parallel for each change. */
+  /**
+   * Execute {@link BatchUpdateOp#updateChange(ChangeContext)} in parallel for each change.
+   *
+   * <p>This improves performance of writing to multiple changes in separate ReviewDb transactions.
+   * When only NoteDb is used, updates to all changes are written in a single batch ref update, so
+   * parallelization is not used and this option is ignored.
+   */
   public BatchUpdate updateChangesInParallel() {
     this.updateChangesInParallel = true;
     return this;
   }
 
   protected void initRepository() throws IOException {
-    if (repo == null) {
-      this.repo = repoManager.openRepository(project);
-      closeRepo = true;
-      inserter = repo.newObjectInserter();
-      revWalk = new RevWalk(inserter.newReader());
-      commands = new ChainedReceiveCommands(repo);
+    if (repoView == null) {
+      repoView = new RepoView(repoManager, project);
     }
   }
 
+  protected RepoView getRepoView() throws IOException {
+    initRepository();
+    return repoView;
+  }
+
   protected CurrentUser getUser() {
     return user;
   }
 
-  protected Repository getRepository() throws IOException {
-    initRepository();
-    return repo;
+  protected Optional<Account> getAccount() {
+    return user.isIdentifiedUser()
+        ? Optional.of(user.asIdentifiedUser().getAccount())
+        : Optional.empty();
   }
 
   protected RevWalk getRevWalk() throws IOException {
     initRepository();
-    return revWalk;
+    return repoView.getRevWalk();
   }
 
-  protected ObjectInserter getObjectInserter() throws IOException {
-    initRepository();
-    return inserter;
-  }
-
-  public Collection<ReceiveCommand> getRefUpdates() {
-    return commands.getCommands().values();
+  public Map<String, ReceiveCommand> getRefUpdates() {
+    return repoView != null ? repoView.getCommands().getCommands() : ImmutableMap.of();
   }
 
   public BatchUpdate addOp(Change.Id id, BatchUpdateOp op) {
@@ -290,7 +375,7 @@
     return this;
   }
 
-  public BatchUpdate insertChange(InsertChangeOp op) {
+  public BatchUpdate insertChange(InsertChangeOp op) throws IOException {
     Context ctx = newContext();
     Change c = op.createChange(ctx);
     checkArgument(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
index 765bba1..847a7ca 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateListener.java
@@ -19,6 +19,8 @@
  *
  * <p>When used during execution of multiple batch updates, the {@code after*} methods are called
  * after that phase has been completed for <em>all</em> updates.
+ *
+ * <p>Listeners are only supported for the {@link Order#REPO_BEFORE_DB} order.
  */
 public interface BatchUpdateListener {
   public static final BatchUpdateListener NONE = new BatchUpdateListener() {};
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
index 39e25dd..87a43a3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/BatchUpdateOp.java
@@ -22,8 +22,11 @@
  * BatchUpdate#addOp(com.google.gerrit.reviewdb.client.Change.Id, BatchUpdateOp)}.
  *
  * <p>Usually, a single {@code BatchUpdateOp} instance is only associated with a single change, i.e.
- * {@code addOp} is only called once with that instance. This allows an instance to communicate
- * between phases by storing data in private fields.
+ * {@code addOp} is only called once with that instance. Additionally, each method in {@code
+ * BatchUpdateOp} is called at most once per {@link BatchUpdate} execution.
+ *
+ * <p>Taken together, these two properties mean an instance may communicate between phases by
+ * storing data in private fields, and a single instance must not be reused.
  */
 public interface BatchUpdateOp extends RepoOnlyOp {
   /**
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
index d619490..f017580 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeContext.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
-import com.google.gerrit.server.project.ChangeControl;
 
 /**
  * Context for performing the {@link BatchUpdateOp#updateChange} phase.
@@ -44,17 +43,24 @@
   ChangeUpdate getUpdate(PatchSet.Id psId);
 
   /**
-   * @return control for this change. The user will be the same as {@link #getUser()}, and the
-   *     change data is read within the same transaction that {@code updateChange} is executing.
+   * Get the up-to-date notes for this change.
+   *
+   * <p>The change data is read within the same transaction that {@link
+   * BatchUpdateOp#updateChange(ChangeContext)} is executing.
+   *
+   * @return notes for this change.
    */
-  ChangeControl getControl();
+  ChangeNotes getNotes();
 
   /**
-   * @param bump whether to bump the value of {@link Change#getLastUpdatedOn()} field before storing
-   *     to ReviewDb. For NoteDb, the value is always incremented (assuming the update is not
-   *     otherwise a no-op).
+   * Don't bump the value of {@link Change#getLastUpdatedOn()}.
+   *
+   * <p>If called, don't bump the timestamp before storing to ReviewDb. Only has an effect in
+   * ReviewDb, and the only usage should be to match the behavior of NoteDb. Specifically, in NoteDb
+   * the timestamp is updated if and only if the change meta graph is updated, and is not updated
+   * when only drafts are modified.
    */
-  void bumpLastUpdatedOn(boolean bump);
+  void dontBumpLastUpdatedOn();
 
   /**
    * Instruct {@link BatchUpdate} to delete this change.
@@ -63,13 +69,8 @@
    */
   void deleteChange();
 
-  /** @return notes corresponding to {@link #getControl()}. */
-  default ChangeNotes getNotes() {
-    return checkNotNull(getControl().getNotes());
-  }
-
-  /** @return change corresponding to {@link #getControl()}. */
+  /** @return change corresponding to {@link #getNotes()}. */
   default Change getChange() {
-    return checkNotNull(getControl().getChange());
+    return checkNotNull(getNotes().getChange());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java
index 42199e9..1d957cf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ChangeUpdateExecutor.java
@@ -17,13 +17,11 @@
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
 import com.google.common.util.concurrent.ListeningExecutorService;
-import com.google.gerrit.server.git.ReceiveCommits;
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
 
 /**
- * Marker on the global {@link ListeningExecutorService} used by {@link ReceiveCommits} to create or
- * replace changes.
+ * Marker on the global {@link ListeningExecutorService} used by asynchronous {@link BatchUpdate}s.
  */
 @Retention(RUNTIME)
 @BindingAnnotation
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
index 497b7ab..f33536d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/Context.java
@@ -24,7 +24,6 @@
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.TimeZone;
-import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
 
 /**
@@ -33,19 +32,22 @@
  * <p>A single update may span multiple changes, but they all belong to a single repo.
  */
 public interface Context {
-  /** @return the project name this update operates on. */
+  /**
+   * Get the project name this update operates on.
+   *
+   * @return project.
+   */
   Project.NameKey getProject();
 
   /**
-   * Get an open repository instance for this project.
+   * Get a read-only view of the open repository for this project.
    *
-   * <p>Will be opened lazily if necessary; callers should not close the repo. In some phases of the
-   * update, the repository might be read-only; see {@link BatchUpdateOp} for details.
+   * <p>Will be opened lazily if necessary.
    *
    * @return repository instance.
    * @throws IOException if an error occurred opening the repo.
    */
-  Repository getRepository() throws IOException;
+  RepoView getRepoView() throws IOException;
 
   /**
    * Get a walk for this project.
@@ -57,50 +59,80 @@
    */
   RevWalk getRevWalk() throws IOException;
 
-  /** @return the timestamp at which this update takes place. */
+  /**
+   * Get the timestamp at which this update takes place.
+   *
+   * @return timestamp.
+   */
   Timestamp getWhen();
 
   /**
-   * @return the time zone in which this update takes place. In the current implementation, this is
-   *     always the time zone of the server.
+   * Get the time zone in which this update takes place.
+   *
+   * <p>In the current implementation, this is always the time zone of the server.
+   *
+   * @return time zone.
    */
   TimeZone getTimeZone();
 
   /**
-   * @return an open ReviewDb database. Callers should not manage transactions or call mutating
-   *     methods on the Changes table. Mutations on other tables (including other entities in the
-   *     change entity group) are fine.
+   * Get the ReviewDb database.
+   *
+   * <p>Callers should not manage transactions or call mutating methods on the Changes table.
+   * Mutations on other tables (including other entities in the change entity group) are fine.
+   *
+   * @return open database instance.
    */
   ReviewDb getDb();
 
   /**
-   * @return user performing the update. In the current implementation, this is always an {@link
-   *     IdentifiedUser} or {@link com.google.gerrit.server.InternalUser}.
+   * Get the user performing the update.
+   *
+   * <p>In the current implementation, this is always an {@link IdentifiedUser} or {@link
+   * com.google.gerrit.server.InternalUser}.
+   *
+   * @return user.
    */
   CurrentUser getUser();
 
-  /** @return order in which operations are executed in this update. */
+  /**
+   * Get the order in which operations are executed in this update.
+   *
+   * @return order of operations.
+   */
   Order getOrder();
 
   /**
-   * @return identified user performing the update; throws an unchecked exception if the user is not
-   *     an {@link IdentifiedUser}
+   * Get the identified user performing the update.
+   *
+   * <p>Convenience method for {@code getUser().asIdentifiedUser()}.
+   *
+   * @see CurrentUser#asIdentifiedUser()
+   * @return user.
    */
   default IdentifiedUser getIdentifiedUser() {
     return checkNotNull(getUser()).asIdentifiedUser();
   }
 
   /**
-   * @return account of the user performing the update; throws if the user is not an {@link
-   *     IdentifiedUser}
+   * Get the account of the user performing the update.
+   *
+   * <p>Convenience method for {@code getIdentifiedUser().getAccount()}.
+   *
+   * @see CurrentUser#asIdentifiedUser()
+   * @return account.
    */
   default Account getAccount() {
     return getIdentifiedUser().getAccount();
   }
 
   /**
-   * @return account ID of the user performing the update; throws if the user is not an {@link
-   *     IdentifiedUser}
+   * Get the account ID of the user performing the update.
+   *
+   * <p>Convenience method for {@code getUser().getAccountId()}
+   *
+   * @see CurrentUser#getAccountId()
+   * @return account ID.
    */
   default Account.Id getAccountId() {
     return getIdentifiedUser().getAccountId();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
index 1a947e6..7060059 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/InsertChangeOp.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.update;
 
 import com.google.gerrit.reviewdb.client.Change;
+import java.io.IOException;
 
 /**
  * Specialization of {@link BatchUpdateOp} for creating changes.
@@ -27,5 +28,5 @@
  * first.
  */
 public interface InsertChangeOp extends BatchUpdateOp {
-  Change createChange(Context ctx);
+  Change createChange(Context ctx) throws IOException;
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
new file mode 100644
index 0000000..ceef352
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/NoteDbBatchUpdate.java
@@ -0,0 +1,457 @@
+// 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.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+import static java.util.Comparator.comparing;
+
+import com.google.common.base.Throwables;
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.index.change.ChangeIndexer;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NoteDbUpdateManager;
+import com.google.gerrit.server.util.RequestId;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+import java.io.IOException;
+import java.sql.Timestamp;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.TimeZone;
+import java.util.TreeMap;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/**
+ * {@link BatchUpdate} implementation using only NoteDb that updates code refs and meta refs in a
+ * single {@link org.eclipse.jgit.lib.BatchRefUpdate}.
+ *
+ * <p>Used when {@code noteDb.changes.disableReviewDb=true}, at which point ReviewDb is not
+ * consulted during updates.
+ */
+class NoteDbBatchUpdate extends BatchUpdate {
+  interface AssistedFactory {
+    NoteDbBatchUpdate create(
+        ReviewDb db, Project.NameKey project, CurrentUser user, Timestamp when);
+  }
+
+  static void execute(
+      ImmutableList<NoteDbBatchUpdate> updates,
+      BatchUpdateListener listener,
+      @Nullable RequestId requestId,
+      boolean dryrun)
+      throws UpdateException, RestApiException {
+    if (updates.isEmpty()) {
+      return;
+    }
+    setRequestIds(updates, requestId);
+
+    try {
+      @SuppressWarnings("deprecation")
+      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
+          new ArrayList<>();
+      List<ChangesHandle> handles = new ArrayList<>(updates.size());
+      Order order = getOrder(updates, listener);
+      try {
+        switch (order) {
+          case REPO_BEFORE_DB:
+            for (NoteDbBatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            listener.afterUpdateRepos();
+            for (NoteDbBatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (ChangesHandle h : handles) {
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            listener.afterUpdateRefs();
+            listener.afterUpdateChanges();
+            break;
+
+          case DB_BEFORE_REPO:
+            // Call updateChange for each op before updateRepo, but defer executing the
+            // NoteDbUpdateManager until after calling updateRepo. They share an inserter and
+            // BatchRefUpdate, so it will all execute as a single batch. But we have to let
+            // NoteDbUpdateManager actually execute the update, since it has to interleave it
+            // properly with All-Users updates.
+            //
+            // TODO(dborowitz): This may still result in multiple updates to All-Users, but that's
+            // currently not a big deal because multi-change batches generally aren't affecting
+            // drafts anyway.
+            for (NoteDbBatchUpdate u : updates) {
+              handles.add(u.executeChangeOps(dryrun));
+            }
+            for (NoteDbBatchUpdate u : updates) {
+              u.executeUpdateRepo();
+            }
+            for (ChangesHandle h : handles) {
+              // TODO(dborowitz): This isn't quite good enough: in theory updateRepo may want to
+              // see the results of change meta commands, but they aren't actually added to the
+              // BatchUpdate until the body of execute. To fix this, execute needs to be split up
+              // into a method that returns a BatchRefUpdate before execution. Not a big deal at the
+              // moment, because this order is only used for deleting changes, and those updateRepo
+              // implementations definitely don't need to observe the updated change meta refs.
+              h.execute();
+              indexFutures.addAll(h.startIndexFutures());
+            }
+            break;
+          default:
+            throw new IllegalStateException("invalid execution order: " + order);
+        }
+      } finally {
+        for (ChangesHandle h : handles) {
+          h.close();
+        }
+      }
+
+      ChangeIndexer.allAsList(indexFutures).get();
+
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
+      if (!dryrun) {
+        for (NoteDbBatchUpdate u : updates) {
+          u.executePostOps();
+        }
+      }
+    } catch (Exception e) {
+      wrapAndThrowException(e);
+    }
+  }
+
+  class ContextImpl implements Context {
+    @Override
+    public RepoView getRepoView() throws IOException {
+      return NoteDbBatchUpdate.this.getRepoView();
+    }
+
+    @Override
+    public RevWalk getRevWalk() throws IOException {
+      return getRepoView().getRevWalk();
+    }
+
+    @Override
+    public Project.NameKey getProject() {
+      return project;
+    }
+
+    @Override
+    public Timestamp getWhen() {
+      return when;
+    }
+
+    @Override
+    public TimeZone getTimeZone() {
+      return tz;
+    }
+
+    @Override
+    public ReviewDb getDb() {
+      return db;
+    }
+
+    @Override
+    public CurrentUser getUser() {
+      return user;
+    }
+
+    @Override
+    public Order getOrder() {
+      return order;
+    }
+  }
+
+  private class RepoContextImpl extends ContextImpl implements RepoContext {
+    @Override
+    public ObjectInserter getInserter() throws IOException {
+      return getRepoView().getInserterWrapper();
+    }
+
+    @Override
+    public void addRefUpdate(ReceiveCommand cmd) throws IOException {
+      getRepoView().getCommands().add(cmd);
+    }
+  }
+
+  private class ChangeContextImpl extends ContextImpl implements ChangeContext {
+    private final ChangeNotes notes;
+    private final Map<PatchSet.Id, ChangeUpdate> updates;
+
+    private boolean deleted;
+
+    protected ChangeContextImpl(ChangeNotes notes) {
+      this.notes = checkNotNull(notes);
+      updates = new TreeMap<>(comparing(PatchSet.Id::get));
+    }
+
+    @Override
+    public ChangeUpdate getUpdate(PatchSet.Id psId) {
+      ChangeUpdate u = updates.get(psId);
+      if (u == null) {
+        u = changeUpdateFactory.create(notes, user, when);
+        if (newChanges.containsKey(notes.getChangeId())) {
+          u.setAllowWriteToNewRef(true);
+        }
+        u.setPatchSetId(psId);
+        updates.put(psId, u);
+      }
+      return u;
+    }
+
+    @Override
+    public ChangeNotes getNotes() {
+      return notes;
+    }
+
+    @Override
+    public void dontBumpLastUpdatedOn() {
+      // Do nothing; NoteDb effectively updates timestamp if and only if a commit was written to the
+      // change meta ref.
+    }
+
+    @Override
+    public void deleteChange() {
+      deleted = true;
+    }
+  }
+
+  /** Per-change result status from {@link #executeChangeOps}. */
+  private enum ChangeResult {
+    SKIPPED,
+    UPSERTED,
+    DELETED;
+  }
+
+  private final ChangeNotes.Factory changeNotesFactory;
+  private final ChangeUpdate.Factory changeUpdateFactory;
+  private final NoteDbUpdateManager.Factory updateManagerFactory;
+  private final ChangeIndexer indexer;
+  private final GitReferenceUpdated gitRefUpdated;
+  private final ReviewDb db;
+
+  @Inject
+  NoteDbBatchUpdate(
+      GitRepositoryManager repoManager,
+      @GerritPersonIdent PersonIdent serverIdent,
+      ChangeNotes.Factory changeNotesFactory,
+      ChangeUpdate.Factory changeUpdateFactory,
+      NoteDbUpdateManager.Factory updateManagerFactory,
+      ChangeIndexer indexer,
+      GitReferenceUpdated gitRefUpdated,
+      @Assisted ReviewDb db,
+      @Assisted Project.NameKey project,
+      @Assisted CurrentUser user,
+      @Assisted Timestamp when) {
+    super(repoManager, serverIdent, project, user, when);
+    this.changeNotesFactory = changeNotesFactory;
+    this.changeUpdateFactory = changeUpdateFactory;
+    this.updateManagerFactory = updateManagerFactory;
+    this.indexer = indexer;
+    this.gitRefUpdated = gitRefUpdated;
+    this.db = db;
+  }
+
+  @Override
+  public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
+    execute(ImmutableList.of(this), listener, requestId, false);
+  }
+
+  @Override
+  protected Context newContext() {
+    return new ContextImpl();
+  }
+
+  private void executeUpdateRepo() throws UpdateException, RestApiException {
+    try {
+      logDebug("Executing updateRepo on {} ops", ops.size());
+      RepoContextImpl ctx = new RepoContextImpl();
+      for (BatchUpdateOp op : ops.values()) {
+        op.updateRepo(ctx);
+      }
+
+      logDebug("Executing updateRepo on {} RepoOnlyOps", repoOnlyOps.size());
+      for (RepoOnlyOp op : repoOnlyOps) {
+        op.updateRepo(ctx);
+      }
+
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
+      }
+    } catch (Exception e) {
+      Throwables.throwIfInstanceOf(e, RestApiException.class);
+      throw new UpdateException(e);
+    }
+  }
+
+  private class ChangesHandle implements AutoCloseable {
+    private final NoteDbUpdateManager manager;
+    private final boolean dryrun;
+    private final Map<Change.Id, ChangeResult> results;
+
+    ChangesHandle(NoteDbUpdateManager manager, boolean dryrun) {
+      this.manager = manager;
+      this.dryrun = dryrun;
+      results = new HashMap<>();
+    }
+
+    @Override
+    public void close() {
+      manager.close();
+    }
+
+    void setResult(Change.Id id, ChangeResult result) {
+      ChangeResult old = results.putIfAbsent(id, result);
+      checkArgument(old == null, "result for change %s already set: %s", id, old);
+    }
+
+    void execute() throws OrmException, IOException {
+      NoteDbBatchUpdate.this.batchRefUpdate = manager.execute(dryrun);
+    }
+
+    @SuppressWarnings("deprecation")
+    List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> startIndexFutures() {
+      if (dryrun) {
+        return ImmutableList.of();
+      }
+      logDebug("Reindexing {} changes", results.size());
+      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
+          new ArrayList<>(results.size());
+      for (Map.Entry<Change.Id, ChangeResult> e : results.entrySet()) {
+        Change.Id id = e.getKey();
+        switch (e.getValue()) {
+          case UPSERTED:
+            indexFutures.add(indexer.indexAsync(project, id));
+            break;
+          case DELETED:
+            indexFutures.add(indexer.deleteAsync(id));
+            break;
+          case SKIPPED:
+            break;
+          default:
+            throw new IllegalStateException("unexpected result: " + e.getValue());
+        }
+      }
+      return indexFutures;
+    }
+  }
+
+  private ChangesHandle executeChangeOps(boolean dryrun) throws Exception {
+    logDebug("Executing change ops");
+    initRepository();
+    Repository repo = repoView.getRepository();
+    checkState(
+        repo.getRefDatabase().performsAtomicTransactions(),
+        "cannot use NoteDb with a repository that does not support atomic batch ref updates: %s",
+        repo);
+
+    ChangesHandle handle =
+        new ChangesHandle(
+            updateManagerFactory
+                .create(project)
+                .setChangeRepo(
+                    repo, repoView.getRevWalk(), repoView.getInserter(), repoView.getCommands()),
+            dryrun);
+    if (user.isIdentifiedUser()) {
+      handle.manager.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
+    }
+    handle.manager.setRefLogMessage(refLogMessage);
+    handle.manager.setPushCertificate(pushCert);
+    for (Map.Entry<Change.Id, Collection<BatchUpdateOp>> e : ops.asMap().entrySet()) {
+      Change.Id id = e.getKey();
+      ChangeContextImpl ctx = newChangeContext(id);
+      boolean dirty = false;
+      logDebug("Applying {} ops for change {}", e.getValue().size(), id);
+      for (BatchUpdateOp op : e.getValue()) {
+        dirty |= op.updateChange(ctx);
+      }
+      if (!dirty) {
+        logDebug("No ops reported dirty, short-circuiting");
+        handle.setResult(id, ChangeResult.SKIPPED);
+        continue;
+      }
+      for (ChangeUpdate u : ctx.updates.values()) {
+        handle.manager.add(u);
+      }
+      if (ctx.deleted) {
+        logDebug("Change {} was deleted", id);
+        handle.manager.deleteChange(id);
+        handle.setResult(id, ChangeResult.DELETED);
+      } else {
+        handle.setResult(id, ChangeResult.UPSERTED);
+      }
+    }
+    return handle;
+  }
+
+  private ChangeContextImpl newChangeContext(Change.Id id) throws OrmException {
+    logDebug("Opening change {} for update", id);
+    Change c = newChanges.get(id);
+    boolean isNew = c != null;
+    if (!isNew) {
+      // Pass a synthetic change into ChangeNotes.Factory, which will take care of checking for
+      // existence and populating columns from the parsed notes state.
+      // TODO(dborowitz): This dance made more sense when using Reviewdb; consider a nicer way.
+      c = ChangeNotes.Factory.newNoteDbOnlyChange(project, id);
+    } else {
+      logDebug("Change {} is new", id);
+    }
+    ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
+    return new ChangeContextImpl(notes);
+  }
+
+  private void executePostOps() throws Exception {
+    ContextImpl ctx = new ContextImpl();
+    for (BatchUpdateOp op : ops.values()) {
+      op.postUpdate(ctx);
+    }
+
+    for (RepoOnlyOp op : repoOnlyOps) {
+      op.postUpdate(ctx);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java
deleted file mode 100644
index 37c1d60..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReadOnlyRepository.java
+++ /dev/null
@@ -1,180 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.update;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import java.io.IOException;
-import java.util.List;
-import java.util.Map;
-import org.eclipse.jgit.attributes.AttributesNodeProvider;
-import org.eclipse.jgit.lib.BaseRepositoryBuilder;
-import org.eclipse.jgit.lib.ObjectDatabase;
-import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
-import org.eclipse.jgit.lib.Ref;
-import org.eclipse.jgit.lib.RefDatabase;
-import org.eclipse.jgit.lib.RefRename;
-import org.eclipse.jgit.lib.RefUpdate;
-import org.eclipse.jgit.lib.ReflogReader;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.lib.StoredConfig;
-
-class ReadOnlyRepository extends Repository {
-  private static final String MSG = "Cannot modify a " + ReadOnlyRepository.class.getSimpleName();
-
-  private static BaseRepositoryBuilder<?, ?> builder(Repository r) {
-    checkNotNull(r);
-    BaseRepositoryBuilder<?, ?> builder =
-        new BaseRepositoryBuilder<>().setFS(r.getFS()).setGitDir(r.getDirectory());
-
-    if (!r.isBare()) {
-      builder.setWorkTree(r.getWorkTree()).setIndexFile(r.getIndexFile());
-    }
-    return builder;
-  }
-
-  private final Repository delegate;
-  private final RefDb refdb;
-  private final ObjDb objdb;
-
-  ReadOnlyRepository(Repository delegate) {
-    super(builder(delegate));
-    this.delegate = delegate;
-    this.refdb = new RefDb(delegate.getRefDatabase());
-    this.objdb = new ObjDb(delegate.getObjectDatabase());
-  }
-
-  @Override
-  public void create(boolean bare) throws IOException {
-    throw new UnsupportedOperationException(MSG);
-  }
-
-  @Override
-  public ObjectDatabase getObjectDatabase() {
-    return objdb;
-  }
-
-  @Override
-  public RefDatabase getRefDatabase() {
-    return refdb;
-  }
-
-  @Override
-  public StoredConfig getConfig() {
-    return delegate.getConfig();
-  }
-
-  @Override
-  public AttributesNodeProvider createAttributesNodeProvider() {
-    return delegate.createAttributesNodeProvider();
-  }
-
-  @Override
-  public void scanForRepoChanges() throws IOException {
-    delegate.scanForRepoChanges();
-  }
-
-  @Override
-  public void notifyIndexChanged() {
-    delegate.notifyIndexChanged();
-  }
-
-  @Override
-  public ReflogReader getReflogReader(String refName) throws IOException {
-    return delegate.getReflogReader(refName);
-  }
-
-  @Override
-  public String getGitwebDescription() throws IOException {
-    return delegate.getGitwebDescription();
-  }
-
-  private static class RefDb extends RefDatabase {
-    private final RefDatabase delegate;
-
-    private RefDb(RefDatabase delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public void create() throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public void close() {
-      delegate.close();
-    }
-
-    @Override
-    public boolean isNameConflicting(String name) throws IOException {
-      return delegate.isNameConflicting(name);
-    }
-
-    @Override
-    public RefUpdate newUpdate(String name, boolean detach) throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public RefRename newRename(String fromName, String toName) throws IOException {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public Ref getRef(String name) throws IOException {
-      return delegate.getRef(name);
-    }
-
-    @Override
-    public Map<String, Ref> getRefs(String prefix) throws IOException {
-      return delegate.getRefs(prefix);
-    }
-
-    @Override
-    public List<Ref> getAdditionalRefs() throws IOException {
-      return delegate.getAdditionalRefs();
-    }
-
-    @Override
-    public Ref peel(Ref ref) throws IOException {
-      return delegate.peel(ref);
-    }
-  }
-
-  private static class ObjDb extends ObjectDatabase {
-    private final ObjectDatabase delegate;
-
-    private ObjDb(ObjectDatabase delegate) {
-      this.delegate = checkNotNull(delegate);
-    }
-
-    @Override
-    public ObjectInserter newInserter() {
-      throw new UnsupportedOperationException(MSG);
-    }
-
-    @Override
-    public ObjectReader newReader() {
-      return delegate.newReader();
-    }
-
-    @Override
-    public void close() {
-      delegate.close();
-    }
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RefUpdateUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RefUpdateUtil.java
new file mode 100644
index 0000000..86b4eef
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RefUpdateUtil.java
@@ -0,0 +1,84 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.update;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.server.git.LockFailureException;
+import java.io.IOException;
+import org.eclipse.jgit.internal.JGitText;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.NullProgressMonitor;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.transport.ReceiveCommand;
+
+/** Static utilities for working with JGit's ref update APIs. */
+public class RefUpdateUtil {
+  /**
+   * Execute a batch ref update, throwing a checked exception if not all updates succeeded.
+   *
+   * @param bru batch update; should already have been executed.
+   * @throws LockFailureException if the transaction was aborted due to lock failure; see {@link
+   *     #checkResults(BatchRefUpdate)} for details.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  public static void executeChecked(BatchRefUpdate bru, RevWalk rw) throws IOException {
+    bru.execute(rw, NullProgressMonitor.INSTANCE);
+    checkResults(bru);
+  }
+
+  /**
+   * Check results of all commands in the update batch, reducing to a single exception if there was
+   * a failure.
+   *
+   * <p>Throws {@link LockFailureException} if at least one command failed with {@code
+   * LOCK_FAILURE}, and the entire transaction was aborted, i.e. any non-{@code LOCK_FAILURE}
+   * results, if there were any, failed with "transaction aborted".
+   *
+   * <p>In particular, if the underlying ref database does not {@link
+   * org.eclipse.jgit.lib.RefDatabase#performsAtomicTransactions() perform atomic transactions},
+   * then a combination of {@code LOCK_FAILURE} on one ref and {@code OK} or another result on other
+   * refs will <em>not</em> throw {@code LockFailureException}.
+   *
+   * @param bru batch update; should already have been executed.
+   * @throws LockFailureException if the transaction was aborted due to lock failure.
+   * @throws IOException if any result was not {@code OK}.
+   */
+  @VisibleForTesting
+  static void checkResults(BatchRefUpdate bru) throws IOException {
+    int lockFailure = 0;
+    int aborted = 0;
+    int failure = 0;
+
+    for (ReceiveCommand cmd : bru.getCommands()) {
+      if (cmd.getResult() != ReceiveCommand.Result.OK) {
+        failure++;
+      }
+      if (cmd.getResult() == ReceiveCommand.Result.LOCK_FAILURE) {
+        lockFailure++;
+      } else if (cmd.getResult() == ReceiveCommand.Result.REJECTED_OTHER_REASON
+          && JGitText.get().transactionAborted.equals(cmd.getMessage())) {
+        aborted++;
+      }
+    }
+
+    if (lockFailure + aborted == bru.getCommands().size()) {
+      throw new LockFailureException("Update aborted with one or more lock failures: " + bru, bru);
+    } else if (failure > 0) {
+      throw new IOException("Update failed: " + bru);
+    }
+  }
+
+  private RefUpdateUtil() {}
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
index 5009c50..9faf628 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoContext.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.update;
 
 import java.io.IOException;
+import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
 import org.eclipse.jgit.transport.ReceiveCommand;
 
@@ -31,11 +32,28 @@
   /**
    * Add a command to the pending list of commands.
    *
-   * <p>Callers should use this method instead of writing directly to the repository returned by
-   * {@link #getRepository()}.
+   * <p>Adding commands to the {@code RepoContext} is the only way of updating refs in the
+   * repository from a {@link BatchUpdateOp}.
    *
    * @param cmd ref update command.
    * @throws IOException if an error occurred opening the repo.
    */
   void addRefUpdate(ReceiveCommand cmd) throws IOException;
+
+  /**
+   * Add a command to the pending list of commands.
+   *
+   * <p>Adding commands to the {@code RepoContext} is the only way of updating refs in the
+   * repository from a {@link BatchUpdateOp}.
+   *
+   * @param oldId the old object ID; must not be null. Use {@link ObjectId#zeroId()} for ref
+   *     creation.
+   * @param newId the new object ID; must not be null. Use {@link ObjectId#zeroId()} for ref
+   *     deletion.
+   * @param refName the ref name.
+   * @throws IOException if an error occurred opening the repo.
+   */
+  default void addRefUpdate(ObjectId oldId, ObjectId newId, String refName) throws IOException {
+    addRefUpdate(new ReceiveCommand(oldId, newId, refName));
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java
new file mode 100644
index 0000000..8839dbe
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RepoView.java
@@ -0,0 +1,234 @@
+// 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.update;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.google.common.collect.Maps;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import java.io.IOException;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/**
+ * Restricted view of a {@link Repository} for use by {@link BatchUpdateOp} implementations.
+ *
+ * <p>This class serves two purposes in the context of {@link BatchUpdate}. First, the subset of
+ * normal Repository functionality is purely read-only, which prevents implementors from modifying
+ * the repository outside of {@link BatchUpdateOp#updateRepo}. Write operations can only be
+ * performed by calling methods on {@link RepoContext}.
+ *
+ * <p>Second, the read methods take into account any pending operations on the repository that
+ * implementations have staged using the write methods on {@link RepoContext}. Callers do not have
+ * to worry about whether operations have been performed yet, and the implementation details may
+ * differ between ReviewDb and NoteDb, but callers just don't need to care.
+ */
+public class RepoView {
+  private final Repository repo;
+  private final RevWalk rw;
+  private final ObjectInserter inserter;
+  private final ObjectInserter inserterWrapper;
+  private final ChainedReceiveCommands commands;
+  private final boolean closeRepo;
+
+  RepoView(GitRepositoryManager repoManager, Project.NameKey project) throws IOException {
+    repo = repoManager.openRepository(project);
+    inserter = repo.newObjectInserter();
+    inserterWrapper = new NonFlushingInserter(inserter);
+    rw = new RevWalk(inserter.newReader());
+    commands = new ChainedReceiveCommands(repo);
+    closeRepo = true;
+  }
+
+  RepoView(Repository repo, RevWalk rw, ObjectInserter inserter) {
+    checkArgument(
+        rw.getObjectReader().getCreatedFromInserter() == inserter,
+        "expected RevWalk %s to be created by ObjectInserter %s",
+        rw,
+        inserter);
+    this.repo = checkNotNull(repo);
+    this.rw = checkNotNull(rw);
+    this.inserter = checkNotNull(inserter);
+    inserterWrapper = new NonFlushingInserter(inserter);
+    commands = new ChainedReceiveCommands(repo);
+    closeRepo = false;
+  }
+
+  /**
+   * Get this repo's configuration.
+   *
+   * <p>This is the storage-level config you would get with {@link Repository#getConfig()}, not, for
+   * example, the Gerrit-level project config.
+   *
+   * @return a defensive copy of the config; modifications have no effect on the underlying config.
+   */
+  public Config getConfig() {
+    return new Config(repo.getConfig());
+  }
+
+  /**
+   * Get an open revwalk on the repo.
+   *
+   * <p>Guaranteed to be able to read back any objects inserted in the repository via {@link
+   * RepoContext#getInserter()}, even if objects have not been flushed to the underlying repo. In
+   * particular this includes any object returned by {@link #getRef(String)}, even taking into
+   * account not-yet-executed commands.
+   *
+   * @return revwalk.
+   */
+  public RevWalk getRevWalk() {
+    return rw;
+  }
+
+  /**
+   * Read a single ref from the repo.
+   *
+   * <p>Takes into account any ref update commands added during the course of the update using
+   * {@link RepoContext#addRefUpdate}, even if they have not yet been executed on the underlying
+   * repo.
+   *
+   * <p>The results of individual ref lookups are cached: calling this method multiple times with
+   * the same ref name will return the same result (unless a command was added in the meantime). The
+   * repo is not reread.
+   *
+   * @param name exact ref name.
+   * @return the value of the ref, if present.
+   * @throws IOException if an error occurred.
+   */
+  public Optional<ObjectId> getRef(String name) throws IOException {
+    return getCommands().get(name);
+  }
+
+  /**
+   * Look up refs by prefix.
+   *
+   * <p>Takes into account any ref update commands added during the course of the update using
+   * {@link RepoContext#addRefUpdate}, even if they have not yet been executed on the underlying
+   * repo.
+   *
+   * <p>For any ref that has previously been accessed with {@link #getRef(String)}, the value in the
+   * result map will be that same cached value. Any refs that have <em>not</em> been previously
+   * accessed are re-scanned from the repo on each call.
+   *
+   * @param prefix ref prefix; must end in '/' or else be empty.
+   * @return a map of ref suffixes to SHA-1s. The refs are all under {@code prefix} and have the
+   *     prefix stripped; this matches the behavior of {@link
+   *     org.eclipse.jgit.lib.RefDatabase#getRefs(String)}.
+   * @throws IOException if an error occurred.
+   */
+  public Map<String, ObjectId> getRefs(String prefix) throws IOException {
+    Map<String, ObjectId> result =
+        new HashMap<>(
+            Maps.transformValues(repo.getRefDatabase().getRefs(prefix), Ref::getObjectId));
+
+    // First, overwrite any cached reads from the underlying RepoRefCache. If any of these differ,
+    // it's because a ref was updated after the RepoRefCache read it. It feels a little odd to
+    // prefer the *old* value in this case, but it would be weirder to be inconsistent with getRef.
+    //
+    // Mostly this doesn't matter. If the caller was intending to write to the ref, they lost a
+    // race, and they will get a lock failure. If they just want to read, well, the JGit interface
+    // doesn't currently guarantee that any snapshot of multiple refs is consistent, so they were
+    // probably out of luck anyway.
+    commands
+        .getRepoRefCache()
+        .getCachedRefs()
+        .forEach((k, v) -> updateRefIfPrefixMatches(result, prefix, k, v));
+
+    // Second, overwrite with any pending commands.
+    commands
+        .getCommands()
+        .values()
+        .forEach(
+            c ->
+                updateRefIfPrefixMatches(result, prefix, c.getRefName(), toOptional(c.getNewId())));
+
+    return result;
+  }
+
+  private static Optional<ObjectId> toOptional(ObjectId id) {
+    return id.equals(ObjectId.zeroId()) ? Optional.empty() : Optional.of(id);
+  }
+
+  private static void updateRefIfPrefixMatches(
+      Map<String, ObjectId> map, String prefix, String fullRefName, Optional<ObjectId> maybeId) {
+    if (!fullRefName.startsWith(prefix)) {
+      return;
+    }
+    String suffix = fullRefName.substring(prefix.length());
+    if (maybeId.isPresent()) {
+      map.put(suffix, maybeId.get());
+    } else {
+      map.remove(suffix);
+    }
+  }
+
+  // Not AutoCloseable so callers can't improperly close it. Plus it's never managed with a try
+  // block anyway.
+  void close() {
+    if (closeRepo) {
+      inserter.close();
+      rw.close();
+      repo.close();
+    }
+  }
+
+  Repository getRepository() {
+    return repo;
+  }
+
+  ObjectInserter getInserter() {
+    return inserter;
+  }
+
+  ObjectInserter getInserterWrapper() {
+    return inserterWrapper;
+  }
+
+  ChainedReceiveCommands getCommands() {
+    return commands;
+  }
+
+  private static class NonFlushingInserter extends ObjectInserter.Filter {
+    private final ObjectInserter delegate;
+
+    private NonFlushingInserter(ObjectInserter delegate) {
+      this.delegate = delegate;
+    }
+
+    @Override
+    protected ObjectInserter delegate() {
+      return delegate;
+    }
+
+    @Override
+    public void flush() {
+      // Do nothing.
+    }
+
+    @Override
+    public void close() {
+      // Do nothing; the delegate is closed separately.
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java
new file mode 100644
index 0000000..4cbaffd
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryHelper.java
@@ -0,0 +1,214 @@
+// 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.update;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static java.util.concurrent.TimeUnit.MILLISECONDS;
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import com.github.rholder.retry.Attempt;
+import com.github.rholder.retry.RetryException;
+import com.github.rholder.retry.RetryListener;
+import com.github.rholder.retry.RetryerBuilder;
+import com.github.rholder.retry.StopStrategies;
+import com.github.rholder.retry.WaitStrategies;
+import com.github.rholder.retry.WaitStrategy;
+import com.google.auto.value.AutoValue;
+import com.google.common.base.Throwables;
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Counter0;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Histogram0;
+import com.google.gerrit.metrics.MetricMaker;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.LockFailureException;
+import com.google.gerrit.server.notedb.NotesMigration;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.time.Duration;
+import java.util.concurrent.ExecutionException;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+public class RetryHelper {
+  public interface Action<T> {
+    T call(BatchUpdate.Factory updateFactory) throws Exception;
+  }
+
+  /**
+   * Options for retrying a single operation.
+   *
+   * <p>This class is similar in function to upstream's {@link RetryerBuilder}, but it exists as its
+   * own class in Gerrit for several reasons:
+   *
+   * <ul>
+   *   <li>Gerrit needs to support defaults for some of the options, such as a default timeout.
+   *       {@code RetryerBuilder} doesn't support calling the same setter multiple times, so doing
+   *       this with {@code RetryerBuilder} directly would not be easy.
+   *   <li>Gerrit explicitly does not want callers to have full control over all possible options,
+   *       so this class exposes a curated subset.
+   * </ul>
+   */
+  @AutoValue
+  public abstract static class Options {
+    @Nullable
+    abstract RetryListener listener();
+
+    @Nullable
+    abstract Duration timeout();
+
+    @AutoValue.Builder
+    public abstract static class Builder {
+      public abstract Builder listener(RetryListener listener);
+
+      public abstract Builder timeout(Duration timeout);
+
+      public abstract Options build();
+    }
+  }
+
+  @Singleton
+  private static class Metrics {
+    final Histogram0 attemptCounts;
+    final Counter0 timeoutCount;
+
+    @Inject
+    Metrics(MetricMaker metricMaker) {
+      attemptCounts =
+          metricMaker.newHistogram(
+              "batch_update/retry_attempt_counts",
+              new Description(
+                      "Distribution of number of attempts made by RetryHelper"
+                          + " (1 == single attempt, no retry)")
+                  .setCumulative()
+                  .setUnit("attempts"));
+      timeoutCount =
+          metricMaker.newCounter(
+              "batch_update/retry_timeout_count",
+              new Description("Number of executions of RetryHelper that ultimately timed out")
+                  .setCumulative()
+                  .setUnit("timeouts"));
+    }
+  }
+
+  public static Options.Builder options() {
+    return new AutoValue_RetryHelper_Options.Builder();
+  }
+
+  public static Options defaults() {
+    return options().build();
+  }
+
+  private final NotesMigration migration;
+  private final Metrics metrics;
+  private final BatchUpdate.Factory updateFactory;
+  private final Duration defaultTimeout;
+  private final WaitStrategy waitStrategy;
+
+  @Inject
+  RetryHelper(
+      @GerritServerConfig Config cfg,
+      Metrics metrics,
+      NotesMigration migration,
+      ReviewDbBatchUpdate.AssistedFactory reviewDbBatchUpdateFactory,
+      NoteDbBatchUpdate.AssistedFactory noteDbBatchUpdateFactory) {
+    this.metrics = metrics;
+    this.migration = migration;
+    this.updateFactory =
+        new BatchUpdate.Factory(migration, reviewDbBatchUpdateFactory, noteDbBatchUpdateFactory);
+    this.defaultTimeout =
+        Duration.ofMillis(
+            cfg.getTimeUnit("noteDb", null, "retryTimeout", SECONDS.toMillis(20), MILLISECONDS));
+    this.waitStrategy =
+        WaitStrategies.join(
+            WaitStrategies.exponentialWait(
+                cfg.getTimeUnit("noteDb", null, "retryMaxWait", SECONDS.toMillis(5), MILLISECONDS),
+                MILLISECONDS),
+            WaitStrategies.randomWait(50, MILLISECONDS));
+  }
+
+  public Duration getDefaultTimeout() {
+    return defaultTimeout;
+  }
+
+  public <T> T execute(Action<T> action) throws RestApiException, UpdateException {
+    return execute(action, defaults());
+  }
+
+  public <T> T execute(Action<T> action, Options opts) throws RestApiException, UpdateException {
+    MetricListener listener = null;
+    try {
+      RetryerBuilder<T> builder = RetryerBuilder.newBuilder();
+      if (migration.disableChangeReviewDb()) {
+        listener = new MetricListener(opts.listener());
+        builder
+            .withRetryListener(listener)
+            .withStopStrategy(
+                StopStrategies.stopAfterDelay(
+                    firstNonNull(opts.timeout(), defaultTimeout).toMillis(), MILLISECONDS))
+            .withWaitStrategy(waitStrategy)
+            .retryIfException(RetryHelper::isLockFailure);
+      } else {
+        // Either we aren't full-NoteDb, or the underlying ref storage doesn't support atomic
+        // transactions. Either way, retrying a partially-failed operation is not idempotent, so
+        // don't do it automatically. Let the end user decide whether they want to retry.
+      }
+      return builder.build().call(() -> action.call(updateFactory));
+    } catch (ExecutionException | RetryException e) {
+      if (e instanceof RetryException) {
+        metrics.timeoutCount.increment();
+      }
+      if (e.getCause() != null) {
+        Throwables.throwIfInstanceOf(e.getCause(), UpdateException.class);
+        Throwables.throwIfInstanceOf(e.getCause(), RestApiException.class);
+      }
+      throw new UpdateException(e);
+    } finally {
+      if (listener != null) {
+        metrics.attemptCounts.record(listener.getAttemptCount());
+      }
+    }
+  }
+
+  private static boolean isLockFailure(Throwable t) {
+    if (t instanceof UpdateException) {
+      t = t.getCause();
+    }
+    return t instanceof LockFailureException;
+  }
+
+  private static class MetricListener implements RetryListener {
+    private final RetryListener delegate;
+    private long attemptCount;
+
+    MetricListener(@Nullable RetryListener delegate) {
+      this.delegate = delegate;
+      attemptCount = 1;
+    }
+
+    @Override
+    public <V> void onRetry(Attempt<V> attempt) {
+      attemptCount = attempt.getAttemptNumber();
+      if (delegate != null) {
+        delegate.onRetry(attempt);
+      }
+    }
+
+    long getAttemptCount() {
+      return attemptCount;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryingRestModifyView.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryingRestModifyView.java
new file mode 100644
index 0000000..e2f4a02
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/RetryingRestModifyView.java
@@ -0,0 +1,35 @@
+// 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.update;
+
+import com.google.gerrit.extensions.restapi.RestModifyView;
+import com.google.gerrit.extensions.restapi.RestResource;
+
+public abstract class RetryingRestModifyView<R extends RestResource, I, O>
+    implements RestModifyView<R, I> {
+  private final RetryHelper retryHelper;
+
+  protected RetryingRestModifyView(RetryHelper retryHelper) {
+    this.retryHelper = retryHelper;
+  }
+
+  @Override
+  public final O apply(R resource, I input) throws Exception {
+    return retryHelper.execute((updateFactory) -> applyImpl(updateFactory, resource, input));
+  }
+
+  protected abstract O applyImpl(BatchUpdate.Factory updateFactory, R resource, I input)
+      throws Exception;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
index 63d83ac..6835cb4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/update/ReviewDbBatchUpdate.java
@@ -14,11 +14,11 @@
 
 package com.google.gerrit.server.update;
 
-import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 import static java.util.Comparator.comparing;
 import static java.util.concurrent.TimeUnit.NANOSECONDS;
+import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Stopwatch;
 import com.google.common.base.Throwables;
@@ -29,7 +29,6 @@
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.metrics.Description;
 import com.google.gerrit.metrics.Description.Units;
@@ -57,18 +56,12 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.NoteDbUpdateManager.MismatchedStateException;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.InvalidChangeOperationException;
-import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
-import com.google.gerrit.server.project.NoSuchRefException;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 import com.google.inject.assistedinject.Assisted;
-import com.google.inject.assistedinject.AssistedInject;
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.ArrayList;
@@ -84,7 +77,6 @@
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.NullProgressMonitor;
 import org.eclipse.jgit.lib.ObjectInserter;
-import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Repository;
 import org.eclipse.jgit.revwalk.RevWalk;
@@ -117,19 +109,14 @@
   }
 
   class ContextImpl implements Context {
-    private Repository repoWrapper;
-
     @Override
-    public Repository getRepository() throws IOException {
-      if (repoWrapper == null) {
-        repoWrapper = new ReadOnlyRepository(ReviewDbBatchUpdate.this.getRepository());
-      }
-      return repoWrapper;
+    public RepoView getRepoView() throws IOException {
+      return ReviewDbBatchUpdate.this.getRepoView();
     }
 
     @Override
     public RevWalk getRevWalk() throws IOException {
-      return ReviewDbBatchUpdate.this.getRevWalk();
+      return getRepoView().getRevWalk();
     }
 
     @Override
@@ -165,24 +152,19 @@
 
   private class RepoContextImpl extends ContextImpl implements RepoContext {
     @Override
-    public Repository getRepository() throws IOException {
-      return ReviewDbBatchUpdate.this.getRepository();
-    }
-
-    @Override
     public ObjectInserter getInserter() throws IOException {
-      return ReviewDbBatchUpdate.this.getObjectInserter();
+      return getRepoView().getInserterWrapper();
     }
 
     @Override
     public void addRefUpdate(ReceiveCommand cmd) throws IOException {
       initRepository();
-      commands.add(cmd);
+      repoView.getCommands().add(cmd);
     }
   }
 
   private class ChangeContextImpl extends ContextImpl implements ChangeContext {
-    private final ChangeControl ctl;
+    private final ChangeNotes notes;
     private final Map<PatchSet.Id, ChangeUpdate> updates;
     private final ReviewDbWrapper dbWrapper;
     private final Repository threadLocalRepo;
@@ -192,8 +174,8 @@
     private boolean bumpLastUpdatedOn = true;
 
     protected ChangeContextImpl(
-        ChangeControl ctl, ReviewDbWrapper dbWrapper, Repository repo, RevWalk rw) {
-      this.ctl = ctl;
+        ChangeNotes notes, ReviewDbWrapper dbWrapper, Repository repo, RevWalk rw) {
+      this.notes = checkNotNull(notes);
       this.dbWrapper = dbWrapper;
       this.threadLocalRepo = repo;
       this.threadLocalRevWalk = rw;
@@ -207,11 +189,6 @@
     }
 
     @Override
-    public Repository getRepository() {
-      return threadLocalRepo;
-    }
-
-    @Override
     public RevWalk getRevWalk() {
       return threadLocalRevWalk;
     }
@@ -220,8 +197,8 @@
     public ChangeUpdate getUpdate(PatchSet.Id psId) {
       ChangeUpdate u = updates.get(psId);
       if (u == null) {
-        u = changeUpdateFactory.create(ctl, when);
-        if (newChanges.containsKey(ctl.getId())) {
+        u = changeUpdateFactory.create(notes, user, when);
+        if (newChanges.containsKey(notes.getChangeId())) {
           u.setAllowWriteToNewRef(true);
         }
         u.setPatchSetId(psId);
@@ -231,14 +208,13 @@
     }
 
     @Override
-    public ChangeControl getControl() {
-      checkNotNull(ctl);
-      return ctl;
+    public ChangeNotes getNotes() {
+      return notes;
     }
 
     @Override
-    public void bumpLastUpdatedOn(boolean bump) {
-      bumpLastUpdatedOn = bump;
+    public void dontBumpLastUpdatedOn() {
+      bumpLastUpdatedOn = false;
     }
 
     @Override
@@ -272,18 +248,9 @@
     if (updates.isEmpty()) {
       return;
     }
-    if (requestId != null) {
-      for (BatchUpdate u : updates) {
-        checkArgument(
-            u.requestId == null || u.requestId == requestId,
-            "refusing to overwrite RequestId %s in update with %s",
-            u.requestId,
-            requestId);
-        u.setRequestId(requestId);
-      }
-    }
+    setRequestIds(updates, requestId);
     try {
-      Order order = getOrder(updates);
+      Order order = getOrder(updates, listener);
       boolean updateChangesInParallel = getUpdateChangesInParallel(updates);
       switch (order) {
         case REPO_BEFORE_DB:
@@ -304,66 +271,41 @@
           for (ReviewDbBatchUpdate u : updates) {
             u.reindexChanges(u.executeChangeOps(updateChangesInParallel, dryrun));
           }
-          listener.afterUpdateChanges();
           for (ReviewDbBatchUpdate u : updates) {
             u.executeUpdateRepo();
           }
-          listener.afterUpdateRepos();
           for (ReviewDbBatchUpdate u : updates) {
             u.executeRefUpdates(dryrun);
           }
-          listener.afterUpdateRefs();
           break;
         default:
           throw new IllegalStateException("invalid execution order: " + order);
       }
 
-      @SuppressWarnings("deprecation")
-      List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
-          new ArrayList<>();
-      for (ReviewDbBatchUpdate u : updates) {
-        indexFutures.addAll(u.indexFutures);
-      }
-      ChangeIndexer.allAsList(indexFutures).get();
+      ChangeIndexer.allAsList(
+              updates.stream().flatMap(u -> u.indexFutures.stream()).collect(toList()))
+          .get();
 
-      for (ReviewDbBatchUpdate u : updates) {
-        if (u.batchRefUpdate != null) {
-          // Fire ref update events only after all mutations are finished, since
-          // callers may assume a patch set ref being created means the change
-          // was created, or a branch advancing meaning some changes were
-          // closed.
-          u.gitRefUpdated.fire(
-              u.project,
-              u.batchRefUpdate,
-              u.getUser().isIdentifiedUser() ? u.getUser().asIdentifiedUser().getAccount() : null);
-        }
-      }
+      // Fire ref update events only after all mutations are finished, since callers may assume a
+      // patch set ref being created means the change was created, or a branch advancing meaning
+      // some changes were closed.
+      updates
+          .stream()
+          .filter(u -> u.batchRefUpdate != null)
+          .forEach(
+              u -> u.gitRefUpdated.fire(u.project, u.batchRefUpdate, u.getAccount().orElse(null)));
+
       if (!dryrun) {
         for (ReviewDbBatchUpdate u : updates) {
           u.executePostOps();
         }
       }
-    } catch (UpdateException | RestApiException e) {
-      // Propagate REST API exceptions thrown by operations; they commonly throw
-      // exceptions like ResourceConflictException to indicate an atomic update
-      // failure.
-      throw e;
-
-      // Convert other common non-REST exception types with user-visible
-      // messages to corresponding REST exception types
-    } catch (InvalidChangeOperationException e) {
-      throw new ResourceConflictException(e.getMessage(), e);
-    } catch (NoSuchChangeException | NoSuchRefException | NoSuchProjectException e) {
-      throw new ResourceNotFoundException(e.getMessage(), e);
-
     } catch (Exception e) {
-      Throwables.throwIfUnchecked(e);
-      throw new UpdateException(e);
+      wrapAndThrowException(e);
     }
   }
 
   private final AllUsersName allUsers;
-  private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeIndexer indexer;
   private final ChangeNotes.Factory changeNotesFactory;
   private final ChangeUpdate.Factory changeUpdateFactory;
@@ -380,11 +322,10 @@
   private final List<com.google.common.util.concurrent.CheckedFuture<?, IOException>> indexFutures =
       new ArrayList<>();
 
-  @AssistedInject
+  @Inject
   ReviewDbBatchUpdate(
       @GerritServerConfig Config cfg,
       AllUsersName allUsers,
-      ChangeControl.GenericFactory changeControlFactory,
       ChangeIndexer indexer,
       ChangeNotes.Factory changeNotesFactory,
       @ChangeUpdateExecutor ListeningExecutorService changeUpdateExector,
@@ -402,7 +343,6 @@
       @Assisted Timestamp when) {
     super(repoManager, serverIdent, project, user, when);
     this.allUsers = allUsers;
-    this.changeControlFactory = changeControlFactory;
     this.changeNotesFactory = changeNotesFactory;
     this.changeUpdateExector = changeUpdateExector;
     this.changeUpdateFactory = changeUpdateFactory;
@@ -417,11 +357,6 @@
   }
 
   @Override
-  public void execute() throws UpdateException, RestApiException {
-    execute(BatchUpdateListener.NONE);
-  }
-
-  @Override
   public void execute(BatchUpdateListener listener) throws UpdateException, RestApiException {
     execute(ImmutableList.of(this), listener, requestId, false);
   }
@@ -444,20 +379,18 @@
         op.updateRepo(ctx);
       }
 
-      if (onSubmitValidators != null && commands != null && !commands.isEmpty()) {
-        try (ObjectReader reader = ctx.getInserter().newReader()) {
-          // Validation of refs has to take place here and not at the beginning
-          // executeRefUpdates. Otherwise failing validation in a second BatchUpdate object will
-          // happen *after* first object's executeRefUpdates has finished, hence after first repo's
-          // refs have been updated, which is too late.
-          onSubmitValidators.validate(
-              project, new ReadOnlyRepository(getRepository()), reader, commands.getCommands());
-        }
+      if (onSubmitValidators != null && !getRefUpdates().isEmpty()) {
+        // Validation of refs has to take place here and not at the beginning of executeRefUpdates.
+        // Otherwise, failing validation in a second BatchUpdate object will happen *after* the
+        // first update's executeRefUpdates has finished, hence after first repo's refs have been
+        // updated, which is too late.
+        onSubmitValidators.validate(
+            project, ctx.getRevWalk().getObjectReader(), repoView.getCommands());
       }
 
-      if (inserter != null) {
+      if (repoView != null) {
         logDebug("Flushing inserter");
-        inserter.flush();
+        repoView.getInserter().flush();
       } else {
         logDebug("No objects to flush");
       }
@@ -468,24 +401,31 @@
   }
 
   private void executeRefUpdates(boolean dryrun) throws IOException, RestApiException {
-    if (commands == null || commands.isEmpty()) {
+    if (getRefUpdates().isEmpty()) {
       logDebug("No ref updates to execute");
       return;
     }
     // May not be opened if the caller added ref updates but no new objects.
+    // TODO(dborowitz): Really?
     initRepository();
-    batchRefUpdate = repo.getRefDatabase().newBatchUpdate();
+    batchRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
+    batchRefUpdate.setPushCertificate(pushCert);
     batchRefUpdate.setRefLogMessage(refLogMessage, true);
+    batchRefUpdate.setAllowNonFastForwards(true);
+    repoView.getCommands().addTo(batchRefUpdate);
     if (user.isIdentifiedUser()) {
       batchRefUpdate.setRefLogIdent(user.asIdentifiedUser().newRefLogIdent(when, tz));
     }
-    commands.addTo(batchRefUpdate);
     logDebug("Executing batch of {} ref updates", batchRefUpdate.getCommands().size());
     if (dryrun) {
       return;
     }
 
-    batchRefUpdate.execute(revWalk, NullProgressMonitor.INSTANCE);
+    // Force BatchRefUpdate to read newly referenced objects using a new RevWalk, rather than one
+    // that might have access to unflushed objects.
+    try (RevWalk updateRw = new RevWalk(repoView.getRepository())) {
+      batchRefUpdate.execute(updateRw, NullProgressMonitor.INSTANCE);
+    }
     boolean ok = true;
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
       if (cmd.getResult() != ReceiveCommand.Result.OK) {
@@ -510,11 +450,11 @@
 
       tasks = new ArrayList<>(ops.keySet().size());
       try {
-        if (notesMigration.commitChangeWrites() && repo != null) {
+        if (notesMigration.commitChangeWrites() && repoView != null) {
           // A NoteDb change may have been rebuilt since the repo was originally
           // opened, so make sure we see that.
           logDebug("Preemptively scanning for repo changes");
-          repo.scanForRepoChanges();
+          repoView.getRepository().scanForRepoChanges();
         }
         if (!ops.isEmpty() && notesMigration.failChangeWrites()) {
           // Fail fast before attempting any writes if changes are read-only, as
@@ -582,9 +522,10 @@
     // updates on the change repo first.
     logDebug("Executing NoteDb updates for {} changes", tasks.size());
     try {
-      BatchRefUpdate changeRefUpdate = getRepository().getRefDatabase().newBatchUpdate();
+      initRepository();
+      BatchRefUpdate changeRefUpdate = repoView.getRepository().getRefDatabase().newBatchUpdate();
       boolean hasAllUsersCommands = false;
-      try (ObjectInserter ins = getRepository().newObjectInserter()) {
+      try (ObjectInserter ins = repoView.getRepository().newObjectInserter()) {
         int objs = 0;
         for (ChangeTask task : tasks) {
           if (task.noteDbResult == null) {
@@ -691,7 +632,8 @@
     public Void call() throws Exception {
       taskId = id.toString() + "-" + Thread.currentThread().getId();
       if (Thread.currentThread() == mainThread) {
-        Repository repo = getRepository();
+        initRepository();
+        Repository repo = repoView.getRepository();
         try (RevWalk rw = new RevWalk(repo)) {
           call(ReviewDbBatchUpdate.this.db, repo, rw);
         }
@@ -839,8 +781,7 @@
         NoteDbChangeState.checkNotReadOnly(c, skewMs);
       }
       ChangeNotes notes = changeNotesFactory.createForBatchUpdate(c, !isNew);
-      ChangeControl ctl = changeControlFactory.controlFor(notes, user);
-      return new ChangeContextImpl(ctl, new BatchUpdateReviewDb(db), repo, rw);
+      return new ChangeContextImpl(notes, new BatchUpdateReviewDb(db), repo, rw);
     }
 
     private NoteDbUpdateManager stageNoteDbUpdate(ChangeContextImpl ctx, boolean deleted)
@@ -850,7 +791,10 @@
           updateManagerFactory
               .create(ctx.getProject())
               .setChangeRepo(
-                  ctx.getRepository(), ctx.getRevWalk(), null, new ChainedReceiveCommands(repo));
+                  ctx.threadLocalRepo,
+                  ctx.threadLocalRevWalk,
+                  null,
+                  new ChainedReceiveCommands(ctx.threadLocalRepo));
       if (ctx.getUser().isIdentifiedUser()) {
         updateManager.setRefLogIdent(
             ctx.getUser().asIdentifiedUser().newRefLogIdent(ctx.getWhen(), tz));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/CommitMessageUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/CommitMessageUtil.java
new file mode 100644
index 0000000..fa55597
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/CommitMessageUtil.java
@@ -0,0 +1,40 @@
+// 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.util;
+
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+
+/** Utility functions to manipulate commit messages. */
+public class CommitMessageUtil {
+
+  private CommitMessageUtil() {}
+
+  /**
+   * Checks for null or empty commit messages and appends a newline character to the commit message.
+   *
+   * @throws BadRequestException if the commit message is null or empty
+   * @returns the trimmed message with a trailing newline character
+   */
+  public static String checkAndSanitizeCommitMessage(String commitMessage)
+      throws BadRequestException {
+    String wellFormedMessage = Strings.nullToEmpty(commitMessage).trim();
+    if (wellFormedMessage.isEmpty()) {
+      throw new BadRequestException("Commit message cannot be null or empty");
+    }
+    wellFormedMessage = wellFormedMessage + "\n";
+    return wellFormedMessage;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/IdGenerator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/IdGenerator.java
index e4d2890..a9a22d9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/IdGenerator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/IdGenerator.java
@@ -52,7 +52,7 @@
   }
 
   /** A very simple bit permutation to mask a simple incrementer. */
-  public static int mix(final int salt, final int in) {
+  public static int mix(int salt, int in) {
     short v0 = hi16(in);
     short v1 = lo16(in);
     v0 += ((v1 << 2) + 0 ^ v1) + (salt ^ (v1 >>> 3)) + 1;
@@ -61,7 +61,7 @@
   }
 
   /* For testing only. */
-  static int unmix(final int in) {
+  static int unmix(int in) {
     short v0 = hi16(in);
     short v1 = lo16(in);
     v1 -= ((v0 << 2) + 2 ^ v0) + (salt ^ (v0 >>> 3)) + 3;
@@ -69,7 +69,7 @@
     return result(v0, v1);
   }
 
-  private static short hi16(final int in) {
+  private static short hi16(int in) {
     return (short)
         ( //
         ((in >>> 24 & 0xff))
@@ -78,7 +78,7 @@
         );
   }
 
-  private static short lo16(final int in) {
+  private static short lo16(int in) {
     return (short)
         ( //
         ((in >>> 8 & 0xff))
@@ -87,7 +87,7 @@
         );
   }
 
-  private static int result(final short v0, final short v1) {
+  private static int result(short v0, short v1) {
     return ((v0 & 0xff) << 24)
         | //
         (((v0 >>> 8) & 0xff) << 16)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
index 538d7d1..a840e87 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/LabelVote.java
@@ -19,7 +19,6 @@
 import com.google.auto.value.AutoValue;
 import com.google.common.base.Strings;
 import com.google.gerrit.common.data.LabelType;
-import com.google.gerrit.reviewdb.client.PatchSetApproval;
 
 /** A single vote on a label, consisting of a label name and a value. */
 @AutoValue
@@ -53,7 +52,7 @@
     checkArgument(!Strings.isNullOrEmpty(text), "Empty label vote");
     int e = text.lastIndexOf('=');
     checkArgument(e >= 0, "Label vote missing '=': %s", text);
-    return create(text.substring(0, e), Short.parseShort(text.substring(e + 1), text.length()));
+    return create(text.substring(0, e), Short.parseShort(text.substring(e + 1)));
   }
 
   public static StringBuilder appendTo(StringBuilder sb, String label, short value) {
@@ -69,10 +68,6 @@
     return new AutoValue_LabelVote(LabelType.checkNameInternal(label), value);
   }
 
-  public static LabelVote create(PatchSetApproval psa) {
-    return create(psa.getLabel(), psa.getValue());
-  }
-
   public abstract String label();
 
   public abstract short value();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java
index 75e14cb..e757d77 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/MagicBranch.java
@@ -27,7 +27,9 @@
   private static final Logger log = LoggerFactory.getLogger(MagicBranch.class);
 
   public static final String NEW_CHANGE = "refs/for/";
+  // TODO(xchangcheng): remove after 'repo' supports private/wip changes.
   public static final String NEW_DRAFT_CHANGE = "refs/drafts/";
+  // TODO(xchangcheng): remove after migrating tools which are using this magic branch.
   public static final String NEW_PUBLISH_CHANGE = "refs/publish/";
 
   /** Extracts the destination from a ref name */
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java
index 4019851..f243726 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/MostSpecificComparator.java
@@ -52,7 +52,7 @@
     return compare(a.getName(), b.getName());
   }
 
-  public int compare(final String pattern1, final String pattern2) {
+  public int compare(String pattern1, String pattern2) {
     int cmp = distance(pattern1) - distance(pattern2);
     if (cmp == 0) {
       boolean p1_finite = finite(pattern1);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RangeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RangeUtil.java
deleted file mode 100644
index f7f2cff..0000000
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RangeUtil.java
+++ /dev/null
@@ -1,118 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.util;
-
-import com.google.common.primitives.Ints;
-import com.google.gerrit.common.Nullable;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-public final class RangeUtil {
-  private static final Pattern RANGE_PATTERN = Pattern.compile("(>|>=|=|<|<=|)([+-]?\\d+)$");
-
-  private RangeUtil() {}
-
-  public static class Range {
-    /** The prefix of the query, before the range component. */
-    public final String prefix;
-
-    /** The minimum value specified in the query, inclusive. */
-    public final int min;
-
-    /** The maximum value specified in the query, inclusive. */
-    public final int max;
-
-    public Range(String prefix, int min, int max) {
-      this.prefix = prefix;
-      this.min = min;
-      this.max = max;
-    }
-  }
-
-  /**
-   * Determine the range of values being requested in the given query.
-   *
-   * @param rangeQuery the raw query, e.g. "{@code added:>12345}"
-   * @param minValue the minimum possible value for the field, inclusive
-   * @param maxValue the maximum possible value for the field, inclusive
-   * @return the calculated {@link Range}, or null if the query is invalid
-   */
-  @Nullable
-  public static Range getRange(String rangeQuery, int minValue, int maxValue) {
-    Matcher m = RANGE_PATTERN.matcher(rangeQuery);
-    String prefix;
-    String test;
-    Integer queryInt;
-    if (m.find()) {
-      prefix = rangeQuery.substring(0, m.start());
-      test = m.group(1);
-      queryInt = value(m.group(2));
-      if (queryInt == null) {
-        return null;
-      }
-    } else {
-      return null;
-    }
-
-    return getRange(prefix, test, queryInt, minValue, maxValue);
-  }
-
-  /**
-   * Determine the range of values being requested in the given query.
-   *
-   * @param prefix a prefix string which is copied into the range
-   * @param test the test operator, one of &gt;, &gt;=, =, &lt;, or &lt;=
-   * @param queryInt the integer being queried
-   * @param minValue the minimum possible value for the field, inclusive
-   * @param maxValue the maximum possible value for the field, inclusive
-   * @return the calculated {@link Range}
-   */
-  public static Range getRange(
-      String prefix, String test, int queryInt, int minValue, int maxValue) {
-    int min;
-    int max;
-    switch (test) {
-      case "=":
-      default:
-        min = max = queryInt;
-        break;
-      case ">":
-        min = Ints.saturatedCast(queryInt + 1L);
-        max = maxValue;
-        break;
-      case ">=":
-        min = queryInt;
-        max = maxValue;
-        break;
-      case "<":
-        min = minValue;
-        max = Ints.saturatedCast(queryInt - 1L);
-        break;
-      case "<=":
-        min = minValue;
-        max = queryInt;
-        break;
-    }
-
-    return new Range(prefix, min, max);
-  }
-
-  private static Integer value(String value) {
-    if (value.startsWith("+")) {
-      value = value.substring(1);
-    }
-    return Ints.tryParse(value);
-  }
-}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java
index 72c693f..8e8db12 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestId.java
@@ -46,9 +46,8 @@
 
   private final String str;
 
-  @SuppressWarnings("deprecation") // Use Hashing.sha1 for compatibility.
   private RequestId(String resourceId) {
-    Hasher h = Hashing.sha1().newHasher();
+    Hasher h = Hashing.murmur3_128().newHasher();
     h.putLong(Thread.currentThread().getId()).putUnencodedChars(MACHINE_ID);
     str =
         "["
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
index 4d66809e..9c83549 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/RequestScopePropagator.java
@@ -29,6 +29,7 @@
 import com.google.inject.servlet.ServletScopes;
 import java.util.concurrent.Callable;
 import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 
 /**
  * Base class for propagating request-scoped data between threads.
@@ -64,10 +65,9 @@
    * request state when the returned Callable is invoked. The method must be called in a request
    * scope and the returned Callable may only be invoked in a thread that is not already in a
    * request scope or is in the same request scope. The returned Callable will inherit toString()
-   * from the passed in Callable. A {@link com.google.gerrit.server.git.WorkQueue.Executor} does not
-   * accept a Callable, so there is no ProjectCallable implementation. Implementations of this
-   * method must be consistent with Guice's {@link ServletScopes#continueRequest(Callable,
-   * java.util.Map)}.
+   * from the passed in Callable. A {@link ScheduledThreadPoolExecutor} does not accept a Callable,
+   * so there is no ProjectCallable implementation. Implementations of this method must be
+   * consistent with Guice's {@link ServletScopes#continueRequest(Callable, java.util.Map)}.
    *
    * <p>There are some limitations:
    *
@@ -82,7 +82,7 @@
    * @return a new Callable which will execute in the current request scope.
    */
   @SuppressWarnings("javadoc") // See GuiceRequestScopePropagator#wrapImpl
-  public final <T> Callable<T> wrap(final Callable<T> callable) {
+  public final <T> Callable<T> wrap(Callable<T> callable) {
     final RequestContext callerContext = checkNotNull(local.getContext());
     final Callable<T> wrapped = wrapImpl(context(callerContext, cleanup(callable)));
     return new Callable<T>() {
@@ -114,7 +114,7 @@
    * @param runnable the Runnable to wrap.
    * @return a new Runnable which will execute in the current request scope.
    */
-  public final Runnable wrap(final Runnable runnable) {
+  public final Runnable wrap(Runnable runnable) {
     final Callable<Object> wrapped = wrap(Executors.callable(runnable));
 
     if (runnable instanceof ProjectRunnable) {
@@ -172,52 +172,46 @@
   /** @see #wrap(Callable) */
   protected abstract <T> Callable<T> wrapImpl(Callable<T> callable);
 
-  protected <T> Callable<T> context(final RequestContext context, final Callable<T> callable) {
-    return new Callable<T>() {
-      @Override
-      public T call() throws Exception {
-        RequestContext old =
-            local.setContext(
-                new RequestContext() {
-                  @Override
-                  public CurrentUser getUser() {
-                    return context.getUser();
-                  }
+  protected <T> Callable<T> context(RequestContext context, Callable<T> callable) {
+    return () -> {
+      RequestContext old =
+          local.setContext(
+              new RequestContext() {
+                @Override
+                public CurrentUser getUser() {
+                  return context.getUser();
+                }
 
-                  @Override
-                  public Provider<ReviewDb> getReviewDbProvider() {
-                    return dbProviderProvider.get();
-                  }
-                });
-        try {
-          return callable.call();
-        } finally {
-          local.setContext(old);
-        }
+                @Override
+                public Provider<ReviewDb> getReviewDbProvider() {
+                  return dbProviderProvider.get();
+                }
+              });
+      try {
+        return callable.call();
+      } finally {
+        local.setContext(old);
       }
     };
   }
 
-  protected <T> Callable<T> cleanup(final Callable<T> callable) {
-    return new Callable<T>() {
-      @Override
-      public T call() throws Exception {
-        RequestCleanup cleanup =
-            scope
-                .scope(
-                    Key.get(RequestCleanup.class),
-                    new Provider<RequestCleanup>() {
-                      @Override
-                      public RequestCleanup get() {
-                        return new RequestCleanup();
-                      }
-                    })
-                .get();
-        try {
-          return callable.call();
-        } finally {
-          cleanup.run();
-        }
+  protected <T> Callable<T> cleanup(Callable<T> callable) {
+    return () -> {
+      RequestCleanup cleanup =
+          scope
+              .scope(
+                  Key.get(RequestCleanup.class),
+                  new Provider<RequestCleanup>() {
+                    @Override
+                    public RequestCleanup get() {
+                      return new RequestCleanup();
+                    }
+                  })
+              .get();
+      try {
+        return callable.call();
+      } finally {
+        cleanup.run();
       }
     };
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java
index 5b22f73..afa2aee 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/SocketUtil.java
@@ -22,12 +22,12 @@
 
 public final class SocketUtil {
   /** True if this InetAddress is a raw IPv6 in dotted quad notation. */
-  public static boolean isIPv6(final InetAddress ip) {
+  public static boolean isIPv6(InetAddress ip) {
     return ip instanceof Inet6Address && ip.getHostName().equals(ip.getHostAddress());
   }
 
   /** Get the name or IP address, or {@code *} if this address is a wildcard IP. */
-  public static String hostname(final InetSocketAddress addr) {
+  public static String hostname(InetSocketAddress addr) {
     if (addr.getAddress() != null) {
       if (addr.getAddress().isAnyLocalAddress()) {
         return "*";
@@ -38,7 +38,7 @@
   }
 
   /** Format an address string into {@code host:port} or {@code *:port} syntax. */
-  public static String format(final SocketAddress s, final int defaultPort) {
+  public static String format(SocketAddress s, int defaultPort) {
     if (s instanceof InetSocketAddress) {
       final InetSocketAddress addr = (InetSocketAddress) s;
       if (addr.getPort() == defaultPort) {
@@ -62,7 +62,7 @@
   }
 
   /** Parse an address string such as {@code host:port} or {@code *:port}. */
-  public static InetSocketAddress parse(final String desc, final int defaultPort) {
+  public static InetSocketAddress parse(String desc, int defaultPort) {
     String hostStr;
     String portStr;
 
@@ -109,7 +109,7 @@
   }
 
   /** Parse and resolve an address string, looking up the IP address. */
-  public static InetSocketAddress resolve(final String desc, final int defaultPort) {
+  public static InetSocketAddress resolve(String desc, int defaultPort) {
     final InetSocketAddress addr = parse(desc, defaultPort);
     if (addr.getAddress() != null && addr.getAddress().isAnyLocalAddress()) {
       return addr;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
index 61c863b..6de2fef 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/SubmoduleSectionParser.java
@@ -56,7 +56,7 @@
 
   public Set<SubmoduleSubscription> parseAllSections() {
     Set<SubmoduleSubscription> parsedSubscriptions = new HashSet<>();
-    for (final String id : bbc.getSubsections("submodule")) {
+    for (String id : bbc.getSubsections("submodule")) {
       final SubmoduleSubscription subscription = parse(id);
       if (subscription != null) {
         parsedSubscriptions.add(subscription);
@@ -65,7 +65,7 @@
     return parsedSubscriptions;
   }
 
-  private SubmoduleSubscription parse(final String id) {
+  private SubmoduleSubscription parse(String id) {
     final String url = bbc.getString("submodule", id, "url");
     final String path = bbc.getString("submodule", id, "path");
     String branch = bbc.getString("submodule", id, "branch");
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java
index 4efe8f2..43a0a26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/SystemLog.java
@@ -46,7 +46,7 @@
   private final Config config;
 
   @Inject
-  public SystemLog(final SitePaths site, @GerritServerConfig Config config) {
+  public SystemLog(SitePaths site, @GerritServerConfig Config config) {
     this.site = site;
     this.config = config;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
index 4b27208..90fb994 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/ThreadLocalRequestScopePropagator.java
@@ -41,21 +41,18 @@
 
   /** @see RequestScopePropagator#wrap(Callable) */
   @Override
-  protected final <T> Callable<T> wrapImpl(final Callable<T> callable) {
-    final C ctx = continuingContext(requireContext());
-    return new Callable<T>() {
-      @Override
-      public T call() throws Exception {
-        C old = threadLocal.get();
-        threadLocal.set(ctx);
-        try {
-          return callable.call();
-        } finally {
-          if (old != null) {
-            threadLocal.set(old);
-          } else {
-            threadLocal.remove();
-          }
+  protected final <T> Callable<T> wrapImpl(Callable<T> callable) {
+    C ctx = continuingContext(requireContext());
+    return () -> {
+      C old = threadLocal.get();
+      threadLocal.set(ctx);
+      try {
+        return callable.call();
+      } finally {
+        if (old != null) {
+          threadLocal.set(old);
+        } else {
+          threadLocal.remove();
         }
       }
     };
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java b/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
index 8d511f3..49d4a55 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/util/TreeFormatter.java
@@ -36,11 +36,11 @@
   private final PrintWriter stdout;
   private String currentTabSeparator = " ";
 
-  public TreeFormatter(final PrintWriter stdout) {
+  public TreeFormatter(PrintWriter stdout) {
     this.stdout = stdout;
   }
 
-  public void printTree(final SortedSet<? extends TreeNode> rootNodes) {
+  public void printTree(SortedSet<? extends TreeNode> rootNodes) {
     if (rootNodes.isEmpty()) {
       return;
     }
@@ -50,7 +50,7 @@
       currentTabSeparator = DEFAULT_TAB_SEPARATOR;
       int i = 0;
       final int size = rootNodes.size();
-      for (final TreeNode rootNode : rootNodes) {
+      for (TreeNode rootNode : rootNodes) {
         final boolean isLastRoot = ++i == size;
         if (isLastRoot) {
           currentTabSeparator = " ";
@@ -60,28 +60,28 @@
     }
   }
 
-  public void printTree(final TreeNode rootNode) {
+  public void printTree(TreeNode rootNode) {
     printTree(rootNode, 0, true);
   }
 
-  private void printTree(final TreeNode node, final int level, final boolean isLast) {
+  private void printTree(TreeNode node, int level, boolean isLast) {
     printNode(node, level, isLast);
     final SortedSet<? extends TreeNode> childNodes = node.getChildren();
     int i = 0;
     final int size = childNodes.size();
-    for (final TreeNode childNode : childNodes) {
+    for (TreeNode childNode : childNodes) {
       final boolean isLastChild = ++i == size;
       printTree(childNode, level + 1, isLastChild);
     }
   }
 
-  private void printIndention(final int level) {
+  private void printIndention(int level) {
     if (level > 0) {
       stdout.print(String.format("%-" + 4 * level + "s", currentTabSeparator));
     }
   }
 
-  private void printNode(final TreeNode node, final int level, final boolean isLast) {
+  private void printNode(TreeNode node, int level, boolean isLast) {
     printIndention(level);
     stdout.print(isLast ? LAST_NODE_PREFIX : NODE_PREFIX);
     if (node.isVisible()) {
diff --git a/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java b/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.java
new file mode 100644
index 0000000..e84b3ac
--- /dev/null
+++ b/gerrit-server/src/main/java/gerrit/PRED__check_user_label_3.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 gerrit;
+
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.rules.StoredValues;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
+import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.exceptions.SystemException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.SymbolTerm;
+import com.googlecode.prolog_cafe.lang.Term;
+import com.googlecode.prolog_cafe.lang.VariableTerm;
+
+/**
+ * Checks user can set label to val.
+ *
+ * <pre>
+ *   '_check_user_label'(+Label, +CurrentUser, +Val)
+ * </pre>
+ */
+class PRED__check_user_label_3 extends Predicate.P3 {
+  PRED__check_user_label_3(Term a1, Term a2, Term a3, Operation n) {
+    arg1 = a1;
+    arg2 = a2;
+    arg3 = a3;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+    Term a2 = arg2.dereference();
+    Term a3 = arg3.dereference();
+
+    if (a1 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 1);
+    }
+    if (!(a1 instanceof SymbolTerm)) {
+      throw new IllegalTypeException(this, 1, "atom", a1);
+    }
+    String label = a1.name();
+
+    if (a2 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 2);
+    }
+    if (!(a2 instanceof JavaObjectTerm) || !a2.convertible(CurrentUser.class)) {
+      throw new IllegalTypeException(this, 2, "CurrentUser)", a2);
+    }
+    CurrentUser user = (CurrentUser) ((JavaObjectTerm) a2).object();
+
+    if (a3 instanceof VariableTerm) {
+      throw new PInstantiationException(this, 3);
+    }
+    if (!(a3 instanceof IntegerTerm)) {
+      throw new IllegalTypeException(this, 3, "integer", a3);
+    }
+    short val = (short) ((IntegerTerm) a3).intValue();
+
+    try {
+      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
+      LabelType type = cd.getLabelTypes().byLabel(label);
+      if (type == null) {
+        return engine.fail();
+      }
+      StoredValues.PERMISSION_BACKEND
+          .get(engine)
+          .user(user)
+          .change(cd)
+          .check(new LabelPermission.WithValue(type, val));
+      return cont;
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    } catch (AuthException err) {
+      return engine.fail();
+    } catch (PermissionBackendException err) {
+      SystemException se = new SystemException(err.getMessage());
+      se.initCause(err);
+      throw se;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java b/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
index 8b5a33d..5a3d656 100644
--- a/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED__load_commit_labels_1.java
@@ -38,7 +38,7 @@
     Term listHead = Prolog.Nil;
     try {
       ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
-      LabelTypes types = StoredValues.CHANGE_CONTROL.get(engine).getLabelTypes();
+      LabelTypes types = cd.getLabelTypes();
 
       for (PatchSetApproval a : cd.currentApprovals()) {
         LabelType t = types.byLabel(a.getLabelId());
diff --git a/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java b/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
index d06664e..f7f39da 100644
--- a/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
+++ b/gerrit-server/src/main/java/gerrit/PRED__user_label_range_4.java
@@ -14,14 +14,18 @@
 
 package gerrit;
 
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.common.data.PermissionRange;
+import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.rules.StoredValues;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.permissions.LabelPermission;
+import com.google.gerrit.server.permissions.PermissionBackendException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gwtorm.server.OrmException;
 import com.googlecode.prolog_cafe.exceptions.IllegalTypeException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PInstantiationException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.exceptions.SystemException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.JavaObjectTerm;
 import com.googlecode.prolog_cafe.lang.Operation;
@@ -30,12 +34,13 @@
 import com.googlecode.prolog_cafe.lang.SymbolTerm;
 import com.googlecode.prolog_cafe.lang.Term;
 import com.googlecode.prolog_cafe.lang.VariableTerm;
+import java.util.Set;
 
 /**
  * Resolves the valid range for a label on a CurrentUser.
  *
  * <pre>
- *   '$user_label_range'(+Label, +CurrentUser, -Min, -Max)
+ *   '_user_label_range'(+Label, +CurrentUser, -Min, -Max)
  * </pre>
  */
 class PRED__user_label_range_4 extends Predicate.P4 {
@@ -71,20 +76,34 @@
     }
     CurrentUser user = (CurrentUser) ((JavaObjectTerm) a2).object();
 
-    ChangeControl ctl = StoredValues.CHANGE_CONTROL.get(engine).forUser(user);
-    PermissionRange range = ctl.getRange(Permission.LABEL + label);
-    if (range == null) {
+    Set<LabelPermission.WithValue> can;
+    try {
+      ChangeData cd = StoredValues.CHANGE_DATA.get(engine);
+      LabelType type = cd.getLabelTypes().byLabel(label);
+      if (type == null) {
+        return engine.fail();
+      }
+      can = StoredValues.PERMISSION_BACKEND.get(engine).user(user).change(cd).test(type);
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    } catch (PermissionBackendException err) {
+      SystemException se = new SystemException(err.getMessage());
+      se.initCause(err);
+      throw se;
+    }
+
+    int min = 0;
+    int max = 0;
+    for (LabelPermission.WithValue v : can) {
+      min = Math.min(min, v.value());
+      max = Math.max(max, v.value());
+    }
+
+    if (!a3.unify(new IntegerTerm(min), engine.trail)) {
       return engine.fail();
     }
 
-    IntegerTerm min = new IntegerTerm(range.getMin());
-    IntegerTerm max = new IntegerTerm(range.getMax());
-
-    if (!a3.unify(min, engine.trail)) {
-      return engine.fail();
-    }
-
-    if (!a4.unify(max, engine.trail)) {
+    if (!a4.unify(new IntegerTerm(max), engine.trail)) {
       return engine.fail();
     }
 
diff --git a/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java b/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java
index 95be5cb..95c4aaef 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_commit_edits_2.java
@@ -141,7 +141,7 @@
     return Pattern.compile(term.name(), Pattern.MULTILINE);
   }
 
-  private Text load(final ObjectId tree, final String path, final ObjectReader reader)
+  private Text load(ObjectId tree, String path, ObjectReader reader)
       throws MissingObjectException, IncorrectObjectTypeException, CorruptObjectException,
           IOException {
     if (path == null) {
diff --git a/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java b/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
index ea3fb17..2c76999 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_get_legacy_label_types_1.java
@@ -17,6 +17,8 @@
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.rules.StoredValues;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.IntegerTerm;
 import com.googlecode.prolog_cafe.lang.ListTerm;
@@ -51,7 +53,12 @@
   public Operation exec(Prolog engine) throws PrologException {
     engine.setB0();
     Term a1 = arg1.dereference();
-    List<LabelType> list = StoredValues.CHANGE_CONTROL.get(engine).getLabelTypes().getLabelTypes();
+    List<LabelType> list;
+    try {
+      list = StoredValues.CHANGE_DATA.get(engine).getLabelTypes().getLabelTypes();
+    } catch (OrmException err) {
+      throw new JavaException(this, 1, err);
+    }
     Term head = Prolog.Nil;
     for (int idx = list.size() - 1; 0 <= idx; idx--) {
       head = new ListTerm(export(list.get(idx)), head);
@@ -71,7 +78,7 @@
     return new StructureTerm(
         symLabelType,
         SymbolTerm.intern(type.getName()),
-        SymbolTerm.intern(type.getFunctionName()),
+        SymbolTerm.intern(type.getFunction().getFunctionName()),
         min != null ? new IntegerTerm(min.getValue()) : NONE,
         max != null ? new IntegerTerm(max.getValue()) : NONE);
   }
diff --git a/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java b/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java
index cedad9e..1d96433 100644
--- a/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java
+++ b/gerrit-server/src/main/java/gerrit/PRED_project_default_submit_type_1.java
@@ -16,7 +16,7 @@
 
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.rules.StoredValues;
-import com.google.gerrit.server.project.ChangeControl;
+import com.google.gerrit.server.project.ProjectState;
 import com.googlecode.prolog_cafe.exceptions.PrologException;
 import com.googlecode.prolog_cafe.lang.Operation;
 import com.googlecode.prolog_cafe.lang.Predicate;
@@ -46,9 +46,8 @@
     engine.setB0();
     Term a1 = arg1.dereference();
 
-    ChangeControl control = StoredValues.CHANGE_CONTROL.get(engine);
-    SubmitType submitType = control.getProject().getSubmitType();
-
+    ProjectState projectState = StoredValues.PROJECT_STATE.get(engine);
+    SubmitType submitType = projectState.getProject().getSubmitType();
     if (!a1.unify(term[submitType.ordinal()], engine.trail)) {
       return engine.fail();
     }
diff --git a/gerrit-server/src/main/java/gerrit/PRED_pure_revert_1.java b/gerrit-server/src/main/java/gerrit/PRED_pure_revert_1.java
new file mode 100644
index 0000000..f3721fb
--- /dev/null
+++ b/gerrit-server/src/main/java/gerrit/PRED_pure_revert_1.java
@@ -0,0 +1,50 @@
+// 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 gerrit;
+
+import com.google.gerrit.rules.StoredValues;
+import com.google.gwtorm.server.OrmException;
+import com.googlecode.prolog_cafe.exceptions.JavaException;
+import com.googlecode.prolog_cafe.exceptions.PrologException;
+import com.googlecode.prolog_cafe.lang.IntegerTerm;
+import com.googlecode.prolog_cafe.lang.Operation;
+import com.googlecode.prolog_cafe.lang.Predicate;
+import com.googlecode.prolog_cafe.lang.Prolog;
+import com.googlecode.prolog_cafe.lang.Term;
+
+/** Checks if change is a pure revert of the change it references in 'revertOf'. */
+public class PRED_pure_revert_1 extends Predicate.P1 {
+  public PRED_pure_revert_1(Term a1, Operation n) {
+    arg1 = a1;
+    cont = n;
+  }
+
+  @Override
+  public Operation exec(Prolog engine) throws PrologException {
+    engine.setB0();
+    Term a1 = arg1.dereference();
+
+    Boolean isPureRevert;
+    try {
+      isPureRevert = StoredValues.CHANGE_DATA.get(engine).isPureRevert();
+    } catch (OrmException e) {
+      throw new JavaException(this, 1, e);
+    }
+    if (!a1.unify(new IntegerTerm(Boolean.TRUE.equals(isPureRevert) ? 1 : 0), engine.trail)) {
+      return engine.fail();
+    }
+    return cont;
+  }
+}
diff --git a/gerrit-server/src/main/prolog/BUILD b/gerrit-server/src/main/prolog/BUILD
deleted file mode 100644
index 603a0bf..0000000
--- a/gerrit-server/src/main/prolog/BUILD
+++ /dev/null
@@ -1,8 +0,0 @@
-load("//lib/prolog:prolog.bzl", "prolog_cafe_library")
-
-prolog_cafe_library(
-    name = "common",
-    srcs = ["gerrit_common.pl"],
-    visibility = ["//visibility:public"],
-    deps = ["//gerrit-server:server"],
-)
diff --git a/gerrit-server/src/main/prolog/gerrit_common.pl b/gerrit-server/src/main/prolog/gerrit_common.pl
index 59c926f..8fd0657 100644
--- a/gerrit-server/src/main/prolog/gerrit_common.pl
+++ b/gerrit-server/src/main/prolog/gerrit_common.pl
@@ -92,6 +92,27 @@
 
 %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
 %%
+%% check_user_label/3:
+%%
+%%   Check Who can set Label to Val.
+%%
+check_user_label(Label, Who, Val) :-
+  hash_get(commit_labels, '$fast_range', true), !,
+  atom(Label),
+  assume_range_from_label(Label, Who, Min, Max),
+  Min @=< Val, Val @=< Max.
+check_user_label(Label, Who, Val) :-
+  Who = user(_), !,
+  atom(Label),
+  current_user(Who, User),
+  '_check_user_label'(Label, User, Val).
+check_user_label(Label, test_user(Name), Val) :-
+  clause(user:test_grant(Label, test_user(Name), range(Min, Max)), _),
+  Min @=< Val, Val @=< Max
+  .
+
+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
+%%
 %% user_label_range/4:
 %%
 %%   Lookup the range allowed to be used.
@@ -258,12 +279,12 @@
   !,
   max_with_block(Label, Min, Max, S).
 max_with_block(Label, Min, Max, reject(Who)) :-
-  check_label_range_permission(Label, Min, ok(Who)),
+  commit_label(label(Label, Min), Who),
   !
   .
 max_with_block(Label, Min, Max, ok(Who)) :-
-  \+ check_label_range_permission(Label, Min, ok(_)),
-  check_label_range_permission(Label, Max, ok(Who)),
+  \+ commit_label(label(Label, Min), _),
+  commit_label(label(Label, Max), Who),
   !
   .
 max_with_block(Label, Min, Max, need(Max)) :-
@@ -285,7 +306,7 @@
 %%
 any_with_block(Label, Min, reject(Who)) :-
   Min < 0,
-  check_label_range_permission(Label, Min, ok(Who)),
+  commit_label(label(Label, Min), Who),
   !
   .
 any_with_block(Label, Min, may(_)).
@@ -300,7 +321,7 @@
   !,
   max_no_block(Label, Max, S).
 max_no_block(Label, Max, ok(Who)) :-
-  check_label_range_permission(Label, Max, ok(Who)),
+  commit_label(label(Label, Max), Who),
   !
   .
 max_no_block(Label, Max, need(Max)) :-
@@ -319,8 +340,7 @@
 %%
 check_label_range_permission(Label, ExpValue, ok(Who)) :-
   commit_label(label(Label, ExpValue), Who),
-  user_label_range(Label, Who, Min, Max),
-  Min @=< ExpValue, ExpValue @=< Max
+  check_user_label(Label, Who, ExpValue)
   .
 %TODO Uncomment this clause when group suggesting is possible.
 %check_label_range_permission(Label, ExpValue, ask(Group)) :-
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
index f34c992..b2bcde3 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/change/ChangeMessages.properties
@@ -1,9 +1,13 @@
 # Changes to this file should also be made in
 # gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeMessages.properties
 revertChangeDefaultMessage = Revert \"{0}\"\n\nThis reverts commit {1}.
+
+reviewerCantSeeChange = {0} does not have permission to see this change
+reviewerInactive = {0} identifies an inactive account
+reviewerInvalid = {0} is not a valid user identifier
 reviewerNotFoundUser = {0} does not identify a registered user
 reviewerNotFoundUserOrGroup = {0} does not identify a registered user or group
 
-groupIsNotAllowed =  The group {0} cannot be added as reviewer.
+groupIsNotAllowed = The group {0} cannot be added as reviewer.
 groupHasTooManyMembers = The group {0} has too many members to add them all as reviewers.
 groupManyMembersConfirmation = The group {0} has {1} members. Do you want to add them all as reviewers?
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/documentation/pegdown.css b/gerrit-server/src/main/resources/com/google/gerrit/server/documentation/pegdown.css
index eada653..498db01 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/documentation/pegdown.css
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/documentation/pegdown.css
@@ -1,39 +1,415 @@
-body {
-  margin: 1em;
-}
-
-h1, h2, h3, h4, h5, h6 {
-  color: #527bbd;
-  font-family: sans-serif;
-}
-
-h1, h2, h3 {
-  border-bottom: 2px solid silver;
-}
-
-pre {
-  border: 2px solid silver;
-  background: #ebebeb;
-  margin-left: 2em;
-  width: 100em;
-  color: darkgreen;
-  padding: 2px;
-}
-
-dl dt {
-  margin-top: 1em;
-}
-
-table.plugin_info {
-  border-collapse: separate;
-  border-spacing: 0;
-  text-align: left;
-  margin-left: 2em;
-}
-table.plugin_info th {
-  padding-right: 0.5em;
-  border-right: 2px solid silver;
-}
-table.plugin_info td {
-  padding-left: 0.5em;
-}
+/* Asciidoctor default stylesheet | MIT License | http://asciidoctor.org */
+/* Remove comment around @import statement below when using as a custom stylesheet */
+/*@import "https://fonts.googleapis.com/css?family=Open+Sans:300,300italic,400,400italic,600,600italic%7CNoto+Serif:400,400italic,700,700italic%7CDroid+Sans+Mono:400,700";*/
+article,aside,details,figcaption,figure,footer,header,hgroup,main,nav,section,summary{display:block}
+audio,canvas,video{display:inline-block}
+audio:not([controls]){display:none;height:0}
+[hidden],template{display:none}
+script{display:none!important}
+html{font-family:sans-serif;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}
+a{background:transparent}
+a:focus{outline:thin dotted}
+a:active,a:hover{outline:0}
+h1{font-size:2em;margin:.67em 0}
+abbr[title]{border-bottom:1px dotted}
+b,strong{font-weight:bold}
+dfn{font-style:italic}
+hr{-moz-box-sizing:content-box;box-sizing:content-box;height:0}
+mark{background:#ff0;color:#000}
+code,kbd,pre,samp{font-family:monospace;font-size:1em}
+pre{white-space:pre-wrap}
+q{quotes:"\201C" "\201D" "\2018" "\2019"}
+small{font-size:80%}
+sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
+sup{top:-.5em}
+sub{bottom:-.25em}
+img{border:0}
+svg:not(:root){overflow:hidden}
+figure{margin:0}
+fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}
+legend{border:0;padding:0}
+button,input,select,textarea{font-family:inherit;font-size:100%;margin:0}
+button,input{line-height:normal}
+button,select{text-transform:none}
+button,html input[type="button"],input[type="reset"],input[type="submit"]{-webkit-appearance:button;cursor:pointer}
+button[disabled],html input[disabled]{cursor:default}
+input[type="checkbox"],input[type="radio"]{box-sizing:border-box;padding:0}
+input[type="search"]{-webkit-appearance:textfield;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;box-sizing:content-box}
+input[type="search"]::-webkit-search-cancel-button,input[type="search"]::-webkit-search-decoration{-webkit-appearance:none}
+button::-moz-focus-inner,input::-moz-focus-inner{border:0;padding:0}
+textarea{overflow:auto;vertical-align:top}
+table{border-collapse:collapse;border-spacing:0}
+*,*:before,*:after{-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box}
+html,body{font-size:100%}
+body{background:#fff;color:rgba(0,0,0,.8);padding:0;margin:1em;font-family:"Noto Serif","DejaVu Serif",serif;font-weight:400;font-style:normal;line-height:1;position:relative;cursor:auto;tab-size:4;-moz-osx-font-smoothing:grayscale;-webkit-font-smoothing:antialiased}
+a:hover{cursor:pointer}
+img,object,embed{max-width:100%;height:auto}
+object,embed{height:100%}
+img{-ms-interpolation-mode:bicubic}
+.left{float:left!important}
+.right{float:right!important}
+.text-left{text-align:left!important}
+.text-right{text-align:right!important}
+.text-center{text-align:center!important}
+.text-justify{text-align:justify!important}
+.hide{display:none}
+img,object,svg{display:inline-block;vertical-align:middle}
+textarea{height:auto;min-height:50px}
+select{width:100%}
+.center{margin-left:auto;margin-right:auto}
+.spread{width:100%}
+p.lead,.paragraph.lead>p,#preamble>.sectionbody>.paragraph:first-of-type p{font-size:1.21875em;line-height:1.6}
+.subheader,.admonitionblock td.content>.title,.audioblock>.title,.exampleblock>.title,.imageblock>.title,.listingblock>.title,.literalblock>.title,.stemblock>.title,.openblock>.title,.paragraph>.title,.quoteblock>.title,table.tableblock>.title,.verseblock>.title,.videoblock>.title,.dlist>.title,.olist>.title,.ulist>.title,.qlist>.title,.hdlist>.title{line-height:1.45;color:#7a2518;font-weight:400;margin-top:0;margin-bottom:.25em}
+div,dl,dt,dd,ul,ol,li,h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6,pre,form,p,blockquote,th,td{margin:0;padding:0;direction:ltr}
+a{color:#2156a5;text-decoration:underline;line-height:inherit}
+a:hover,a:focus{color:#1d4b8f}
+a img{border:none}
+p{font-family:inherit;font-weight:400;font-size:1em;line-height:1.6;margin-bottom:1.25em;text-rendering:optimizeLegibility}
+p aside{font-size:.875em;line-height:1.35;font-style:italic}
+h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6{font-family:"Open Sans","DejaVu Sans",sans-serif;font-weight:300;font-style:normal;color:#ba3925;text-rendering:optimizeLegibility;margin-top:1em;margin-bottom:.5em;line-height:1.0125em}
+h1 small,h2 small,h3 small,#toctitle small,.sidebarblock>.content>.title small,h4 small,h5 small,h6 small{font-size:60%;color:#e99b8f;line-height:0}
+h1{font-size:2.125em}
+h2{font-size:1.6875em}
+h3,#toctitle,.sidebarblock>.content>.title{font-size:1.375em}
+h4,h5{font-size:1.125em}
+h6{font-size:1em}
+hr{border:solid #ddddd8;border-width:1px 0 0;clear:both;margin:1.25em 0 1.1875em;height:0}
+em,i{font-style:italic;line-height:inherit}
+strong,b{font-weight:bold;line-height:inherit}
+small{font-size:60%;line-height:inherit}
+code{font-family:"Droid Sans Mono","DejaVu Sans Mono",monospace;font-weight:400;color:rgba(0,0,0,.9)}
+ul,ol,dl{font-size:1em;line-height:1.6;margin-bottom:1.25em;list-style-position:outside;font-family:inherit}
+ul,ol{margin-left:1.5em}
+ul li ul,ul li ol{margin-left:1.25em;margin-bottom:0;font-size:1em}
+ul.square li ul,ul.circle li ul,ul.disc li ul{list-style:inherit}
+ul.square{list-style-type:square}
+ul.circle{list-style-type:circle}
+ul.disc{list-style-type:disc}
+ol li ul,ol li ol{margin-left:1.25em;margin-bottom:0}
+dl dt{margin-bottom:.3125em;font-weight:bold}
+dl dd{margin-bottom:1.25em}
+abbr,acronym{text-transform:uppercase;font-size:90%;color:rgba(0,0,0,.8);border-bottom:1px dotted #ddd;cursor:help}
+abbr{text-transform:none}
+blockquote{margin:0 0 1.25em;padding:.5625em 1.25em 0 1.1875em;border-left:1px solid #ddd}
+blockquote cite{display:block;font-size:.9375em;color:rgba(0,0,0,.6)}
+blockquote cite:before{content:"\2014 \0020"}
+blockquote cite a,blockquote cite a:visited{color:rgba(0,0,0,.6)}
+blockquote,blockquote p{line-height:1.6;color:rgba(0,0,0,.85)}
+@media only screen and (min-width:768px){h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6{line-height:1.2}
+h1{font-size:2.75em}
+h2{font-size:2.3125em}
+h3,#toctitle,.sidebarblock>.content>.title{font-size:1.6875em}
+h4{font-size:1.4375em}}
+table{background:#fff;margin-bottom:1.25em;border:solid 1px #dedede}
+table thead,table tfoot{background:#f7f8f7;font-weight:bold}
+table thead tr th,table thead tr td,table tfoot tr th,table tfoot tr td{padding:.5em .625em .625em;font-size:inherit;color:rgba(0,0,0,.8);text-align:left}
+table tr th,table tr td{padding:.5625em .625em;font-size:inherit;color:rgba(0,0,0,.8)}
+table tr.even,table tr.alt,table tr:nth-of-type(even){background:#f8f8f7}
+table thead tr th,table tfoot tr th,table tbody tr td,table tr td,table tfoot tr td{display:table-cell;line-height:1.6}
+h1,h2,h3,#toctitle,.sidebarblock>.content>.title,h4,h5,h6{line-height:1.2;word-spacing:-.05em}
+h1 strong,h2 strong,h3 strong,#toctitle strong,.sidebarblock>.content>.title strong,h4 strong,h5 strong,h6 strong{font-weight:400}
+.clearfix:before,.clearfix:after,.float-group:before,.float-group:after{content:" ";display:table}
+.clearfix:after,.float-group:after{clear:both}
+*:not(pre)>code{font-size:.9375em;font-style:normal!important;letter-spacing:0;padding:.1em .5ex;word-spacing:-.15em;background-color:#f7f7f8;-webkit-border-radius:4px;border-radius:4px;line-height:1.45;text-rendering:optimizeSpeed;word-wrap:break-word}
+*:not(pre)>code.nobreak{word-wrap:normal}
+*:not(pre)>code.nowrap{white-space:nowrap}
+pre,pre>code{line-height:1.45;color:rgba(0,0,0,.9);font-family:"Droid Sans Mono","DejaVu Sans Mono",monospace;font-weight:400;text-rendering:optimizeSpeed}
+em em{font-style:normal}
+strong strong{font-weight:400}
+.keyseq{color:rgba(51,51,51,.8)}
+kbd{font-family:"Droid Sans Mono","DejaVu Sans Mono",monospace;display:inline-block;color:rgba(0,0,0,.8);font-size:.65em;line-height:1.45;background-color:#f7f7f7;border:1px solid #ccc;-webkit-border-radius:3px;border-radius:3px;-webkit-box-shadow:0 1px 0 rgba(0,0,0,.2),0 0 0 .1em white inset;box-shadow:0 1px 0 rgba(0,0,0,.2),0 0 0 .1em #fff inset;margin:0 .15em;padding:.2em .5em;vertical-align:middle;position:relative;top:-.1em;white-space:nowrap}
+.keyseq kbd:first-child{margin-left:0}
+.keyseq kbd:last-child{margin-right:0}
+.menuseq,.menuref{color:#000}
+.menuseq b:not(.caret),.menuref{font-weight:inherit}
+.menuseq{word-spacing:-.02em}
+.menuseq b.caret{font-size:1.25em;line-height:.8}
+.menuseq i.caret{font-weight:bold;text-align:center;width:.45em}
+b.button:before,b.button:after{position:relative;top:-1px;font-weight:400}
+b.button:before{content:"[";padding:0 3px 0 2px}
+b.button:after{content:"]";padding:0 2px 0 3px}
+p a>code:hover{color:rgba(0,0,0,.9)}
+#header,#content,#footnotes,#footer{width:100%;margin-left:auto;margin-right:auto;margin-top:0;margin-bottom:0;max-width:62.5em;*zoom:1;position:relative;padding-left:.9375em;padding-right:.9375em}
+#header:before,#header:after,#content:before,#content:after,#footnotes:before,#footnotes:after,#footer:before,#footer:after{content:" ";display:table}
+#header:after,#content:after,#footnotes:after,#footer:after{clear:both}
+#content{margin-top:1.25em}
+#content:before{content:none}
+#header>h1:first-child{color:rgba(0,0,0,.85);margin-top:2.25rem;margin-bottom:0}
+#header>h1:first-child+#toc{margin-top:8px;border-top:1px solid #ddddd8}
+#header>h1:only-child,body.toc2 #header>h1:nth-last-child(2){border-bottom:1px solid #ddddd8;padding-bottom:8px}
+#header .details{border-bottom:1px solid #ddddd8;line-height:1.45;padding-top:.25em;padding-bottom:.25em;padding-left:.25em;color:rgba(0,0,0,.6);display:-ms-flexbox;display:-webkit-flex;display:flex;-ms-flex-flow:row wrap;-webkit-flex-flow:row wrap;flex-flow:row wrap}
+#header .details span:first-child{margin-left:-.125em}
+#header .details span.email a{color:rgba(0,0,0,.85)}
+#header .details br{display:none}
+#header .details br+span:before{content:"\00a0\2013\00a0"}
+#header .details br+span.author:before{content:"\00a0\22c5\00a0";color:rgba(0,0,0,.85)}
+#header .details br+span#revremark:before{content:"\00a0|\00a0"}
+#header #revnumber{text-transform:capitalize}
+#header #revnumber:after{content:"\00a0"}
+#content>h1:first-child:not([class]){color:rgba(0,0,0,.85);border-bottom:1px solid #ddddd8;padding-bottom:8px;margin-top:0;padding-top:1rem;margin-bottom:1.25rem}
+#toc{border-bottom:1px solid #efefed;padding-bottom:.5em}
+#toc>ul{margin-left:.125em}
+#toc ul.sectlevel0>li>a{font-style:italic}
+#toc ul.sectlevel0 ul.sectlevel1{margin:.5em 0}
+#toc ul{font-family:"Open Sans","DejaVu Sans",sans-serif;list-style-type:none}
+#toc li{line-height:1.3334;margin-top:.3334em}
+#toc a{text-decoration:none}
+#toc a:active{text-decoration:underline}
+#toctitle{color:#7a2518;font-size:1.2em}
+@media only screen and (min-width:768px){#toctitle{font-size:1.375em}
+body.toc2{padding-left:15em;padding-right:0}
+#toc.toc2{margin-top:0!important;background-color:#f8f8f7;position:fixed;width:15em;left:0;top:0;border-right:1px solid #efefed;border-top-width:0!important;border-bottom-width:0!important;z-index:1000;padding:1.25em 1em;height:100%;overflow:auto}
+#toc.toc2 #toctitle{margin-top:0;margin-bottom:.8rem;font-size:1.2em}
+#toc.toc2>ul{font-size:.9em;margin-bottom:0}
+#toc.toc2 ul ul{margin-left:0;padding-left:1em}
+#toc.toc2 ul.sectlevel0 ul.sectlevel1{padding-left:0;margin-top:.5em;margin-bottom:.5em}
+body.toc2.toc-right{padding-left:0;padding-right:15em}
+body.toc2.toc-right #toc.toc2{border-right-width:0;border-left:1px solid #efefed;left:auto;right:0}}
+@media only screen and (min-width:1280px){body.toc2{padding-left:20em;padding-right:0}
+#toc.toc2{width:20em}
+#toc.toc2 #toctitle{font-size:1.375em}
+#toc.toc2>ul{font-size:.95em}
+#toc.toc2 ul ul{padding-left:1.25em}
+body.toc2.toc-right{padding-left:0;padding-right:20em}}
+#content #toc{border-style:solid;border-width:1px;border-color:#e0e0dc;margin-bottom:1.25em;padding:1.25em;background:#f8f8f7;-webkit-border-radius:4px;border-radius:4px}
+#content #toc>:first-child{margin-top:0}
+#content #toc>:last-child{margin-bottom:0}
+#footer{max-width:100%;background-color:rgba(0,0,0,.8);padding:1.25em}
+#footer-text{color:rgba(255,255,255,.8);line-height:1.44}
+.sect1{padding-bottom:.625em}
+@media only screen and (min-width:768px){.sect1{padding-bottom:1.25em}}
+.sect1+.sect1{border-top:1px solid #efefed}
+#content h1>a.anchor,h2>a.anchor,h3>a.anchor,#toctitle>a.anchor,.sidebarblock>.content>.title>a.anchor,h4>a.anchor,h5>a.anchor,h6>a.anchor{position:absolute;z-index:1001;width:1.5ex;margin-left:-1.5ex;display:block;text-decoration:none!important;visibility:hidden;text-align:center;font-weight:400}
+#content h1>a.anchor:before,h2>a.anchor:before,h3>a.anchor:before,#toctitle>a.anchor:before,.sidebarblock>.content>.title>a.anchor:before,h4>a.anchor:before,h5>a.anchor:before,h6>a.anchor:before{content:"\00A7";font-size:.85em;display:block;padding-top:.1em}
+#content h1:hover>a.anchor,#content h1>a.anchor:hover,h2:hover>a.anchor,h2>a.anchor:hover,h3:hover>a.anchor,#toctitle:hover>a.anchor,.sidebarblock>.content>.title:hover>a.anchor,h3>a.anchor:hover,#toctitle>a.anchor:hover,.sidebarblock>.content>.title>a.anchor:hover,h4:hover>a.anchor,h4>a.anchor:hover,h5:hover>a.anchor,h5>a.anchor:hover,h6:hover>a.anchor,h6>a.anchor:hover{visibility:visible}
+#content h1>a.link,h2>a.link,h3>a.link,#toctitle>a.link,.sidebarblock>.content>.title>a.link,h4>a.link,h5>a.link,h6>a.link{color:#ba3925;text-decoration:none}
+#content h1>a.link:hover,h2>a.link:hover,h3>a.link:hover,#toctitle>a.link:hover,.sidebarblock>.content>.title>a.link:hover,h4>a.link:hover,h5>a.link:hover,h6>a.link:hover{color:#a53221}
+.audioblock,.imageblock,.literalblock,.listingblock,.stemblock,.videoblock{margin-bottom:1.25em}
+.admonitionblock td.content>.title,.audioblock>.title,.exampleblock>.title,.imageblock>.title,.listingblock>.title,.literalblock>.title,.stemblock>.title,.openblock>.title,.paragraph>.title,.quoteblock>.title,table.tableblock>.title,.verseblock>.title,.videoblock>.title,.dlist>.title,.olist>.title,.ulist>.title,.qlist>.title,.hdlist>.title{text-rendering:optimizeLegibility;text-align:left;font-family:"Noto Serif","DejaVu Serif",serif;font-size:1rem;font-style:italic}
+table.tableblock>caption.title{white-space:nowrap;overflow:visible;max-width:0}
+.paragraph.lead>p,#preamble>.sectionbody>.paragraph:first-of-type p{color:rgba(0,0,0,.85)}
+table.tableblock #preamble>.sectionbody>.paragraph:first-of-type p{font-size:inherit}
+.admonitionblock>table{border-collapse:separate;border:0;background:none;width:100%}
+.admonitionblock>table td.icon{text-align:center;width:80px}
+.admonitionblock>table td.icon img{max-width:initial}
+.admonitionblock>table td.icon .title{font-weight:bold;font-family:"Open Sans","DejaVu Sans",sans-serif;text-transform:uppercase}
+.admonitionblock>table td.content{padding-left:1.125em;padding-right:1.25em;border-left:1px solid #ddddd8;color:rgba(0,0,0,.6)}
+.admonitionblock>table td.content>:last-child>:last-child{margin-bottom:0}
+.exampleblock>.content{border-style:solid;border-width:1px;border-color:#e6e6e6;margin-bottom:1.25em;padding:1.25em;background:#fff;-webkit-border-radius:4px;border-radius:4px}
+.exampleblock>.content>:first-child{margin-top:0}
+.exampleblock>.content>:last-child{margin-bottom:0}
+.sidebarblock{border-style:solid;border-width:1px;border-color:#e0e0dc;margin-bottom:1.25em;padding:1.25em;background:#f8f8f7;-webkit-border-radius:4px;border-radius:4px}
+.sidebarblock>:first-child{margin-top:0}
+.sidebarblock>:last-child{margin-bottom:0}
+.sidebarblock>.content>.title{color:#7a2518;margin-top:0;text-align:center}
+.exampleblock>.content>:last-child>:last-child,.exampleblock>.content .olist>ol>li:last-child>:last-child,.exampleblock>.content .ulist>ul>li:last-child>:last-child,.exampleblock>.content .qlist>ol>li:last-child>:last-child,.sidebarblock>.content>:last-child>:last-child,.sidebarblock>.content .olist>ol>li:last-child>:last-child,.sidebarblock>.content .ulist>ul>li:last-child>:last-child,.sidebarblock>.content .qlist>ol>li:last-child>:last-child{margin-bottom:0}
+.literalblock pre,.listingblock pre:not(.highlight),.listingblock pre[class="highlight"],.listingblock pre[class^="highlight "],.listingblock pre.CodeRay,.listingblock pre.prettyprint{background:#f7f7f8}
+.sidebarblock .literalblock pre,.sidebarblock .listingblock pre:not(.highlight),.sidebarblock .listingblock pre[class="highlight"],.sidebarblock .listingblock pre[class^="highlight "],.sidebarblock .listingblock pre.CodeRay,.sidebarblock .listingblock pre.prettyprint{background:#f2f1f1}
+.literalblock pre,.literalblock pre[class],.listingblock pre,.listingblock pre[class]{-webkit-border-radius:4px;border-radius:4px;word-wrap:break-word;padding:1em;font-size:.8125em}
+.literalblock pre.nowrap,.literalblock pre[class].nowrap,.listingblock pre.nowrap,.listingblock pre[class].nowrap{overflow-x:auto;white-space:pre;word-wrap:normal}
+@media only screen and (min-width:768px){.literalblock pre,.literalblock pre[class],.listingblock pre,.listingblock pre[class]{font-size:.90625em}}
+@media only screen and (min-width:1280px){.literalblock pre,.literalblock pre[class],.listingblock pre,.listingblock pre[class]{font-size:1em}}
+.literalblock.output pre{color:#f7f7f8;background-color:rgba(0,0,0,.9)}
+.listingblock pre.highlightjs{padding:0}
+.listingblock pre.highlightjs>code{padding:1em;-webkit-border-radius:4px;border-radius:4px}
+.listingblock pre.prettyprint{border-width:0}
+.listingblock>.content{position:relative}
+.listingblock code[data-lang]:before{display:none;content:attr(data-lang);position:absolute;font-size:.75em;top:.425rem;right:.5rem;line-height:1;text-transform:uppercase;color:#999}
+.listingblock:hover code[data-lang]:before{display:block}
+.listingblock.terminal pre .command:before{content:attr(data-prompt);padding-right:.5em;color:#999}
+.listingblock.terminal pre .command:not([data-prompt]):before{content:"$"}
+table.pyhltable{border-collapse:separate;border:0;margin-bottom:0;background:none}
+table.pyhltable td{vertical-align:top;padding-top:0;padding-bottom:0;line-height:1.45}
+table.pyhltable td.code{padding-left:.75em;padding-right:0}
+pre.pygments .lineno,table.pyhltable td:not(.code){color:#999;padding-left:0;padding-right:.5em;border-right:1px solid #ddddd8}
+pre.pygments .lineno{display:inline-block;margin-right:.25em}
+table.pyhltable .linenodiv{background:none!important;padding-right:0!important}
+.quoteblock{margin:0 1em 1.25em 1.5em;display:table}
+.quoteblock>.title{margin-left:-1.5em;margin-bottom:.75em}
+.quoteblock blockquote,.quoteblock blockquote p{color:rgba(0,0,0,.85);font-size:1.15rem;line-height:1.75;word-spacing:.1em;letter-spacing:0;font-style:italic;text-align:justify}
+.quoteblock blockquote{margin:0;padding:0;border:0}
+.quoteblock blockquote:before{content:"\201c";float:left;font-size:2.75em;font-weight:bold;line-height:.6em;margin-left:-.6em;color:#7a2518;text-shadow:0 1px 2px rgba(0,0,0,.1)}
+.quoteblock blockquote>.paragraph:last-child p{margin-bottom:0}
+.quoteblock .attribution{margin-top:.5em;margin-right:.5ex;text-align:right}
+.quoteblock .quoteblock{margin-left:0;margin-right:0;padding:.5em 0;border-left:3px solid rgba(0,0,0,.6)}
+.quoteblock .quoteblock blockquote{padding:0 0 0 .75em}
+.quoteblock .quoteblock blockquote:before{display:none}
+.verseblock{margin:0 1em 1.25em 1em}
+.verseblock pre{font-family:"Open Sans","DejaVu Sans",sans;font-size:1.15rem;color:rgba(0,0,0,.85);font-weight:300;text-rendering:optimizeLegibility}
+.verseblock pre strong{font-weight:400}
+.verseblock .attribution{margin-top:1.25rem;margin-left:.5ex}
+.quoteblock .attribution,.verseblock .attribution{font-size:.9375em;line-height:1.45;font-style:italic}
+.quoteblock .attribution br,.verseblock .attribution br{display:none}
+.quoteblock .attribution cite,.verseblock .attribution cite{display:block;letter-spacing:-.025em;color:rgba(0,0,0,.6)}
+.quoteblock.abstract{margin:0 0 1.25em 0;display:block}
+.quoteblock.abstract blockquote,.quoteblock.abstract blockquote p{text-align:left;word-spacing:0}
+.quoteblock.abstract blockquote:before,.quoteblock.abstract blockquote p:first-of-type:before{display:none}
+table.tableblock{max-width:100%;border-collapse:separate}
+table.tableblock td>.paragraph:last-child p>p:last-child,table.tableblock th>p:last-child,table.tableblock td>p:last-child{margin-bottom:0}
+table.tableblock,th.tableblock,td.tableblock{border:0 solid #dedede}
+table.grid-all>thead>tr>.tableblock,table.grid-all>tbody>tr>.tableblock{border-width:0 1px 1px 0}
+table.grid-all>tfoot>tr>.tableblock{border-width:1px 1px 0 0}
+table.grid-cols>*>tr>.tableblock{border-width:0 1px 0 0}
+table.grid-rows>thead>tr>.tableblock,table.grid-rows>tbody>tr>.tableblock{border-width:0 0 1px 0}
+table.grid-rows>tfoot>tr>.tableblock{border-width:1px 0 0 0}
+table.grid-all>*>tr>.tableblock:last-child,table.grid-cols>*>tr>.tableblock:last-child{border-right-width:0}
+table.grid-all>tbody>tr:last-child>.tableblock,table.grid-all>thead:last-child>tr>.tableblock,table.grid-rows>tbody>tr:last-child>.tableblock,table.grid-rows>thead:last-child>tr>.tableblock{border-bottom-width:0}
+table.frame-all{border-width:1px}
+table.frame-sides{border-width:0 1px}
+table.frame-topbot{border-width:1px 0}
+th.halign-left,td.halign-left{text-align:left}
+th.halign-right,td.halign-right{text-align:right}
+th.halign-center,td.halign-center{text-align:center}
+th.valign-top,td.valign-top{vertical-align:top}
+th.valign-bottom,td.valign-bottom{vertical-align:bottom}
+th.valign-middle,td.valign-middle{vertical-align:middle}
+table thead th,table tfoot th{font-weight:bold}
+tbody tr th{display:table-cell;line-height:1.6;background:#f7f8f7}
+tbody tr th,tbody tr th p,tfoot tr th,tfoot tr th p{color:rgba(0,0,0,.8);font-weight:bold}
+p.tableblock>code:only-child{background:none;padding:0}
+p.tableblock{font-size:1em}
+td>div.verse{white-space:pre}
+ol{margin-left:1.75em}
+ul li ol{margin-left:1.5em}
+dl dd{margin-left:1.125em}
+dl dd:last-child,dl dd:last-child>:last-child{margin-bottom:0}
+ol>li p,ul>li p,ul dd,ol dd,.olist .olist,.ulist .ulist,.ulist .olist,.olist .ulist{margin-bottom:.625em}
+ul.checklist,ul.none,ol.none,ul.no-bullet,ol.no-bullet,ol.unnumbered,ul.unstyled,ol.unstyled{list-style-type:none}
+ul.no-bullet,ol.no-bullet,ol.unnumbered{margin-left:.625em}
+ul.unstyled,ol.unstyled{margin-left:0}
+ul.checklist{margin-left:.625em}
+ul.checklist li>p:first-child>.fa-square-o:first-child,ul.checklist li>p:first-child>.fa-check-square-o:first-child{width:1.25em;font-size:.8em;position:relative;bottom:.125em}
+ul.checklist li>p:first-child>input[type="checkbox"]:first-child{margin-right:.25em}
+ul.inline{margin:0 auto .625em auto;margin-left:-1.375em;margin-right:0;padding:0;list-style:none;overflow:hidden}
+ul.inline>li{list-style:none;float:left;margin-left:1.375em;display:block}
+ul.inline>li>*{display:block}
+.unstyled dl dt{font-weight:400;font-style:normal}
+ol.arabic{list-style-type:decimal}
+ol.decimal{list-style-type:decimal-leading-zero}
+ol.loweralpha{list-style-type:lower-alpha}
+ol.upperalpha{list-style-type:upper-alpha}
+ol.lowerroman{list-style-type:lower-roman}
+ol.upperroman{list-style-type:upper-roman}
+ol.lowergreek{list-style-type:lower-greek}
+.hdlist>table,.colist>table{border:0;background:none}
+.hdlist>table>tbody>tr,.colist>table>tbody>tr{background:none}
+td.hdlist1,td.hdlist2{vertical-align:top;padding:0 .625em}
+td.hdlist1{font-weight:bold;padding-bottom:1.25em}
+.literalblock+.colist,.listingblock+.colist{margin-top:-.5em}
+.colist>table tr>td:first-of-type{padding:.4em .75em 0 .75em;line-height:1;vertical-align:top}
+.colist>table tr>td:first-of-type img{max-width:initial}
+.colist>table tr>td:last-of-type{padding:.25em 0}
+.thumb,.th{line-height:0;display:inline-block;border:solid 4px #fff;-webkit-box-shadow:0 0 0 1px #ddd;box-shadow:0 0 0 1px #ddd}
+.imageblock.left,.imageblock[style*="float: left"]{margin:.25em .625em 1.25em 0}
+.imageblock.right,.imageblock[style*="float: right"]{margin:.25em 0 1.25em .625em}
+.imageblock>.title{margin-bottom:0}
+.imageblock.thumb,.imageblock.th{border-width:6px}
+.imageblock.thumb>.title,.imageblock.th>.title{padding:0 .125em}
+.image.left,.image.right{margin-top:.25em;margin-bottom:.25em;display:inline-block;line-height:0}
+.image.left{margin-right:.625em}
+.image.right{margin-left:.625em}
+a.image{text-decoration:none;display:inline-block}
+a.image object{pointer-events:none}
+sup.footnote,sup.footnoteref{font-size:.875em;position:static;vertical-align:super}
+sup.footnote a,sup.footnoteref a{text-decoration:none}
+sup.footnote a:active,sup.footnoteref a:active{text-decoration:underline}
+#footnotes{padding-top:.75em;padding-bottom:.75em;margin-bottom:.625em}
+#footnotes hr{width:20%;min-width:6.25em;margin:-.25em 0 .75em 0;border-width:1px 0 0 0}
+#footnotes .footnote{padding:0 .375em 0 .225em;line-height:1.3334;font-size:.875em;margin-left:1.2em;text-indent:-1.05em;margin-bottom:.2em}
+#footnotes .footnote a:first-of-type{font-weight:bold;text-decoration:none}
+#footnotes .footnote:last-of-type{margin-bottom:0}
+#content #footnotes{margin-top:-.625em;margin-bottom:0;padding:.75em 0}
+.gist .file-data>table{border:0;background:#fff;width:100%;margin-bottom:0}
+.gist .file-data>table td.line-data{width:99%}
+div.unbreakable{page-break-inside:avoid}
+.big{font-size:larger}
+.small{font-size:smaller}
+.underline{text-decoration:underline}
+.overline{text-decoration:overline}
+.line-through{text-decoration:line-through}
+.aqua{color:#00bfbf}
+.aqua-background{background-color:#00fafa}
+.black{color:#000}
+.black-background{background-color:#000}
+.blue{color:#0000bf}
+.blue-background{background-color:#0000fa}
+.fuchsia{color:#bf00bf}
+.fuchsia-background{background-color:#fa00fa}
+.gray{color:#606060}
+.gray-background{background-color:#7d7d7d}
+.green{color:#006000}
+.green-background{background-color:#007d00}
+.lime{color:#00bf00}
+.lime-background{background-color:#00fa00}
+.maroon{color:#600000}
+.maroon-background{background-color:#7d0000}
+.navy{color:#000060}
+.navy-background{background-color:#00007d}
+.olive{color:#606000}
+.olive-background{background-color:#7d7d00}
+.purple{color:#600060}
+.purple-background{background-color:#7d007d}
+.red{color:#bf0000}
+.red-background{background-color:#fa0000}
+.silver{color:#909090}
+.silver-background{background-color:#bcbcbc}
+.teal{color:#006060}
+.teal-background{background-color:#007d7d}
+.white{color:#bfbfbf}
+.white-background{background-color:#fafafa}
+.yellow{color:#bfbf00}
+.yellow-background{background-color:#fafa00}
+span.icon>.fa{cursor:default}
+a span.icon>.fa{cursor:inherit}
+.admonitionblock td.icon [class^="fa icon-"]{font-size:2.5em;text-shadow:1px 1px 2px rgba(0,0,0,.5);cursor:default}
+.admonitionblock td.icon .icon-note:before{content:"\f05a";color:#19407c}
+.admonitionblock td.icon .icon-tip:before{content:"\f0eb";text-shadow:1px 1px 2px rgba(155,155,0,.8);color:#111}
+.admonitionblock td.icon .icon-warning:before{content:"\f071";color:#bf6900}
+.admonitionblock td.icon .icon-caution:before{content:"\f06d";color:#bf3400}
+.admonitionblock td.icon .icon-important:before{content:"\f06a";color:#bf0000}
+.conum[data-value]{display:inline-block;color:#fff!important;background-color:rgba(0,0,0,.8);-webkit-border-radius:100px;border-radius:100px;text-align:center;font-size:.75em;width:1.67em;height:1.67em;line-height:1.67em;font-family:"Open Sans","DejaVu Sans",sans-serif;font-style:normal;font-weight:bold}
+.conum[data-value] *{color:#fff!important}
+.conum[data-value]+b{display:none}
+.conum[data-value]:after{content:attr(data-value)}
+pre .conum[data-value]{position:relative;top:-.125em}
+b.conum *{color:inherit!important}
+.conum:not([data-value]):empty{display:none}
+dt,th.tableblock,td.content,div.footnote{text-rendering:optimizeLegibility}
+h1,h2,p,td.content,span.alt{letter-spacing:-.01em}
+p strong,td.content strong,div.footnote strong{letter-spacing:-.005em}
+p,blockquote,dt,td.content,span.alt{font-size:1.0625rem}
+p{margin-bottom:1.25rem}
+.sidebarblock p,.sidebarblock dt,.sidebarblock td.content,p.tableblock{font-size:1em}
+.exampleblock>.content{background-color:#fffef7;border-color:#e0e0dc;-webkit-box-shadow:0 1px 4px #e0e0dc;box-shadow:0 1px 4px #e0e0dc}
+.print-only{display:none!important}
+@media print{@page{margin:1.25cm .75cm}
+*{-webkit-box-shadow:none!important;box-shadow:none!important;text-shadow:none!important}
+a{color:inherit!important;text-decoration:underline!important}
+a.bare,a[href^="#"],a[href^="mailto:"]{text-decoration:none!important}
+a[href^="http:"]:not(.bare):after,a[href^="https:"]:not(.bare):after{content:"(" attr(href) ")";display:inline-block;font-size:.875em;padding-left:.25em}
+abbr[title]:after{content:" (" attr(title) ")"}
+pre,blockquote,tr,img,object,svg{page-break-inside:avoid}
+thead{display:table-header-group}
+svg{max-width:100%}
+p,blockquote,dt,td.content{font-size:1em;orphans:3;widows:3}
+h2,h3,#toctitle,.sidebarblock>.content>.title{page-break-after:avoid}
+#toc,.sidebarblock,.exampleblock>.content{background:none!important}
+#toc{border-bottom:1px solid #ddddd8!important;padding-bottom:0!important}
+.sect1{padding-bottom:0!important}
+.sect1+.sect1{border:0!important}
+#header>h1:first-child{margin-top:1.25rem}
+body.book #header{text-align:center}
+body.book #header>h1:first-child{border:0!important;margin:2.5em 0 1em 0}
+body.book #header .details{border:0!important;display:block;padding:0!important}
+body.book #header .details span:first-child{margin-left:0!important}
+body.book #header .details br{display:block}
+body.book #header .details br+span:before{content:none!important}
+body.book #toc{border:0!important;text-align:left!important;padding:0!important;margin:0!important}
+body.book #toc,body.book #preamble,body.book h1.sect0,body.book .sect1>h2{page-break-before:always}
+.listingblock code[data-lang]:before{display:block}
+#footer{background:none!important;padding:0 .9375em}
+#footer-text{color:rgba(0,0,0,.6)!important;font-size:.9em}
+.hide-on-print{display:none!important}
+.print-only{display:block!important}
+.hide-for-print{display:none!important}
+.show-for-print{display:inherit!important}}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
index a034872..37ac126 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooter.soy
@@ -30,7 +30,8 @@
   {/if}
 
   {if $email.settingsUrl}
-    To unsubscribe, visit {$email.settingsUrl}{\n}
+    To unsubscribe, or for help writing mail filters,{sp}
+    visit {$email.settingsUrl}{\n}
   {/if}
 
   {if $email.changeUrl or $email.settingsUrl}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
index 61feb57..00f21db 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/ChangeFooterHtml.soy
@@ -29,7 +29,8 @@
       {/if}
       {if $email.changeUrl and $email.settingsUrl}{sp}{/if}
       {if $email.settingsUrl}
-        To unsubscribe, visit <a href="{$email.settingsUrl}">settings</a>.
+        To unsubscribe, or for help writing mail filters,{sp}
+        visit <a href="{$email.settingsUrl}">settings</a>.
       {/if}
     </p>
   {/if}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
index 59790dc..870ad46 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/CommentHtml.soy
@@ -20,7 +20,6 @@
  * @param commentFiles
  * @param commentCount
  * @param email
- * @param fromName
  * @param labels
  * @param patchSet
  * @param patchSetCommentBlocks
@@ -38,7 +37,17 @@
 
   {let $ulStyle kind="css"}
     list-style: none;
-    padding-left: 20px;
+    padding: 0;
+  {/let}
+
+  {let $fileLiStyle kind="css"}
+    margin: 0;
+    padding: 0;
+  {/let}
+
+  {let $commentLiStyle kind="css"}
+    margin: 0;
+    padding: 0 0 0 16px;
   {/let}
 
   {let $voteStyle kind="css"}
@@ -63,9 +72,30 @@
     background-color: #ddd;
   {/let}
 
-  <p>
-    {$fromName} <strong>posted comments</strong> on this change.
-  </p>
+  {if $patchSetCommentBlocks}
+    {call .WikiFormat}{param content: $patchSetCommentBlocks /}{/call}
+  {/if}
+
+  {if length($labels) > 0}
+    <p>
+      Patch set {$patchSet.patchSetId}:
+      {foreach $label in $labels}
+        {if $label.value > 0}
+          <span style="{$positiveVoteStyle}">
+            {$label.label}{sp}+{$label.value}
+          </span>
+        {elseif $label.value < 0}
+          <span style="{$negativeVoteStyle}">
+            {$label.label}{sp}{$label.value}
+          </span>
+        {else}
+          <span style="{$neutralVoteStyle}">
+            -{$label.label}
+          </span>
+        {/if}
+      {/foreach}
+    </p>
+  {/if}
 
   {if $email.changeUrl}
     <p>
@@ -73,45 +103,22 @@
     </p>
   {/if}
 
-  <p>
-    Patch set {$patchSet.patchSetId}:
-    {foreach $label in $labels}
-      {if $label.value > 0}
-        <span style="{$positiveVoteStyle}">
-          {$label.label}{sp}+{$label.value}
-        </span>
-      {elseif $label.value < 0}
-        <span style="{$negativeVoteStyle}">
-          {$label.label}{sp}{$label.value}
-        </span>
-      {else}
-        <span style="{$neutralVoteStyle}">
-          -{$label.label}
-        </span>
-      {/if}
-    {/foreach}
-  </p>
-
-  {if $patchSetCommentBlocks}
-    {call .WikiFormat}{param content: $patchSetCommentBlocks /}{/call}
-  {/if}
-
   {if $commentCount == 1}
-    <p>(1 comment)</p>
+    <p>1 comment:</p>
   {elseif $commentCount > 1}
-    <p>({$commentCount} comments)</p>
+    <p>{$commentCount} comments:</p>
   {/if}
 
   <ul style="{$ulStyle}">
     {foreach $group in $commentFiles}
-      <li>
+      <li style="{$fileLiStyle}">
         <p>
           <a href="{$group.link}">{$group.title}:</a>
         </p>
 
         <ul style="{$ulStyle}">
           {foreach $comment in $group.comments}
-            <li>
+            <li style="{$commentLiStyle}">
               {if $comment.isRobotComment}
                 <p style="{$commentHeaderStyle}">
                   Robot Comment from{sp}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
index fa2b44d..927601b 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -17,6 +17,7 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
+ * @param diffLines
  * @param email
  * @param fromName
  */
@@ -36,6 +37,6 @@
   {call .Pre}{param content: $email.changeDetail /}{/call}
 
   {if $email.includeDiff}
-    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
   {/if}
-{/template}
\ No newline at end of file
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
index 559bb26..8026666 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/NewChangeHtml.soy
@@ -17,6 +17,7 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
+ * @param diffLines
  * @param email
  * @param fromName
  * @param ownerName
@@ -55,6 +56,6 @@
   {/if}
 
   {if $email.includeDiff}
-    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
   {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
index 93353d7..b26535b 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/Private.soy
@@ -24,7 +24,7 @@
  * Private template to generate "View Change" buttons.
  * @param email
  */
-{template .ViewChangeButton private="true" autoescape="strict" kind="html"}
+{template .ViewChangeButton autoescape="strict" kind="html"}
   <a href="{$email.changeUrl}">View Change</a>
 {/template}
 
@@ -32,7 +32,7 @@
  * Private template to render PRE block with consistent font-sizing.
  * @param content
  */
-{template .Pre private="true" autoescape="strict" kind="html"}
+{template .Pre autoescape="strict" kind="html"}
   {let $preStyle kind="css"}
     font-family: monospace,monospace; // Use this to avoid browsers scaling down
                                       // monospace text.
@@ -56,7 +56,7 @@
  *
  * @param content
  */
-{template .WikiFormat private="true" autoescape="strict" kind="html"}
+{template .WikiFormat autoescape="strict" kind="html"}
   {let $blockquoteStyle kind="css"}
     border-left: 1px solid #aaa;
     margin: 10px 0;
@@ -86,3 +86,36 @@
     {/if}
   {/foreach}
 {/template}
+
+/**
+ * @param diffLines
+ */
+{template .UnifiedDiff autoescape="strict" kind="html"}
+  {let $addStyle kind="css"}
+    color: hsl(120, 100%, 40%);
+  {/let}
+
+  {let $removeStyle kind="css"}
+    color: hsl(0, 100%, 40%);
+  {/let}
+
+  {let $preStyle kind="css"}
+    font-family: monospace,monospace; // Use this to avoid browsers scaling down
+                                      // monospace text.
+    white-space: pre-wrap;
+  {/let}
+
+  <pre style="{$preStyle}">
+    {foreach $line in $diffLines}
+      {if $line.type == 'add'}
+        <span style="{$addStyle}">
+      {elseif $line.type == 'remove'}
+        <span style="{$removeStyle}">
+      {else}
+        <span>
+      {/if}
+        {$line.text}
+      </span><br>
+    {/foreach}
+  </pre>
+{/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
index bbf16d6..31cfbd6 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/SetAssigneeHtml.soy
@@ -17,6 +17,7 @@
 {namespace com.google.gerrit.server.mail.template}
 
 /**
+ * @param diffLines
  * @param email
  * @param fromName
  * @param patchSet
@@ -44,6 +45,6 @@
   {/if}
 
   {if $email.includeDiff}
-    {call .Pre}{param content: $email.unifiedDiff /}{/call}
+    {call .UnifiedDiff}{param diffLines: $diffLines /}{/call}
   {/if}
 {/template}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
index 5a937b6..996e8a4 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mime/mime-types.properties
@@ -26,6 +26,7 @@
 cob = text/x-cobol
 coffee = text/x-coffeescript
 conf = text/plain
+config = text/x-ini
 cpy = text/x-cobol
 cr = text/x-crystal
 cs = text/x-csharp
@@ -156,7 +157,6 @@
 pm = text/x-perl
 pp = text/x-puppet
 pro = text/x-idl
-project.config = text/x-ini
 properties = text/x-ini
 proto = text/x-protobuf
 protobuf = text/x-protobuf
diff --git a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
index 9974bc6..91b01f6 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/metrics/proc/ProcMetricModuleTest.java
@@ -126,19 +126,16 @@
 
   @Test
   public void callbackMetric0() {
-    final CallbackMetric0<Long> cntr =
+    CallbackMetric0<Long> cntr =
         metrics.newCallbackMetric(
             "test/count", Long.class, new Description("simple test").setCumulative());
 
-    final AtomicInteger invocations = new AtomicInteger(0);
+    AtomicInteger invocations = new AtomicInteger(0);
     metrics.newTrigger(
         cntr,
-        new Runnable() {
-          @Override
-          public void run() {
-            invocations.getAndIncrement();
-            cntr.set(42L);
-          }
+        () -> {
+          invocations.getAndIncrement();
+          cntr.set(42L);
         });
 
     // Triggers run immediately with DropWizard binding.
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
index fa4a951..40596e8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/GerritCommonTest.java
@@ -17,8 +17,8 @@
 import static org.easymock.EasyMock.expect;
 
 import com.google.gerrit.common.data.LabelTypes;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
 import com.google.inject.AbstractModule;
 import com.googlecode.prolog_cafe.exceptions.CompileException;
 import com.googlecode.prolog_cafe.exceptions.ReductionLimitException;
@@ -47,27 +47,28 @@
             cfg.setInt("rules", null, "reductionLimit", 1300);
             cfg.setInt("rules", null, "compileReductionLimit", (int) 1e6);
             bind(PrologEnvironment.Args.class)
-                .toInstance(new PrologEnvironment.Args(null, null, null, null, null, null, cfg));
+                .toInstance(
+                    new PrologEnvironment.Args(null, null, null, null, null, null, null, cfg));
           }
         });
   }
 
   @Override
-  protected void setUpEnvironment(PrologEnvironment env) {
+  protected void setUpEnvironment(PrologEnvironment env) throws Exception {
     LabelTypes labelTypes = new LabelTypes(Arrays.asList(Util.codeReview(), Util.verified()));
-    ChangeControl ctl = EasyMock.createMock(ChangeControl.class);
-    expect(ctl.getLabelTypes()).andStubReturn(labelTypes);
-    EasyMock.replay(ctl);
-    env.set(StoredValues.CHANGE_CONTROL, ctl);
+    ChangeData cd = EasyMock.createMock(ChangeData.class);
+    expect(cd.getLabelTypes()).andStubReturn(labelTypes);
+    EasyMock.replay(cd);
+    env.set(StoredValues.CHANGE_DATA, cd);
   }
 
   @Test
-  public void gerritCommon() {
+  public void gerritCommon() throws Exception {
     runPrologBasedTests();
   }
 
   @Test
-  public void reductionLimit() throws CompileException {
+  public void reductionLimit() throws Exception {
     PrologEnvironment env = envFactory.create(machine);
     setUpEnvironment(env);
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
index 6f6d189..c0a2192 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/rules/PrologTestCase.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.rules;
 
 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.google.gerrit.common.TimeUtil;
@@ -84,7 +84,7 @@
    *
    * @param env Prolog environment.
    */
-  protected void setUpEnvironment(PrologEnvironment env) {}
+  protected void setUpEnvironment(PrologEnvironment env) throws Exception {}
 
   private PrologMachineCopy newMachine() {
     BufferingPrologControl ctl = new BufferingPrologControl();
@@ -115,7 +115,7 @@
     return env.execute(Prolog.BUILTIN, "clause", head, new VariableTerm());
   }
 
-  public void runPrologBasedTests() {
+  public void runPrologBasedTests() throws Exception {
     int errors = 0;
     long start = TimeUtil.nowMs();
 
@@ -172,8 +172,7 @@
 
   private void call(BufferingPrologControl env, String name) {
     StructureTerm head = SymbolTerm.create(pkg, name, 0);
-    assert_()
-        .withFailureMessage("Cannot invoke " + pkg + ":" + name)
+    assertWithMessage("Cannot invoke " + pkg + ":" + name)
         .that(env.execute(Prolog.BUILTIN, "call", head))
         .isTrue();
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
index 4689688..b32bdc6 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/IdentifiedUserTest.java
@@ -20,7 +20,6 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.FakeRealm;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.Realm;
@@ -36,7 +35,6 @@
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
-import com.google.inject.util.Providers;
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.Set;
@@ -93,8 +91,6 @@
                 .toInstance("http://localhost:8080/");
             bind(AccountCache.class).toInstance(accountCache);
             bind(GroupBackend.class).to(SystemGroupBackend.class).in(SINGLETON);
-            bind(CapabilityControl.Factory.class)
-                .toProvider(Providers.<CapabilityControl.Factory>of(null));
             bind(Realm.class).toInstance(mockRealm);
           }
         };
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
index 804e2f2..b3faef4 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.server.config;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 
 import com.google.common.collect.ImmutableList;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -147,7 +148,7 @@
 
   @Test
   public void basePathWhenNotConfigured() {
-    assertThat((Object) repoCfg.getBasePath(new NameKey("someProject"))).isNull();
+    assertThat(repoCfg.getBasePath(new NameKey("someProject"))).isNull();
   }
 
   @Test
@@ -161,7 +162,7 @@
   public void basePathForSpecificFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
     configureBasePath("someProject", basePath);
-    assertThat((Object) repoCfg.getBasePath(new NameKey("someOtherProject"))).isNull();
+    assertThat(repoCfg.getBasePath(new NameKey("someOtherProject"))).isNull();
     assertThat(repoCfg.getBasePath(new NameKey("someProject")).toString()).isEqualTo(basePath);
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
new file mode 100644
index 0000000..801b2b0
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/ChangeFileContentModificationSubject.java
@@ -0,0 +1,69 @@
+// 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.edit.tree;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.io.CharStreams;
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.restapi.RawInput;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+
+public class ChangeFileContentModificationSubject
+    extends Subject<ChangeFileContentModificationSubject, ChangeFileContentModification> {
+
+  private static final SubjectFactory<
+          ChangeFileContentModificationSubject, ChangeFileContentModification>
+      MODIFICATION_SUBJECT_FACTORY =
+          new SubjectFactory<
+              ChangeFileContentModificationSubject, ChangeFileContentModification>() {
+            @Override
+            public ChangeFileContentModificationSubject getSubject(
+                FailureStrategy failureStrategy, ChangeFileContentModification modification) {
+              return new ChangeFileContentModificationSubject(failureStrategy, modification);
+            }
+          };
+
+  public static ChangeFileContentModificationSubject assertThat(
+      ChangeFileContentModification modification) {
+    return assertAbout(MODIFICATION_SUBJECT_FACTORY).that(modification);
+  }
+
+  private ChangeFileContentModificationSubject(
+      FailureStrategy failureStrategy, ChangeFileContentModification modification) {
+    super(failureStrategy, modification);
+  }
+
+  public StringSubject filePath() {
+    isNotNull();
+    return Truth.assertThat(actual().getFilePath()).named("filePath");
+  }
+
+  public StringSubject newContent() throws IOException {
+    isNotNull();
+    RawInput newContent = actual().getNewContent();
+    Truth.assertThat(newContent).named("newContent").isNotNull();
+    String contentString =
+        CharStreams.toString(
+            new InputStreamReader(newContent.getInputStream(), StandardCharsets.UTF_8));
+    return Truth.assertThat(contentString).named("newContent");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
new file mode 100644
index 0000000..ac4ebb8
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/edit/tree/TreeModificationSubject.java
@@ -0,0 +1,57 @@
+// 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.edit.tree;
+
+import static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.gerrit.truth.ListSubject;
+import java.util.List;
+
+public class TreeModificationSubject extends Subject<TreeModificationSubject, TreeModification> {
+
+  private static final SubjectFactory<TreeModificationSubject, TreeModification>
+      TREE_MODIFICATION_SUBJECT_FACTORY =
+          new SubjectFactory<TreeModificationSubject, TreeModification>() {
+            @Override
+            public TreeModificationSubject getSubject(
+                FailureStrategy failureStrategy, TreeModification treeModification) {
+              return new TreeModificationSubject(failureStrategy, treeModification);
+            }
+          };
+
+  public static TreeModificationSubject assertThat(TreeModification treeModification) {
+    return assertAbout(TREE_MODIFICATION_SUBJECT_FACTORY).that(treeModification);
+  }
+
+  public static ListSubject<TreeModificationSubject, TreeModification> assertThatList(
+      List<TreeModification> treeModifications) {
+    return ListSubject.assertThat(treeModifications, TreeModificationSubject::assertThat)
+        .named("treeModifications");
+  }
+
+  private TreeModificationSubject(
+      FailureStrategy failureStrategy, TreeModification treeModification) {
+    super(failureStrategy, treeModification);
+  }
+
+  public ChangeFileContentModificationSubject asChangeFileContentModification() {
+    isInstanceOf(ChangeFileContentModification.class);
+    return ChangeFileContentModificationSubject.assertThat(
+        (ChangeFileContentModification) actual());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java
index 355f775..997fda9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/events/EventDeserializerTest.java
@@ -57,7 +57,7 @@
     assertThat(e.submitter.get().email).isEqualTo(accountAttribute.email);
   }
 
-  private <T> Supplier<T> createSupplier(final T value) {
+  private <T> Supplier<T> createSupplier(T value) {
     return Suppliers.memoize(
         new Supplier<T>() {
           @Override
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
new file mode 100644
index 0000000..c1a65bb
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/FixReplacementInterpreterTest.java
@@ -0,0 +1,333 @@
+// 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.fixes;
+
+import static com.google.gerrit.server.edit.tree.TreeModificationSubject.assertThatList;
+import static org.easymock.EasyMock.createMock;
+import static org.easymock.EasyMock.replay;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.extensions.restapi.BinaryResult;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.reviewdb.client.Comment.Range;
+import com.google.gerrit.reviewdb.client.FixReplacement;
+import com.google.gerrit.server.change.FileContentUtil;
+import com.google.gerrit.server.edit.tree.TreeModification;
+import com.google.gerrit.server.project.ProjectState;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import org.easymock.EasyMock;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class FixReplacementInterpreterTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  private final FileContentUtil fileContentUtil = createMock(FileContentUtil.class);
+  private final Repository repository = createMock(Repository.class);
+  private final ProjectState projectState = createMock(ProjectState.class);
+  private final ObjectId patchSetCommitId = createMock(ObjectId.class);
+  private final String filePath1 = "an/arbitrary/file.txt";
+  private final String filePath2 = "another/arbitrary/file.txt";
+
+  private FixReplacementInterpreter fixReplacementInterpreter;
+
+  @Before
+  public void setUp() {
+    fixReplacementInterpreter = new FixReplacementInterpreter(fileContentUtil);
+  }
+
+  @Test
+  public void noReplacementsResultInNoTreeModifications() throws Exception {
+    List<TreeModification> treeModifications = toTreeModifications();
+    assertThatList(treeModifications).isEmpty();
+  }
+
+  @Test
+  public void treeModificationsTargetCorrectFiles() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, 6, 3, 2), "Modified content");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(3, 5, 3, 5), "Second modification");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+    FixReplacement fixReplacement3 =
+        new FixReplacement(filePath2, new Range(2, 0, 3, 0), "Another modified content");
+    mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement, fixReplacement3, fixReplacement2);
+    List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
+    assertThatList(sortedTreeModifications)
+        .element(0)
+        .asChangeFileContentModification()
+        .filePath()
+        .isEqualTo(filePath1);
+    assertThatList(sortedTreeModifications)
+        .element(0)
+        .asChangeFileContentModification()
+        .newContent()
+        .startsWith("First");
+    assertThatList(sortedTreeModifications)
+        .element(1)
+        .asChangeFileContentModification()
+        .filePath()
+        .isEqualTo(filePath2);
+    assertThatList(sortedTreeModifications)
+        .element(1)
+        .asChangeFileContentModification()
+        .newContent()
+        .startsWith("1st");
+  }
+
+  @Test
+  public void replacementsCanDeleteALine() throws Exception {
+    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 0, 3, 0), "");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nThird line\n");
+  }
+
+  @Test
+  public void replacementsCanAddALine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(2, 0, 2, 0), "A new line\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nA new line\nSecond line\nThird line\n");
+  }
+
+  @Test
+  public void replacementsMaySpanMultipleLines() throws Exception {
+    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(1, 6, 3, 1), "and t");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First and third line\n");
+  }
+
+  @Test
+  public void replacementsMayOccurOnSameLine() throws Exception {
+    FixReplacement fixReplacement1 = new FixReplacement(filePath1, new Range(2, 0, 2, 6), "A");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(2, 7, 2, 11), "modification");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement1, fixReplacement2);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nA modification\nThird line\n");
+  }
+
+  @Test
+  public void replacementsMayTouch() throws Exception {
+    FixReplacement fixReplacement1 =
+        new FixReplacement(filePath1, new Range(1, 6, 2, 7), "modified ");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(2, 7, 3, 5), "content");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement1, fixReplacement2);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First modified content line\n");
+  }
+
+  @Test
+  public void replacementsCanAddContentAtEndOfFile() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(4, 0, 4, 0), "New content");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nSecond line\nThird line\nNew content");
+  }
+
+  @Test
+  public void replacementsCanModifySeveralFilesInAnyOrder() throws Exception {
+    FixReplacement fixReplacement1 =
+        new FixReplacement(filePath1, new Range(1, 1, 3, 2), "Modified content");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath2, new Range(2, 0, 3, 0), "First modification\n");
+    FixReplacement fixReplacement3 =
+        new FixReplacement(filePath2, new Range(3, 0, 4, 0), "Second modification\n");
+    mockFileContent(filePath2, "1st line\n2nd line\n3rd line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement3, fixReplacement1, fixReplacement2);
+    List<TreeModification> sortedTreeModifications = getSortedCopy(treeModifications);
+    assertThatList(sortedTreeModifications)
+        .element(0)
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("FModified contentird line\n");
+    assertThatList(sortedTreeModifications)
+        .element(1)
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("1st line\nFirst modification\nSecond modification\n");
+  }
+
+  @Test
+  public void lineSeparatorCanBeChanged() throws Exception {
+    FixReplacement fixReplacement = new FixReplacement(filePath1, new Range(2, 11, 3, 0), "\r");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications = toTreeModifications(fixReplacement);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo("First line\nSecond line\rThird line\n");
+  }
+
+  @Test
+  public void replacementsDoNotNeedToBeOrderedAccordingToRange() throws Exception {
+    FixReplacement fixReplacement1 =
+        new FixReplacement(filePath1, new Range(1, 0, 2, 0), "1st modification\n");
+    FixReplacement fixReplacement2 =
+        new FixReplacement(filePath1, new Range(3, 0, 4, 0), "2nd modification\n");
+    FixReplacement fixReplacement3 =
+        new FixReplacement(filePath1, new Range(4, 0, 5, 0), "3rd modification\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\nFourth line\nFifth line\n");
+
+    replay(fileContentUtil);
+    List<TreeModification> treeModifications =
+        toTreeModifications(fixReplacement2, fixReplacement1, fixReplacement3);
+    assertThatList(treeModifications)
+        .onlyElement()
+        .asChangeFileContentModification()
+        .newContent()
+        .isEqualTo(
+            "1st modification\nSecond line\n2nd modification\n3rd modification\nFifth line\n");
+  }
+
+  @Test
+  public void replacementsMustNotReferToNotExistingLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(5, 0, 5, 0), "A new line\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToZeroLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(0, 0, 0, 0), "A new line\n");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToNotExistingOffsetOfIntermediateLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, 0, 1, 11), "modified");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToNotExistingOffsetOfLastLine() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(3, 0, 3, 11), "modified");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  @Test
+  public void replacementsMustNotReferToNegativeOffset() throws Exception {
+    FixReplacement fixReplacement =
+        new FixReplacement(filePath1, new Range(1, -1, 1, 5), "modified");
+    mockFileContent(filePath1, "First line\nSecond line\nThird line\n");
+
+    replay(fileContentUtil);
+
+    expectedException.expect(ResourceConflictException.class);
+    toTreeModifications(fixReplacement);
+  }
+
+  private void mockFileContent(String filePath, String fileContent) throws Exception {
+    EasyMock.expect(
+            fileContentUtil.getContent(repository, projectState, patchSetCommitId, filePath))
+        .andReturn(BinaryResult.create(fileContent));
+  }
+
+  private List<TreeModification> toTreeModifications(FixReplacement... fixReplacements)
+      throws Exception {
+    return fixReplacementInterpreter.toTreeModifications(
+        repository, projectState, patchSetCommitId, ImmutableList.copyOf(fixReplacements));
+  }
+
+  private static List<TreeModification> getSortedCopy(List<TreeModification> treeModifications) {
+    List<TreeModification> sortedTreeModifications = new ArrayList<>(treeModifications);
+    sortedTreeModifications.sort(Comparator.comparing(TreeModification::getFilePath));
+    return sortedTreeModifications;
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java
new file mode 100644
index 0000000..f638346
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/LineIdentifierTest.java
@@ -0,0 +1,257 @@
+// 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.fixes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class LineIdentifierTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  @Test
+  public void lineNumberMustBePositive() {
+    LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    expectedException.expectMessage("positive");
+    lineIdentifier.getStartIndexOfLine(0);
+  }
+
+  @Test
+  public void lineNumberMustIndicateAnAvailableLine() {
+    LineIdentifier lineIdentifier = new LineIdentifier("First line\nSecond line");
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    expectedException.expectMessage("Line 3 isn't available");
+    lineIdentifier.getStartIndexOfLine(3);
+  }
+
+  @Test
+  public void startIndexOfFirstLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfFirstLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(8);
+  }
+
+  @Test
+  public void startIndexOfSecondLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfSecondLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void startIndexOfLastLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(3);
+    assertThat(startIndex).isEqualTo(13);
+  }
+
+  @Test
+  public void lengthOfLastLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(3);
+    assertThat(lineLength).isEqualTo(7);
+  }
+
+  @Test
+  public void emptyFirstLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("\n123\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfEmptyFirstLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("\n123\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void emptyIntermediaryLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfEmptyIntermediaryLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void lineAfterIntermediaryLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n\n1234567");
+    int startIndex = lineIdentifier.getStartIndexOfLine(3);
+    assertThat(startIndex).isEqualTo(10);
+  }
+
+  @Test
+  public void emptyLastLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n");
+    int startIndex = lineIdentifier.getStartIndexOfLine(3);
+    assertThat(startIndex).isEqualTo(13);
+  }
+
+  @Test
+  public void lengthOfEmptyLastLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n");
+    int lineLength = lineIdentifier.getLengthOfLine(3);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void startIndexOfSingleLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfSingleLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(8);
+  }
+
+  @Test
+  public void startIndexOfSingleEmptyLineIsRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("");
+    int startIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(startIndex).isEqualTo(0);
+  }
+
+  @Test
+  public void lengthOfSingleEmptyLineIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("");
+    int lineLength = lineIdentifier.getLengthOfLine(1);
+    assertThat(lineLength).isEqualTo(0);
+  }
+
+  @Test
+  public void lookingUpSubsequentLinesIsPossible() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
+
+    int firstLineStartIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(firstLineStartIndex).isEqualTo(0);
+
+    int secondLineStartIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(secondLineStartIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lookingUpNotSubsequentLinesInAscendingOrderIsPossible() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
+
+    int firstLineStartIndex = lineIdentifier.getStartIndexOfLine(1);
+    assertThat(firstLineStartIndex).isEqualTo(0);
+
+    int fourthLineStartIndex = lineIdentifier.getStartIndexOfLine(4);
+    assertThat(fourthLineStartIndex).isEqualTo(21);
+  }
+
+  @Test
+  public void lookingUpNotSubsequentLinesInDescendingOrderIsPossible() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\n123\n1234567\n12");
+
+    int fourthLineStartIndex = lineIdentifier.getStartIndexOfLine(4);
+    assertThat(fourthLineStartIndex).isEqualTo(21);
+
+    int secondLineStartIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(secondLineStartIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void linesSeparatedByOnlyCarriageReturnAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfLinesSeparatedByOnlyCarriageReturnIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r12");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void linesSeparatedByLineFeedAndCarriageReturnAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r\n123\r\n12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(10);
+  }
+
+  @Test
+  public void lengthOfLinesSeparatedByLineFeedAndCarriageReturnIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r\n123\r\n12");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void linesSeparatedByMixtureOfCarriageReturnAndLineFeedAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\r123\r\n12\n123456\r\n1234");
+    int startIndex = lineIdentifier.getStartIndexOfLine(5);
+    assertThat(startIndex).isEqualTo(25);
+  }
+
+  @Test
+  public void linesSeparatedBySomeUnicodeLinebreakCharacterAreRecognized() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\u2029123\u202912");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(9);
+  }
+
+  @Test
+  public void lengthOfLinesSeparatedBySomeUnicodeLinebreakCharacterIsCorrect() {
+    LineIdentifier lineIdentifier = new LineIdentifier("12345678\u2029123\u202912");
+    int lineLength = lineIdentifier.getLengthOfLine(2);
+    assertThat(lineLength).isEqualTo(3);
+  }
+
+  @Test
+  public void blanksAreNotInterpretedAsLineSeparators() {
+    LineIdentifier lineIdentifier = new LineIdentifier("1 2345678\n123\n12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(10);
+  }
+
+  @Test
+  public void tabsAreNotInterpretedAsLineSeparators() {
+    LineIdentifier lineIdentifier = new LineIdentifier("123\t45678\n123\n12");
+    int startIndex = lineIdentifier.getStartIndexOfLine(2);
+    assertThat(startIndex).isEqualTo(10);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java
new file mode 100644
index 0000000..d23e928
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/fixes/StringModifierTest.java
@@ -0,0 +1,106 @@
+// 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.fixes;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+
+public class StringModifierTest {
+
+  @Rule public ExpectedException expectedException = ExpectedException.none();
+
+  private final String originalString = "This is the original, unmodified string.";
+  private StringModifier stringModifier;
+
+  @Before
+  public void setUp() {
+    stringModifier = new StringModifier(originalString);
+  }
+
+  @Test
+  public void singlePartIsReplaced() {
+    stringModifier.replace(0, 11, "An");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("An original, unmodified string.");
+  }
+
+  @Test
+  public void twoPartsCanBeReplacedWithInsertionFirst() {
+    stringModifier.replace(5, 5, "string ");
+    stringModifier.replace(8, 39, "a modified version");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("This string is a modified version.");
+  }
+
+  @Test
+  public void twoPartsCanBeReplacedWithDeletionFirst() {
+    stringModifier.replace(0, 8, "");
+    stringModifier.replace(12, 32, "modified");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("the modified string.");
+  }
+
+  @Test
+  public void replacedPartsMayTouch() {
+    stringModifier.replace(0, 8, "");
+    stringModifier.replace(8, 32, "The modified");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString).isEqualTo("The modified string.");
+  }
+
+  @Test
+  public void replacedPartsMustNotOverlap() {
+    stringModifier.replace(0, 9, "");
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(8, 32, "The modified");
+  }
+
+  @Test
+  public void startIndexMustNotBeGreaterThanEndIndex() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(10, 9, "something");
+  }
+
+  @Test
+  public void startIndexMustNotBeNegative() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(-1, 9, "something");
+  }
+
+  @Test
+  public void newContentCanBeInsertedAtEndOfString() {
+    stringModifier.replace(
+        originalString.length(), originalString.length(), " And this an addition.");
+    String modifiedString = stringModifier.getResult();
+    assertThat(modifiedString)
+        .isEqualTo("This is the original, unmodified string. And this an addition.");
+  }
+
+  @Test
+  public void startIndexMustNotBeGreaterThanLengthOfString() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(originalString.length() + 1, originalString.length() + 1, "something");
+  }
+
+  @Test
+  public void endIndexMustNotBeGreaterThanLengthOfString() {
+    expectedException.expect(StringIndexOutOfBoundsException.class);
+    stringModifier.replace(8, originalString.length() + 1, "something");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
index eabccf7..5453fad 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/LabelNormalizerTest.java
@@ -40,6 +40,7 @@
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.git.LabelNormalizer.Result;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
@@ -69,12 +70,14 @@
   @Inject private ProjectCache projectCache;
   @Inject private SchemaCreator schemaCreator;
   @Inject protected ThreadLocalRequestContext requestContext;
+  @Inject private ChangeNotes.Factory changeNotesFactory;
 
   private LifecycleManager lifecycle;
   private ReviewDb db;
   private Account.Id userId;
   private IdentifiedUser user;
   private Change change;
+  private ChangeNotes notes;
 
   @Before
   public void setUpInjector() throws Exception {
@@ -131,6 +134,7 @@
     ps.setSubject("Test change");
     change.setCurrentPatchSet(ps);
     db.changes().insert(ImmutableList.of(change));
+    notes = changeNotesFactory.createChecked(db, change);
   }
 
   @After
@@ -146,7 +150,7 @@
   }
 
   @Test
-  public void normalizeByPermission() throws Exception {
+  public void noNormalizeByPermission() throws Exception {
     ProjectConfig pc = loadAllProjects();
     allow(pc, forLabel("Code-Review"), -1, 1, REGISTERED_USERS, "refs/heads/*");
     allow(pc, forLabel("Verified"), -1, 1, REGISTERED_USERS, "refs/heads/*");
@@ -154,8 +158,7 @@
 
     PatchSetApproval cr = psa(userId, "Code-Review", 2);
     PatchSetApproval v = psa(userId, "Verified", 1);
-    assertEquals(
-        Result.create(list(v), list(copy(cr, 1)), list()), norm.normalize(change, list(cr, v)));
+    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
   @Test
@@ -169,14 +172,14 @@
     PatchSetApproval v = psa(userId, "Verified", 5);
     assertEquals(
         Result.create(list(), list(copy(cr, 2), copy(v, 1)), list()),
-        norm.normalize(change, list(cr, v)));
+        norm.normalize(notes, list(cr, v)));
   }
 
   @Test
-  public void emptyPermissionRangeOmitsResult() throws Exception {
+  public void emptyPermissionRangeKeepsResult() throws Exception {
     PatchSetApproval cr = psa(userId, "Code-Review", 1);
     PatchSetApproval v = psa(userId, "Verified", 1);
-    assertEquals(Result.create(list(), list(), list(cr, v)), norm.normalize(change, list(cr, v)));
+    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
   @Test
@@ -187,7 +190,7 @@
 
     PatchSetApproval cr = psa(userId, "Code-Review", 0);
     PatchSetApproval v = psa(userId, "Verified", 0);
-    assertEquals(Result.create(list(cr), list(), list(v)), norm.normalize(change, list(cr, v)));
+    assertEquals(Result.create(list(cr, v), list(), list()), norm.normalize(notes, list(cr, v)));
   }
 
   private ProjectConfig loadAllProjects() throws Exception {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
index ad9a1ce..cae8fec 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/git/ProjectConfigTest.java
@@ -15,7 +15,7 @@
 package com.google.gerrit.server.git;
 
 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.common.collect.Iterables;
 import com.google.gerrit.common.data.AccessSection;
@@ -30,6 +30,8 @@
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.server.project.CommentLinkInfoImpl;
+import com.google.gwtorm.client.KeyUtil;
+import com.google.gwtorm.server.StandardKeyEncoder;
 import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collections;
@@ -47,6 +49,7 @@
 import org.eclipse.jgit.revwalk.RevObject;
 import org.eclipse.jgit.util.RawParseUtils;
 import org.junit.Before;
+import org.junit.BeforeClass;
 import org.junit.Test;
 
 public class ProjectConfigTest extends LocalDiskRepositoryTestCase {
@@ -77,6 +80,11 @@
   private Repository db;
   private TestRepository<Repository> util;
 
+  @BeforeClass
+  public static void setUpOnce() {
+    KeyUtil.setEncoderImpl(new StandardKeyEncoder());
+  }
+
   @Override
   @Before
   public void setUp() throws Exception {
@@ -515,8 +523,7 @@
     u.disableRefLog();
     u.setNewObjectId(rev);
     Result result = u.forceUpdate();
-    assert_()
-        .withFailureMessage("Cannot update ref for test: " + result)
+    assertWithMessage("Cannot update ref for test: " + result)
         .that(result)
         .isAnyOf(Result.FAST_FORWARD, Result.FORCED, Result.NEW, Result.NO_CHANGE);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/SchemaUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/SchemaUtilTest.java
deleted file mode 100644
index 1dc6a469..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/SchemaUtilTest.java
+++ /dev/null
@@ -1,80 +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.server.index;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.gerrit.server.index.SchemaUtil.getNameParts;
-import static com.google.gerrit.server.index.SchemaUtil.getPersonParts;
-import static com.google.gerrit.server.index.SchemaUtil.schema;
-
-import com.google.gerrit.testutil.GerritBaseTests;
-import java.util.Map;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.junit.Test;
-
-public class SchemaUtilTest extends GerritBaseTests {
-  static class TestSchemas {
-    static final Schema<String> V1 = schema();
-    static final Schema<String> V2 = schema();
-    static Schema<String> V3 = schema(); // Not final, ignored.
-    private static final Schema<String> V4 = schema();
-
-    // Ignored.
-    static Schema<String> V10 = schema();
-    final Schema<String> V11 = schema();
-  }
-
-  @Test
-  public void schemasFromClassBuildsMap() {
-    Map<Integer, Schema<String>> all = SchemaUtil.schemasFromClass(TestSchemas.class, String.class);
-    assertThat(all.keySet()).containsExactly(1, 2, 4);
-    assertThat(all.get(1)).isEqualTo(TestSchemas.V1);
-    assertThat(all.get(2)).isEqualTo(TestSchemas.V2);
-    assertThat(all.get(4)).isEqualTo(TestSchemas.V4);
-
-    exception.expect(IllegalArgumentException.class);
-    SchemaUtil.schemasFromClass(TestSchemas.class, Object.class);
-  }
-
-  @Test
-  public void getPersonPartsExtractsParts() {
-    // PersonIdent allows empty email, which should be extracted as the empty
-    // string. However, it converts empty names to null internally.
-    assertThat(getPersonParts(new PersonIdent("", ""))).containsExactly("");
-    assertThat(getPersonParts(new PersonIdent("foo bar", ""))).containsExactly("foo", "bar", "");
-
-    assertThat(getPersonParts(new PersonIdent("", "foo@example.com")))
-        .containsExactly("foo@example.com", "foo", "example.com", "example", "com");
-    assertThat(getPersonParts(new PersonIdent("foO J. bAr", "bA-z@exAmple.cOm")))
-        .containsExactly(
-            "foo",
-            "j",
-            "bar",
-            "ba-z@example.com",
-            "ba-z",
-            "ba",
-            "z",
-            "example.com",
-            "example",
-            "com");
-  }
-
-  @Test
-  public void getNamePartsExtractsParts() {
-    assertThat(getNameParts("")).isEmpty();
-    assertThat(getNameParts("foO-bAr_Baz a.b@c/d"))
-        .containsExactly("foo", "bar", "baz", "a", "b", "c", "d");
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/account/AccountFieldTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/account/AccountFieldTest.java
new file mode 100644
index 0000000..6d4f122
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/account/AccountFieldTest.java
@@ -0,0 +1,85 @@
+// 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.index.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toList;
+
+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.common.TimeUtil;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
+import com.google.gerrit.testutil.GerritBaseTests;
+import java.util.List;
+import org.eclipse.jgit.lib.ObjectId;
+import org.junit.Test;
+
+public class AccountFieldTest extends GerritBaseTests {
+  @Test
+  public void refStateFieldValues() throws Exception {
+    AllUsersName allUsersName = new AllUsersName(AllUsersNameProvider.DEFAULT);
+    Account account = new Account(new Account.Id(1), TimeUtil.nowTs());
+    String metaId = "0e39795bb25dc914118224995c53c5c36923a461";
+    account.setMetaId(metaId);
+    List<String> values =
+        toStrings(
+            AccountField.REF_STATE.get(
+                new AccountState(allUsersName, account, ImmutableSet.of(), ImmutableMap.of())));
+    assertThat(values).hasSize(1);
+    String expectedValue =
+        allUsersName.get() + ":" + RefNames.refsUsers(account.getId()) + ":" + metaId;
+    assertThat(Iterables.getOnlyElement(values)).isEqualTo(expectedValue);
+  }
+
+  @Test
+  public void externalIdStateFieldValues() throws Exception {
+    Account.Id id = new Account.Id(1);
+    Account account = new Account(id, TimeUtil.nowTs());
+    ExternalId extId1 =
+        ExternalId.create(
+            ExternalId.Key.create(ExternalId.SCHEME_MAILTO, "foo.bar@example.com"),
+            id,
+            "foo.bar@example.com",
+            null,
+            ObjectId.fromString("1b9a0cf038ea38a0ab08617c39aa8e28413a27ca"));
+    ExternalId extId2 =
+        ExternalId.create(
+            ExternalId.Key.create(ExternalId.SCHEME_USERNAME, "foo"),
+            id,
+            null,
+            "secret",
+            ObjectId.fromString("5b3a73dc9a668a5b89b5f049225261e3e3291d1a"));
+    List<String> values =
+        toStrings(
+            AccountField.EXTERNAL_ID_STATE.get(
+                new AccountState(
+                    null, account, ImmutableSet.of(extId1, extId2), ImmutableMap.of())));
+    String expectedValue1 = extId1.key().sha1().name() + ":" + extId1.blobId().name();
+    String expectedValue2 = extId2.key().sha1().name() + ":" + extId2.blobId().name();
+    assertThat(values).containsExactly(expectedValue1, expectedValue2);
+  }
+
+  private List<String> toStrings(Iterable<byte[]> values) {
+    return Streams.stream(values).map(v -> new String(v, UTF_8)).collect(toList());
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
index 1724c51..4a6663a 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/ChangeIndexRewriterTest.java
@@ -16,22 +16,19 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.common.data.GlobalCapability.DEFAULT_MAX_QUERY_LIMIT;
-import static com.google.gerrit.reviewdb.client.Change.Status.ABANDONED;
-import static com.google.gerrit.reviewdb.client.Change.Status.DRAFT;
+import static com.google.gerrit.index.query.Predicate.and;
+import static com.google.gerrit.index.query.Predicate.or;
 import static com.google.gerrit.reviewdb.client.Change.Status.MERGED;
 import static com.google.gerrit.reviewdb.client.Change.Status.NEW;
 import static com.google.gerrit.server.index.change.IndexedChangeQuery.convertOptions;
-import static com.google.gerrit.server.query.Predicate.and;
-import static com.google.gerrit.server.query.Predicate.or;
 import static org.junit.Assert.assertEquals;
 
 import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.query.Predicate;
+import com.google.gerrit.index.query.QueryParseException;
 import com.google.gerrit.reviewdb.client.Change;
-import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.query.AndPredicate;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.AndChangeSource;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
@@ -58,7 +55,7 @@
     indexes = new ChangeIndexCollection();
     indexes.setSearchIndex(index);
     queryBuilder = new FakeQueryBuilder(indexes);
-    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.create(0, 0, 3));
+    rewrite = new ChangeIndexRewriter(indexes, IndexConfig.builder().maxTerms(3).build());
   }
 
   @Test
@@ -137,7 +134,7 @@
 
   @Test
   public void duplicateCompoundNonIndexOnlyPredicates() throws Exception {
-    Predicate<ChangeData> in = parse("(status:new OR status:draft) bar:p file:a");
+    Predicate<ChangeData> in = parse("status:new bar:p file:a");
     Predicate<ChangeData> out = rewrite(in);
     assertThat(out.getClass()).isEqualTo(AndChangeSource.class);
     assertThat(out.getChildren())
@@ -178,16 +175,16 @@
 
   @Test
   public void getPossibleStatus() throws Exception {
-    assertThat(status("file:a")).isEqualTo(EnumSet.allOf(Change.Status.class));
+    Set<Change.Status> all = EnumSet.allOf(Change.Status.class);
+    assertThat(status("file:a")).isEqualTo(all);
     assertThat(status("is:new")).containsExactly(NEW);
-    assertThat(status("-is:new")).containsExactly(DRAFT, MERGED, ABANDONED);
     assertThat(status("is:new OR is:merged")).containsExactly(NEW, MERGED);
+    assertThat(status("is:new OR is:x")).isEqualTo(all);
 
     assertThat(status("is:new is:merged")).isEmpty();
-    assertThat(status("(is:new is:draft) (is:merged)")).isEmpty();
-    assertThat(status("(is:new is:draft) (is:merged)")).isEmpty();
-
-    assertThat(status("(is:new is:draft) OR (is:merged)")).containsExactly(MERGED);
+    assertThat(status("(is:new) (is:merged)")).isEmpty();
+    assertThat(status("(is:new) (is:merged)")).isEmpty();
+    assertThat(status("is:new is:x")).containsExactly(NEW);
   }
 
   @Test
@@ -196,9 +193,10 @@
     assertThat(rewrite(in)).isEqualTo(query(in));
 
     indexes.setSearchIndex(new FakeChangeIndex(FakeChangeIndex.V1));
-    Predicate<ChangeData> out = rewrite(in);
-    assertThat(out).isInstanceOf(AndPredicate.class);
-    assertThat(out.getChildren()).containsExactly(query(in.getChild(0)), in.getChild(1)).inOrder();
+
+    exception.expect(QueryParseException.class);
+    exception.expectMessage("Unsupported index predicate: file:a");
+    rewrite(in);
   }
 
   @Test
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
index 8189c81..74e1c09 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeChangeIndex.java
@@ -15,12 +15,12 @@
 package com.google.gerrit.server.index.change;
 
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.index.FieldDef;
+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.Change;
-import com.google.gerrit.server.index.FieldDef;
-import com.google.gerrit.server.index.QueryOptions;
-import com.google.gerrit.server.index.Schema;
-import com.google.gerrit.server.query.Predicate;
-import com.google.gerrit.server.query.QueryParseException;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeDataSource;
 import com.google.gwtorm.server.OrmException;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
index 6fda100..edd4abf 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/FakeQueryBuilder.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.server.index.change;
 
-import com.google.gerrit.server.query.OperatorPredicate;
-import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.index.query.OperatorPredicate;
+import com.google.gerrit.index.query.Predicate;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gerrit.server.query.change.ChangeQueryBuilder;
 import org.junit.Ignore;
@@ -27,8 +27,7 @@
         new FakeQueryBuilder.Definition<>(FakeQueryBuilder.class),
         new ChangeQueryBuilder.Arguments(
             null, null, null, null, null, null, null, null, null, null, null, null, null, null,
-            null, null, null, null, null, null, null, indexes, null, null, null, null, null, null,
-            null, null, null));
+            null, null, null, null, null, indexes, null, null, null, null, null, null, null));
   }
 
   @Operator
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
index 4eef629..b25ed2b 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/index/change/StalenessCheckerTest.java
@@ -28,7 +28,7 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.index.change.StalenessChecker.RefState;
+import com.google.gerrit.server.index.RefState;
 import com.google.gerrit.server.index.change.StalenessChecker.RefStatePattern;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.testutil.GerritBaseTests;
@@ -321,11 +321,13 @@
     Change indexChange = newChange(P1, new Account.Id(1));
     indexChange.setNoteDbState(SHA1);
 
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null)).isFalse();
+    // Change is missing from ReviewDb but present in index.
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, null)).isTrue();
 
+    // Change differs only in primary storage.
     Change noteDbPrimary = clone(indexChange);
     noteDbPrimary.setNoteDbState(NoteDbChangeState.NOTE_DB_PRIMARY_STATE);
-    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary)).isFalse();
+    assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, noteDbPrimary)).isTrue();
 
     assertThat(StalenessChecker.reviewDbChangeIsStale(indexChange, clone(indexChange))).isFalse();
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java
index 800413b..fae8559 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/ioutil/BasicSerializationTest.java
@@ -129,14 +129,14 @@
     assertOutput(b(7, 'c', 'o', 'f', 'f', 'e', 'e', '4'), out);
   }
 
-  private static void assertOutput(final byte[] expect, final ByteArrayOutputStream out) {
+  private static void assertOutput(byte[] expect, ByteArrayOutputStream out) {
     final byte[] buf = out.toByteArray();
     for (int i = 0; i < expect.length; i++) {
       assertEquals(expect[i], buf[i]);
     }
   }
 
-  private static InputStream r(final byte[] buf) {
+  private static InputStream r(byte[] buf) {
     return new ByteArrayInputStream(buf);
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
index 2909df7..42e8a8e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/AddressTest.java
@@ -96,7 +96,7 @@
     assertInvalid("a <@a>");
   }
 
-  private void assertInvalid(final String in) {
+  private void assertInvalid(String in) {
     try {
       Address.parse(in);
       fail("Expected IllegalArgumentException for " + in);
@@ -151,7 +151,7 @@
     assertThat(format(null, "a,b@a")).isEqualTo("<a,b@a>");
   }
 
-  private static String format(final String name, final String email) {
+  private static String format(String name, String email) {
     return new Address(name, email).toHeaderString();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java
index 62bc580..0e0e8b0 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/HtmlParserTest.java
@@ -21,6 +21,9 @@
 import org.junit.Ignore;
 import org.junit.Test;
 
+/**
+ * Abstract parser test for HTML messages. Payload will be added through concrete implementations.
+ */
 @Ignore
 public abstract class HtmlParserTest extends AbstractParserTest {
   @Test
@@ -105,6 +108,23 @@
     assertInlineComment("Also have a comment here.", parsedComments.get(1), comments.get(3));
   }
 
+  @Test
+  public void commentsSpanningMultipleBlocks() {
+    String htmlMessage =
+        "This is a very long test comment. <div><br></div><div>Now this is a new paragraph yay.</div>";
+    String txtMessage = "This is a very long test comment.\n\nNow this is a new paragraph yay.";
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.htmlContent(newHtmlBody(htmlMessage, null, null, htmlMessage, htmlMessage, null, null));
+
+    List<Comment> comments = defaultComments();
+    List<MailComment> parsedComments = HtmlParser.parse(b.build(), comments, CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(3);
+    assertChangeMessage(txtMessage, parsedComments.get(0));
+    assertFileComment(txtMessage, parsedComments.get(1), comments.get(1).key.filename);
+    assertInlineComment(txtMessage, parsedComments.get(2), comments.get(3));
+  }
+
   /**
    * Create an html message body with the specified comments.
    *
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java
index 0a5381e..89e1f22 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/TextParserTest.java
@@ -165,6 +165,19 @@
     assertInlineComment("Comment in reply to file comment", parsedComments.get(1), comments.get(0));
   }
 
+  @Test
+  public void squashComments() {
+    MailMessage.Builder b = newMailMessageBuilder();
+    b.textContent(
+        "Nice change\n> Some quoted content\nMy other comment on the same entity\n" + quotedFooter);
+
+    List<MailComment> parsedComments = TextParser.parse(b.build(), defaultComments(), CHANGE_URL);
+
+    assertThat(parsedComments).hasSize(1);
+    assertChangeMessage(
+        "Nice change\n\nMy other comment on the same entity", parsedComments.get(0));
+  }
+
   /**
    * Create a plaintext message body with the specified comments.
    *
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
index d8530b5..be8d882 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/receive/data/AttachmentMessage.java
@@ -20,7 +20,10 @@
 import org.joda.time.DateTimeZone;
 import org.junit.Ignore;
 
-/** Tests that all mime parts that are neither text/plain, nor text/html are dropped. */
+/**
+ * Provides a raw message payload and a parsed {@code MailMessage} to check that mime parts that are
+ * neither text/plain, nor * text/html are dropped.
+ */
 @Ignore
 public class AttachmentMessage extends RawMailMessage {
   private static String raw =
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentSenderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentSenderTest.java
new file mode 100644
index 0000000..6b6632c
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/CommentSenderTest.java
@@ -0,0 +1,62 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.mail.send;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gwtorm.server.OrmException;
+import java.util.Collections;
+import org.junit.Test;
+
+public class CommentSenderTest {
+  private static class TestSender extends CommentSender {
+    TestSender() throws OrmException {
+      super(null, null, null, null, null);
+    }
+  }
+
+  // A 100-character long string.
+  private static String chars100 = String.join("", Collections.nCopies(25, "abcd"));
+
+  @Test
+  public void shortMessageNotShortened() {
+    String message = "foo bar baz";
+    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(message);
+
+    message = "foo bar baz.";
+    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(message);
+  }
+
+  @Test
+  public void longMessageIsShortened() {
+    String message = chars100 + "x";
+    String expected = chars100 + " […]";
+    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+  }
+
+  @Test
+  public void shortenedToFirstLine() {
+    String message = "abc\n" + chars100;
+    String expected = "abc […]";
+    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+  }
+
+  @Test
+  public void shortenedToFirstSentence() {
+    String message = "foo bar baz. " + chars100;
+    String expected = "foo bar baz. […]";
+    assertThat(TestSender.getShortenedCommentMessage(message)).isEqualTo(expected);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
index a7b37a8..d65dd47 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/FromAddressGeneratorProviderTest.java
@@ -25,6 +25,8 @@
 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.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import com.google.gerrit.server.mail.Address;
 import java.util.Arrays;
 import java.util.Collections;
@@ -51,7 +53,7 @@
     return new FromAddressGeneratorProvider(config, "Anonymous Coward", ident, accountCache).get();
   }
 
-  private void setFrom(final String newFrom) {
+  private void setFrom(String newFrom) {
     config.setString("sendemail", null, "from", newFrom);
   }
 
@@ -366,23 +368,26 @@
     verify(accountCache);
   }
 
-  private Account.Id user(final String name, final String email) {
+  private Account.Id user(String name, String email) {
     final AccountState s = makeUser(name, email);
     expect(accountCache.get(eq(s.getAccount().getId()))).andReturn(s);
     return s.getAccount().getId();
   }
 
-  private Account.Id userNoLookup(final String name, final String email) {
+  private Account.Id userNoLookup(String name, String email) {
     final AccountState s = makeUser(name, email);
     return s.getAccount().getId();
   }
 
-  private AccountState makeUser(final String name, final String email) {
+  private AccountState makeUser(String name, String email) {
     final Account.Id userId = new Account.Id(42);
     final Account account = new Account(userId, TimeUtil.nowTs());
     account.setFullName(name);
     account.setPreferredEmail(email);
     return new AccountState(
-        account, Collections.emptySet(), Collections.emptySet(), new HashMap<>());
+        new AllUsersName(AllUsersNameProvider.DEFAULT),
+        account,
+        Collections.emptySet(),
+        new HashMap<>());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java
deleted file mode 100644
index 5fafcf7..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/mail/send/ValidatorTest.java
+++ /dev/null
@@ -1,63 +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.server.mail.send;
-
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.common.truth.Truth.assert_;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import java.io.BufferedReader;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import org.junit.Test;
-
-public class ValidatorTest {
-  private static final String UNSUPPORTED_PREFIX = "#! ";
-
-  @Test
-  public void validateLocalDomain() throws Exception {
-    assertThat(OutgoingEmailValidator.isValid("foo@bar.local")).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());
-          assert_()
-              .withFailureMessage("expected invalid TLD \"" + test + "\"")
-              .that(OutgoingEmailValidator.isValid(test))
-              .isFalse();
-        } else {
-          String test = "test@example." + tld.toLowerCase();
-          assert_()
-              .withFailureMessage("failed to validate TLD \"" + test + "\"")
-              .that(OutgoingEmailValidator.isValid(test))
-              .isTrue();
-        }
-      }
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
index be153c9..f03fb37 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/AbstractChangeNotesTest.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.account.AccountCache;
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.FakeRealm;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.Realm;
@@ -57,12 +56,13 @@
 import com.google.gerrit.testutil.GerritBaseTests;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.TestChanges;
-import com.google.gerrit.testutil.TestNotesMigration;
 import com.google.gerrit.testutil.TestTimeUtil;
 import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
+import com.google.inject.TypeLiteral;
 import com.google.inject.util.Providers;
 import java.sql.Timestamp;
 import java.util.TimeZone;
@@ -97,8 +97,6 @@
 
   private static final TimeZone TZ = TimeZone.getTimeZone("America/Los_Angeles");
 
-  private static final NotesMigration MIGRATION = new TestNotesMigration().setAllEnabled(true);
-
   protected Account.Id otherUserId;
   protected FakeAccountCache accountCache;
   protected IdentifiedUser changeOwner;
@@ -153,11 +151,8 @@
                 install(NoteDbModule.forTest(testConfig));
                 bind(AllUsersName.class).toProvider(AllUsersNameProvider.class);
                 bind(String.class).annotatedWith(GerritServerId.class).toInstance("gerrit");
-                bind(NotesMigration.class).toInstance(MIGRATION);
                 bind(GitRepositoryManager.class).toInstance(repoManager);
                 bind(ProjectCache.class).toProvider(Providers.<ProjectCache>of(null));
-                bind(CapabilityControl.Factory.class)
-                    .toProvider(Providers.<CapabilityControl.Factory>of(null));
                 bind(Config.class).annotatedWith(GerritServerConfig.class).toInstance(testConfig);
                 bind(String.class)
                     .annotatedWith(AnonymousCowardName.class)
@@ -177,6 +172,23 @@
                 bind(GitReferenceUpdated.class).toInstance(GitReferenceUpdated.DISABLED);
                 bind(MetricMaker.class).to(DisabledMetricMaker.class);
                 bind(ReviewDb.class).toProvider(Providers.<ReviewDb>of(null));
+
+                MutableNotesMigration migration = MutableNotesMigration.newDisabled();
+                migration.setFrom(NotesMigrationState.FINAL);
+                bind(MutableNotesMigration.class).toInstance(migration);
+                bind(NotesMigration.class).to(MutableNotesMigration.class);
+
+                // Tests don't support ReviewDb at all, but bindings are required via NoteDbModule.
+                bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {})
+                    .toInstance(
+                        () -> {
+                          throw new UnsupportedOperationException();
+                        });
+                bind(ChangeBundleReader.class)
+                    .toInstance(
+                        (db, id) -> {
+                          throw new UnsupportedOperationException();
+                        });
               }
             });
 
@@ -185,7 +197,7 @@
     changeOwner = userFactory.create(co.getId());
     otherUser = userFactory.create(ou.getId());
     otherUserId = otherUser.getAccountId();
-    internalUser = new InternalUser(null);
+    internalUser = new InternalUser();
   }
 
   private void setTimeForTesting() {
@@ -199,15 +211,24 @@
     System.setProperty("user.timezone", systemTimeZone);
   }
 
-  protected Change newChange() throws Exception {
+  protected Change newChange(boolean workInProgress) throws Exception {
     Change c = TestChanges.newChange(project, changeOwner.getAccountId());
     ChangeUpdate u = newUpdate(c, changeOwner);
     u.setChangeId(c.getKey().get());
     u.setBranch(c.getDest().get());
+    u.setWorkInProgress(workInProgress);
     u.commit();
     return c;
   }
 
+  protected Change newWorkInProgressChange() throws Exception {
+    return newChange(true);
+  }
+
+  protected Change newChange() throws Exception {
+    return newChange(false);
+  }
+
   protected ChangeUpdate newUpdate(Change c, CurrentUser user) throws Exception {
     ChangeUpdate update = TestChanges.newUpdate(injector, c, user);
     update.setPatchSetId(c.currentPatchSetId());
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
index 90e6800..80a8ab9 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeBundleTest.java
@@ -667,6 +667,39 @@
   }
 
   @Test
+  public void diffChangesAllowsCreatedToMatchLastUpdated() throws Exception {
+    Change c1 = TestChanges.newChange(new Project.NameKey("project"), new Account.Id(100));
+    c1.setCreatedOn(TimeUtil.nowTs());
+    assertThat(c1.getCreatedOn()).isGreaterThan(c1.getLastUpdatedOn());
+    Change c2 = clone(c1);
+    c2.setCreatedOn(c2.getLastUpdatedOn());
+
+    // Both ReviewDb.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for Change.Id "
+            + c1.getId()
+            + ": {2009-09-30 17:00:06.0} != {2009-09-30 17:00:00.0}");
+
+    // One NoteDb.
+    b1 =
+        new ChangeBundle(
+            c1, messages(), patchSets(), approvals(), comments(), reviewers(), REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c2, messages(), patchSets(), approvals(), comments(), reviewers(), NOTE_DB);
+    assertNoDiffs(b1, b2);
+    assertNoDiffs(b2, b1);
+  }
+
+  @Test
   public void diffChangeMessageKeySets() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     int id = c.getId().get();
@@ -1393,6 +1426,90 @@
   }
 
   @Test
+  public void diffPatchSetsAllowsFirstPatchSetCreatedOnToMatchChangeCreatedOn() {
+    Change c = TestChanges.newChange(project, accountId);
+    c.setLastUpdatedOn(TimeUtil.nowTs());
+
+    PatchSet goodPs1 = new PatchSet(new PatchSet.Id(c.getId(), 1));
+    goodPs1.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    goodPs1.setUploader(accountId);
+    goodPs1.setCreatedOn(TimeUtil.nowTs());
+    assertThat(goodPs1.getCreatedOn()).isGreaterThan(c.getCreatedOn());
+
+    PatchSet ps1AtCreatedOn = clone(goodPs1);
+    ps1AtCreatedOn.setCreatedOn(c.getCreatedOn());
+
+    PatchSet goodPs2 = new PatchSet(new PatchSet.Id(c.getId(), 2));
+    goodPs2.setRevision(new RevId("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"));
+    goodPs2.setUploader(accountId);
+    goodPs2.setCreatedOn(TimeUtil.nowTs());
+
+    PatchSet ps2AtCreatedOn = clone(goodPs2);
+    ps2AtCreatedOn.setCreatedOn(c.getCreatedOn());
+
+    // Both ReviewDb, exact match required.
+    ChangeBundle b1 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, goodPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    ChangeBundle b2 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(ps1AtCreatedOn, ps2AtCreatedOn),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for PatchSet.Id "
+            + c.getId()
+            + ",1: {2009-09-30 17:00:12.0} != {2009-09-30 17:00:00.0}",
+        "createdOn differs for PatchSet.Id "
+            + c.getId()
+            + ",2: {2009-09-30 17:00:18.0} != {2009-09-30 17:00:00.0}");
+
+    // One ReviewDb, PS1 is allowed to match change createdOn, but PS2 isn't.
+    b1 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(goodPs1, goodPs2),
+            approvals(),
+            comments(),
+            reviewers(),
+            REVIEW_DB);
+    b2 =
+        new ChangeBundle(
+            c,
+            messages(),
+            patchSets(ps1AtCreatedOn, ps2AtCreatedOn),
+            approvals(),
+            comments(),
+            reviewers(),
+            NOTE_DB);
+    assertDiffs(
+        b1,
+        b2,
+        "createdOn differs for PatchSet.Id "
+            + c.getId()
+            + ",2 in NoteDb vs. ReviewDb: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:18.0}");
+    assertDiffs(
+        b2,
+        b1,
+        "createdOn differs for PatchSet.Id "
+            + c.getId()
+            + ",2 in NoteDb vs. ReviewDb: {2009-09-30 17:00:00.0} != {2009-09-30 17:00:18.0}");
+  }
+
+  @Test
   public void diffPatchSetApprovalKeySets() throws Exception {
     Change c = TestChanges.newChange(project, accountId);
     int id = c.getId().get();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
index 39c4c08..5fa7a30 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesParserTest.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.notedb;
 
+import static com.google.common.truth.Truth.assertThat;
 import static org.junit.Assert.fail;
 
 import com.google.gerrit.common.TimeUtil;
@@ -327,7 +328,7 @@
             + "Branch: refs/heads/master\n"
             + "Change-id: I577fb248e474018276351785930358ec0450e9f7\n"
             + "Subject: Some subject of a change\n");
-    assertParseSucceeds(
+    assertParseFails(
         "Update change\n"
             + "\n"
             + "Patch-set: 1 (DRAFT)\n"
@@ -437,6 +438,45 @@
   }
 
   @Test
+  public void parseWorkInProgress() throws Exception {
+    // Change created in WIP remains in WIP.
+    RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
+    ChangeNotesState state = newParser(commit).parseAll();
+    assertThat(state.hasReviewStarted()).isFalse();
+
+    // Moving change out of WIP starts review.
+    commit =
+        writeCommit("New ready change\n" + "\n" + "Patch-set: 1\n" + "Work-in-progress: false\n");
+    state = newParser(commit).parseAll();
+    assertThat(state.hasReviewStarted()).isTrue();
+
+    // Change created not in WIP has always been in review started state.
+    state = assertParseSucceeds("New change that doesn't declare WIP\n" + "\n" + "Patch-set: 1\n");
+    assertThat(state.hasReviewStarted()).isTrue();
+  }
+
+  @Test
+  public void pendingReviewers() throws Exception {
+    // Change created in WIP.
+    RevCommit commit = writeCommit("Update WIP change\n" + "\n" + "Patch-set: 1\n", true);
+    ChangeNotesState state = newParser(commit).parseAll();
+    assertThat(state.pendingReviewers().all()).isEmpty();
+    assertThat(state.pendingReviewersByEmail().all()).isEmpty();
+
+    // Reviewers added while in WIP.
+    commit =
+        writeCommit(
+            "Add reviewers\n"
+                + "\n"
+                + "Patch-set: 1\n"
+                + "Reviewer: Change Owner "
+                + "<1@gerrit>\n",
+            true);
+    state = newParser(commit).parseAll();
+    assertThat(state.pendingReviewers().byState(ReviewerStateInternal.REVIEWER)).isNotEmpty();
+  }
+
+  @Test
   public void caseInsensitiveFooters() throws Exception {
     assertParseSucceeds(
         "Update change\n"
@@ -460,11 +500,26 @@
     return writeCommit(
         body,
         noteUtil.newIdent(
-            changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent, "Anonymous Coward"));
+            changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent, "Anonymous Coward"),
+        false);
   }
 
   private RevCommit writeCommit(String body, PersonIdent author) throws Exception {
-    Change change = newChange();
+    return writeCommit(body, author, false);
+  }
+
+  private RevCommit writeCommit(String body, boolean initWorkInProgress) throws Exception {
+    ChangeNoteUtil noteUtil = injector.getInstance(ChangeNoteUtil.class);
+    return writeCommit(
+        body,
+        noteUtil.newIdent(
+            changeOwner.getAccount(), TimeUtil.nowTs(), serverIdent, "Anonymous Coward"),
+        initWorkInProgress);
+  }
+
+  private RevCommit writeCommit(String body, PersonIdent author, boolean initWorkInProgress)
+      throws Exception {
+    Change change = newChange(initWorkInProgress);
     ChangeNotes notes = newNotes(change).load();
     try (ObjectInserter ins = testRepo.getRepository().newObjectInserter()) {
       CommitBuilder cb = new CommitBuilder();
@@ -481,12 +536,12 @@
     }
   }
 
-  private void assertParseSucceeds(String body) throws Exception {
-    assertParseSucceeds(writeCommit(body));
+  private ChangeNotesState assertParseSucceeds(String body) throws Exception {
+    return assertParseSucceeds(writeCommit(body));
   }
 
-  private void assertParseSucceeds(RevCommit commit) throws Exception {
-    newParser(commit).parseAll();
+  private ChangeNotesState assertParseSucceeds(RevCommit commit) throws Exception {
+    return newParser(commit).parseAll();
   }
 
   private void assertParseFails(String body) throws Exception {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 9d6cb60..0daaee8 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
+import static com.google.gerrit.server.notedb.ReviewerStateInternal.REMOVED;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
@@ -49,6 +50,7 @@
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.notedb.ChangeNotesCommit.ChangeNotesRevWalk;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.TestChanges;
@@ -115,7 +117,7 @@
   }
 
   @Test
-  public void tagInlineCommenrts() throws Exception {
+  public void tagInlineComments() throws Exception {
     String tag = "jenkins";
     Change c = newChange();
     RevCommit commit = tr.commit().message("PS2").create();
@@ -751,7 +753,7 @@
     try (RevWalk walk = new RevWalk(repo)) {
       RevCommit commit = walk.parseCommit(update.getResult());
       walk.parseBody(commit);
-      assertThat(commit.getFullMessage()).endsWith("Hashtags: tag1,tag2\n");
+      assertThat(commit.getFullMessage()).contains("Hashtags: tag1,tag2\n");
     }
   }
 
@@ -1054,7 +1056,6 @@
     RevCommit commit = tr.commit().message("PS2").create();
     ChangeUpdate update = newUpdate(c, changeOwner);
     update.setCommit(rw, commit);
-    update.setPatchSetState(PatchSetState.DRAFT);
     update.putApproval("Code-Review", (short) 1);
     update.setChangeMessage("This is a message");
     update.putComment(
@@ -1075,7 +1076,6 @@
     update.commit();
 
     ChangeNotes notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId2).isDraft()).isTrue();
     assertThat(notes.getPatchSets().keySet()).containsExactly(psId1, psId2);
     assertThat(notes.getApprovals()).isNotEmpty();
     assertThat(notes.getChangeMessagesByPatchSet()).isNotEmpty();
@@ -1087,9 +1087,6 @@
     update.setPatchSetState(PatchSetState.PUBLISHED);
     update.commit();
 
-    notes = newNotes(c);
-    assertThat(notes.getPatchSets().get(psId2).isDraft()).isFalse();
-
     // delete ps2
     update = newUpdate(c, changeOwner);
     update.setPatchSetState(PatchSetState.DELETED);
@@ -3265,6 +3262,270 @@
     assertThat(notes.getReadOnlyUntil()).isEqualTo(new Timestamp(0));
   }
 
+  @Test
+  public void privateDefault() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isFalse();
+  }
+
+  @Test
+  public void privateSetPrivate() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPrivate(true);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isTrue();
+  }
+
+  @Test
+  public void privateSetPrivateMultipleTimes() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setPrivate(true);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.setPrivate(false);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.isPrivate()).isFalse();
+  }
+
+  @Test
+  public void defaultReviewersByEmailIsEmpty() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).isEmpty();
+  }
+
+  @Test
+  public void putReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void putAndRemoveReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewerByEmail(adr);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).isEmpty();
+  }
+
+  @Test
+  public void putRemoveAndAddBackReviewerByEmail() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewerByEmail(adr);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void putReviewerByEmailAndCcByEmail() throws Exception {
+    Address adrReviewer = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+    Address adrCc = new Address("Foo Bor", "foo.bar.2@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adrReviewer, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adrCc, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER))
+        .containsExactly(adrReviewer);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC))
+        .containsExactly(adrCc);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adrReviewer, adrCc);
+  }
+
+  @Test
+  public void putReviewerByEmailAndChangeToCc() throws Exception {
+    Address adr = new Address("Foo Bar", "foo.bar@gerritcodereview.com");
+
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(adr, ReviewerStateInternal.CC);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.REVIEWER)).isEmpty();
+    assertThat(notes.getReviewersByEmail().byState(ReviewerStateInternal.CC)).containsExactly(adr);
+    assertThat(notes.getReviewersByEmail().all()).containsExactly(adr);
+  }
+
+  @Test
+  public void hasReviewStarted() throws Exception {
+    ChangeNotes notes = newNotes(newChange());
+    assertThat(notes.hasReviewStarted()).isTrue();
+
+    notes = newNotes(newWorkInProgressChange());
+    assertThat(notes.hasReviewStarted()).isFalse();
+
+    Change c = newWorkInProgressChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.hasReviewStarted()).isFalse();
+
+    update = newUpdate(c, changeOwner);
+    update.setWorkInProgress(true);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.hasReviewStarted()).isFalse();
+
+    update = newUpdate(c, changeOwner);
+    update.setWorkInProgress(false);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.hasReviewStarted()).isTrue();
+
+    // Once review is started, setting WIP should have no impact.
+    c = newChange();
+    notes = newNotes(c);
+    assertThat(notes.hasReviewStarted()).isTrue();
+    update = newUpdate(c, changeOwner);
+    update.setWorkInProgress(true);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.hasReviewStarted()).isTrue();
+  }
+
+  @Test
+  public void pendingReviewers() throws Exception {
+    Address adr1 = new Address("Foo Bar1", "foo.bar1@gerritcodereview.com");
+    Address adr2 = new Address("Foo Bar2", "foo.bar2@gerritcodereview.com");
+    Account.Id ownerId = changeOwner.getAccount().getId();
+    Account.Id otherUserId = otherUser.getAccount().getId();
+
+    ChangeNotes notes = newNotes(newChange());
+    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
+
+    Change c = newWorkInProgressChange();
+    notes = newNotes(c);
+    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
+
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewer(ownerId, REVIEWER);
+    update.putReviewer(otherUserId, CC);
+    update.putReviewerByEmail(adr1, REVIEWER);
+    update.putReviewerByEmail(adr2, CC);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPendingReviewers().byState(REVIEWER)).containsExactly(ownerId);
+    assertThat(notes.getPendingReviewers().byState(CC)).containsExactly(otherUserId);
+    assertThat(notes.getPendingReviewers().byState(REMOVED)).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().byState(REVIEWER)).containsExactly(adr1);
+    assertThat(notes.getPendingReviewersByEmail().byState(CC)).containsExactly(adr2);
+    assertThat(notes.getPendingReviewersByEmail().byState(REMOVED)).isEmpty();
+
+    update = newUpdate(c, changeOwner);
+    update.removeReviewer(ownerId);
+    update.removeReviewerByEmail(adr1);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPendingReviewers().byState(REVIEWER)).isEmpty();
+    assertThat(notes.getPendingReviewers().byState(CC)).containsExactly(otherUserId);
+    assertThat(notes.getPendingReviewers().byState(REMOVED)).containsExactly(ownerId);
+    assertThat(notes.getPendingReviewersByEmail().byState(REVIEWER)).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().byState(CC)).containsExactly(adr2);
+    assertThat(notes.getPendingReviewersByEmail().byState(REMOVED)).containsExactly(adr1);
+
+    update = newUpdate(c, changeOwner);
+    update.setWorkInProgress(false);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
+
+    update = newUpdate(c, changeOwner);
+    update.putReviewer(ownerId, REVIEWER);
+    update.putReviewerByEmail(adr1, REVIEWER);
+    update.commit();
+    notes = newNotes(c);
+    assertThat(notes.getPendingReviewers().asTable()).isEmpty();
+    assertThat(notes.getPendingReviewersByEmail().asTable()).isEmpty();
+  }
+
+  @Test
+  public void revertOfIsNullByDefault() throws Exception {
+    Change c = newChange();
+    ChangeNotes notes = newNotes(c);
+    assertThat(notes.getRevertOf()).isNull();
+  }
+
+  @Test
+  public void setRevertOfPersistsValue() throws Exception {
+    Change changeToRevert = newChange();
+    Change c = TestChanges.newChange(project, changeOwner.getAccountId());
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setChangeId(c.getKey().get());
+    update.setRevertOf(changeToRevert.getId().get());
+    update.commit();
+    assertThat(newNotes(c).getRevertOf()).isEqualTo(changeToRevert.getId());
+  }
+
+  @Test
+  public void setRevertOfToCurrentChangeFails() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage("A change cannot revert itself");
+    update.setRevertOf(c.getId().get());
+  }
+
+  @Test
+  public void setRevertOfOnChildCommitFails() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.setRevertOf(newChange().getId().get());
+    exception.expect(OrmException.class);
+    exception.expectMessage("Given ChangeUpdate is only allowed on initial commit");
+    update.commit();
+  }
+
   private boolean testJson() {
     return noteUtil.getWriteJson();
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 25b5168..83dcf61 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.mail.Address;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestChanges;
@@ -382,6 +383,32 @@
     assertBodyEquals("Update patch set 1\n\nPatch-set: 1\nCurrent: true\n", update.getResult());
   }
 
+  @Test
+  public void reviewerByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(
+        new Address("John Doe", "j.doe@gerritcodereview.com"), ReviewerStateInternal.REVIEWER);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\n"
+            + "Reviewer-email: John Doe <j.doe@gerritcodereview.com>\n",
+        update.getResult());
+  }
+
+  @Test
+  public void ccByEmail() throws Exception {
+    Change c = newChange();
+    ChangeUpdate update = newUpdate(c, changeOwner);
+    update.putReviewerByEmail(new Address("j.doe@gerritcodereview.com"), ReviewerStateInternal.CC);
+    update.commit();
+
+    assertBodyEquals(
+        "Update patch set 1\n\nPatch-set: 1\nCC-email: j.doe@gerritcodereview.com\n",
+        update.getResult());
+  }
+
   private RevCommit parseCommit(ObjectId id) throws Exception {
     if (id instanceof RevCommit) {
       return (RevCommit) id;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
index 0553dc5..67ad65c 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/NoteDbChangeStateTest.java
@@ -16,6 +16,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.gerrit.common.TimeUtil.nowTs;
 import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.NOTE_DB;
 import static com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage.REVIEW_DB;
@@ -63,7 +64,7 @@
     assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
     assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
     assertThat(state.getDraftIds()).isEmpty();
-    assertThat(state.getReadOnlyUntil().isPresent()).isFalse();
+    assertThat(state.getReadOnlyUntil()).isEmpty();
     assertThat(state.toString()).isEqualTo(SHA1.name());
 
     state = parse(new Change.Id(1), "R," + SHA1.name());
@@ -71,7 +72,7 @@
     assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
     assertThat(state.getChangeMetaId()).isEqualTo(SHA1);
     assertThat(state.getDraftIds()).isEmpty();
-    assertThat(state.getReadOnlyUntil().isPresent()).isFalse();
+    assertThat(state.getReadOnlyUntil()).isEmpty();
     assertThat(state.toString()).isEqualTo(SHA1.name());
   }
 
@@ -87,7 +88,7 @@
         .containsExactly(
             new Account.Id(1001), SHA3,
             new Account.Id(2003), SHA2);
-    assertThat(state.getReadOnlyUntil().isPresent()).isFalse();
+    assertThat(state.getReadOnlyUntil()).isEmpty();
     assertThat(state.toString()).isEqualTo(expected);
 
     state = parse(new Change.Id(1), "R," + str);
@@ -98,7 +99,7 @@
         .containsExactly(
             new Account.Id(1001), SHA3,
             new Account.Id(2003), SHA2);
-    assertThat(state.getReadOnlyUntil().isPresent()).isFalse();
+    assertThat(state.getReadOnlyUntil()).isEmpty();
     assertThat(state.toString()).isEqualTo(expected);
   }
 
@@ -117,7 +118,7 @@
     state = parse(new Change.Id(1), str);
     assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
     assertThat(state.getChangeId()).isEqualTo(new Change.Id(1));
-    assertThat(state.getRefState().isPresent()).isFalse();
+    assertThat(state.getRefState()).isEmpty();
     assertThat(state.getReadOnlyUntil().get()).isEqualTo(ts);
     assertThat(state.toString()).isEqualTo(str);
   }
@@ -194,8 +195,8 @@
   public void parseNoteDbPrimary() {
     NoteDbChangeState state = parse(new Change.Id(1), "N");
     assertThat(state.getPrimaryStorage()).isEqualTo(NOTE_DB);
-    assertThat(state.getRefState().isPresent()).isFalse();
-    assertThat(state.getReadOnlyUntil().isPresent()).isFalse();
+    assertThat(state.getRefState()).isEmpty();
+    assertThat(state.getReadOnlyUntil()).isEmpty();
   }
 
   @Test(expected = IllegalArgumentException.class)
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
index df3e405..76be4569 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/RepoSequenceTest.java
@@ -19,13 +19,13 @@
 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.util.concurrent.Runnables;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gwtorm.server.OrmException;
 import java.io.IOException;
@@ -45,16 +45,9 @@
 import org.junit.rules.ExpectedException;
 
 public class RepoSequenceTest {
+  // Don't sleep in tests.
   private static final Retryer<RefUpdate.Result> RETRYER =
-      RepoSequence.retryerBuilder()
-          .withBlockStrategy(
-              new BlockStrategy() {
-                @Override
-                public void block(long sleepTime) {
-                  // Don't sleep in tests.
-                }
-              })
-          .build();
+      RepoSequence.retryerBuilder().withBlockStrategy(t -> {}).build();
 
   @Rule public ExpectedException exception = ExpectedException.none();
 
@@ -159,14 +152,11 @@
     // Seed existing ref value.
     writeBlob("id", "1");
 
-    final AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
+    AtomicBoolean doneBgUpdate = new AtomicBoolean(false);
     Runnable bgUpdate =
-        new Runnable() {
-          @Override
-          public void run() {
-            if (!doneBgUpdate.getAndSet(true)) {
-              writeBlob("id", "1234");
-            }
+        () -> {
+          if (!doneBgUpdate.getAndSet(true)) {
+            writeBlob("id", "1234");
           }
         };
 
@@ -203,20 +193,13 @@
 
   @Test
   public void failAfterRetryerGivesUp() throws Exception {
-    final AtomicInteger bgCounter = new AtomicInteger(1234);
-    Runnable bgUpdate =
-        new Runnable() {
-          @Override
-          public void run() {
-            writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000)));
-          }
-        };
+    AtomicInteger bgCounter = new AtomicInteger(1234);
     RepoSequence s =
         newSequence(
             "id",
             1,
             10,
-            bgUpdate,
+            () -> writeBlob("id", Integer.toString(bgCounter.getAndAdd(1000))),
             RetryerBuilder.<RefUpdate.Result>newBuilder()
                 .withStopStrategy(StopStrategies.stopAfterAttempt(3))
                 .build());
@@ -283,14 +266,10 @@
       Retryer<RefUpdate.Result> retryer) {
     return new RepoSequence(
         repoManager,
+        GitReferenceUpdated.DISABLED,
         project,
         name,
-        new RepoSequence.Seed() {
-          @Override
-          public int get() {
-            return start;
-          }
-        },
+        () -> start,
         batchSize,
         afterReadRef,
         retryer);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java
index 4411799..ef80d7e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/patch/IntraLineLoaderTest.java
@@ -17,6 +17,8 @@
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
 import java.util.List;
 import org.eclipse.jgit.diff.Edit;
 import org.eclipse.jgit.diff.EditList;
@@ -148,8 +150,8 @@
     Text aText = new Text(a.getBytes(UTF_8));
     Text bText = new Text(b.getBytes(UTF_8));
 
-    IntraLineDiff diff;
-    diff = IntraLineLoader.compute(aText, bText, EditList.singleton(lines));
+    IntraLineDiff diff =
+        IntraLineLoader.compute(aText, bText, ImmutableList.of(lines), ImmutableSet.of());
 
     assertThat(diff.getStatus()).isEqualTo(IntraLineDiff.Status.EDIT_LIST);
     List<Edit> actualEdits = diff.getEdits();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java
index 19adf32..0a7b97cc 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/patch/PatchListTest.java
@@ -17,6 +17,11 @@
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.gerrit.reviewdb.client.Patch;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
 import java.util.Arrays;
 import java.util.Comparator;
 import org.junit.Test;
@@ -65,4 +70,21 @@
         });
     assertThat(names).isEqualTo(want);
   }
+
+  @Test
+  public void largeObjectTombstoneCanBeSerializedAndDeserialized() throws Exception {
+    // Serialize
+    byte[] serializedObject;
+    try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        ObjectOutputStream objectStream = new ObjectOutputStream(baos)) {
+      objectStream.writeObject(new PatchListCacheImpl.LargeObjectTombstone());
+      serializedObject = baos.toByteArray();
+      assertThat(serializedObject).isNotNull();
+    }
+    // Deserialize
+    try (InputStream is = new ByteArrayInputStream(serializedObject);
+        ObjectInputStream ois = new ObjectInputStream(is)) {
+      assertThat(ois.readObject()).isInstanceOf(PatchListCacheImpl.LargeObjectTombstone.class);
+    }
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/CommitsCollectionTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/CommitsCollectionTest.java
new file mode 100644
index 0000000..7f1b233
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/CommitsCollectionTest.java
@@ -0,0 +1,266 @@
+// Copyright (C) 2014 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.project;
+
+import static com.google.gerrit.common.data.Permission.READ;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Permission;
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.GroupCache;
+import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.git.MetaDataUpdate;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gerrit.testutil.InMemoryDatabase;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Unit tests for {@link CommitsCollection}. */
+public class CommitsCollectionTest {
+  @Inject private AccountManager accountManager;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private InMemoryDatabase schemaFactory;
+  @Inject private InMemoryRepositoryManager repoManager;
+  @Inject private SchemaCreator schemaCreator;
+  @Inject private ThreadLocalRequestContext requestContext;
+  @Inject protected ProjectCache projectCache;
+  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected AllProjectsName allProjects;
+  @Inject protected GroupCache groupCache;
+  @Inject private CommitsCollection commits;
+
+  private LifecycleManager lifecycle;
+  private ReviewDb db;
+  private TestRepository<InMemoryRepository> repo;
+  private ProjectConfig project;
+  private IdentifiedUser user;
+  private AccountGroup.UUID admins;
+
+  @Before
+  public void setUp() throws Exception {
+    Injector injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    lifecycle = new LifecycleManager();
+    lifecycle.add(injector);
+    lifecycle.start();
+
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+    // Need to create at least one user to be admin before creating a "normal"
+    // registered user.
+    // See AccountManager#create().
+    accountManager.authenticate(AuthRequest.forUser("admin")).getAccountId();
+    admins = groupCache.get(new AccountGroup.NameKey("Administrators")).orElse(null).getGroupUUID();
+    setUpPermissions();
+
+    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
+    user = userFactory.create(userId);
+
+    Project.NameKey name = new Project.NameKey("project");
+    InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
+    project = new ProjectConfig(name);
+    project.load(inMemoryRepo);
+    repo = new TestRepository<>(inMemoryRepo);
+
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+  }
+
+  @After
+  public void tearDown() {
+    if (repo != null) {
+      repo.getRepository().close();
+    }
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    requestContext.setContext(null);
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(schemaFactory);
+  }
+
+  @Test
+  public void canReadCommitWhenAllRefsVisible() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/*");
+    ObjectId id = repo.branch("master").commit().create();
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id)));
+  }
+
+  @Test
+  public void canReadCommitIfTwoRefsVisible() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+
+    ObjectId id1 = repo.branch("branch1").commit().create();
+    ObjectId id2 = repo.branch("branch2").commit().create();
+
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id2)));
+  }
+
+  @Test
+  public void canReadCommitIfRefVisible() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+
+    ObjectId id1 = repo.branch("branch1").commit().create();
+    ObjectId id2 = repo.branch("branch2").commit().create();
+
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
+    assertFalse(commits.canRead(state, r, rw.parseCommit(id2)));
+  }
+
+  @Test
+  public void canReadCommitIfReachableFromVisibleRef() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
+
+    RevCommit parent1 = repo.commit().create();
+    repo.branch("branch1").commit().parent(parent1).create();
+
+    RevCommit parent2 = repo.commit().create();
+    repo.branch("branch2").commit().parent(parent2).create();
+
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
+    assertFalse(commits.canRead(state, r, rw.parseCommit(parent2)));
+  }
+
+  @Test
+  public void cannotReadAfterRollbackWithRestrictedRead() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
+
+    RevCommit parent1 = repo.commit().create();
+    ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
+
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
+
+    repo.branch("branch1").update(parent1);
+    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
+    assertFalse(commits.canRead(state, r, rw.parseCommit(id1)));
+  }
+
+  @Test
+  public void canReadAfterRollbackWithAllRefsVisible() throws Exception {
+    allow(project, READ, REGISTERED_USERS, "refs/*");
+
+    RevCommit parent1 = repo.commit().create();
+    ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
+
+    ProjectState state = readProjectState();
+    RevWalk rw = repo.getRevWalk();
+    Repository r = repo.getRepository();
+
+    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
+    assertTrue(commits.canRead(state, r, rw.parseCommit(id1)));
+
+    repo.branch("branch1").update(parent1);
+    assertTrue(commits.canRead(state, r, rw.parseCommit(parent1)));
+    assertFalse(commits.canRead(state, r, rw.parseCommit(id1)));
+  }
+
+  private ProjectState readProjectState() throws Exception {
+    return projectCache.get(project.getName());
+  }
+
+  protected void allow(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
+      throws Exception {
+    Util.allow(project, permission, id, ref);
+    saveProjectConfig(project);
+  }
+
+  protected void deny(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
+      throws Exception {
+    Util.deny(project, permission, id, ref);
+    saveProjectConfig(project);
+  }
+
+  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
+    try (MetaDataUpdate md = metaDataUpdateFactory.create(cfg.getName())) {
+      cfg.commit(md);
+    }
+    projectCache.evict(cfg.getProject());
+  }
+
+  private void setUpPermissions() throws Exception {
+    // Remove read permissions for all users besides admin, because by default
+    // Anonymous user group has ALLOW READ permission in refs/*.
+    // This method is idempotent, so is safe to call on every test setup.
+    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
+    for (AccessSection sec : pc.getAccessSections()) {
+      sec.removePermission(Permission.READ);
+    }
+    allow(pc, Permission.READ, admins, "refs/*");
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java
deleted file mode 100644
index 92d7a52..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/ProjectControlTest.java
+++ /dev/null
@@ -1,266 +0,0 @@
-// Copyright (C) 2014 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.project;
-
-import static com.google.gerrit.common.data.Permission.READ;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertTrue;
-
-import com.google.gerrit.common.data.AccessSection;
-import com.google.gerrit.common.data.Permission;
-import com.google.gerrit.lifecycle.LifecycleManager;
-import com.google.gerrit.reviewdb.client.Account;
-import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.AccountManager;
-import com.google.gerrit.server.account.AuthRequest;
-import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.config.AllProjectsName;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.schema.SchemaCreator;
-import com.google.gerrit.server.util.RequestContext;
-import com.google.gerrit.server.util.ThreadLocalRequestContext;
-import com.google.gerrit.testutil.InMemoryDatabase;
-import com.google.gerrit.testutil.InMemoryModule;
-import com.google.gerrit.testutil.InMemoryRepositoryManager;
-import com.google.inject.Guice;
-import com.google.inject.Inject;
-import com.google.inject.Injector;
-import com.google.inject.Provider;
-import com.google.inject.util.Providers;
-import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
-import org.eclipse.jgit.junit.TestRepository;
-import org.eclipse.jgit.lib.ObjectId;
-import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.revwalk.RevWalk;
-import org.junit.After;
-import org.junit.Before;
-import org.junit.Test;
-
-/** Unit tests for {@link ProjectControl}. */
-public class ProjectControlTest {
-  @Inject private AccountManager accountManager;
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-  @Inject private InMemoryDatabase schemaFactory;
-  @Inject private InMemoryRepositoryManager repoManager;
-  @Inject private ProjectControl.GenericFactory projectControlFactory;
-  @Inject private SchemaCreator schemaCreator;
-  @Inject private ThreadLocalRequestContext requestContext;
-  @Inject protected ProjectCache projectCache;
-  @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
-  @Inject protected AllProjectsName allProjects;
-  @Inject protected GroupCache groupCache;
-
-  private LifecycleManager lifecycle;
-  private ReviewDb db;
-  private TestRepository<InMemoryRepository> repo;
-  private ProjectConfig project;
-  private IdentifiedUser user;
-  private AccountGroup.UUID admins;
-
-  @Before
-  public void setUp() throws Exception {
-    Injector injector = Guice.createInjector(new InMemoryModule());
-    injector.injectMembers(this);
-    lifecycle = new LifecycleManager();
-    lifecycle.add(injector);
-    lifecycle.start();
-
-    db = schemaFactory.open();
-    schemaCreator.create(db);
-    // Need to create at least one user to be admin before creating a "normal"
-    // registered user.
-    // See AccountManager#create().
-    accountManager.authenticate(AuthRequest.forUser("admin")).getAccountId();
-    admins = groupCache.get(new AccountGroup.NameKey("Administrators")).getGroupUUID();
-    setUpPermissions();
-
-    Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
-    user = userFactory.create(userId);
-
-    Project.NameKey name = new Project.NameKey("project");
-    InMemoryRepository inMemoryRepo = repoManager.createRepository(name);
-    project = new ProjectConfig(name);
-    project.load(inMemoryRepo);
-    repo = new TestRepository<>(inMemoryRepo);
-
-    requestContext.setContext(
-        new RequestContext() {
-          @Override
-          public CurrentUser getUser() {
-            return user;
-          }
-
-          @Override
-          public Provider<ReviewDb> getReviewDbProvider() {
-            return Providers.of(db);
-          }
-        });
-  }
-
-  @After
-  public void tearDown() {
-    if (repo != null) {
-      repo.getRepository().close();
-    }
-    if (lifecycle != null) {
-      lifecycle.stop();
-    }
-    requestContext.setContext(null);
-    if (db != null) {
-      db.close();
-    }
-    InMemoryDatabase.drop(schemaFactory);
-  }
-
-  @Test
-  public void canReadCommitWhenAllRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/*");
-    ObjectId id = repo.branch("master").commit().create();
-    ProjectControl pc = newProjectControl();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id)));
-  }
-
-  @Test
-  public void canReadCommitIfTwoRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch2");
-
-    ObjectId id1 = repo.branch("branch1").commit().create();
-    ObjectId id2 = repo.branch("branch2").commit().create();
-
-    ProjectControl pc = newProjectControl();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id1)));
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id2)));
-  }
-
-  @Test
-  public void canReadCommitIfRefVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
-
-    ObjectId id1 = repo.branch("branch1").commit().create();
-    ObjectId id2 = repo.branch("branch2").commit().create();
-
-    ProjectControl pc = newProjectControl();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id1)));
-    assertFalse(pc.canReadCommit(db, r, rw.parseCommit(id2)));
-  }
-
-  @Test
-  public void canReadCommitIfReachableFromVisibleRef() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-    deny(project, READ, REGISTERED_USERS, "refs/heads/branch2");
-
-    RevCommit parent1 = repo.commit().create();
-    repo.branch("branch1").commit().parent(parent1).create();
-
-    RevCommit parent2 = repo.commit().create();
-    repo.branch("branch2").commit().parent(parent2).create();
-
-    ProjectControl pc = newProjectControl();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(parent1)));
-    assertFalse(pc.canReadCommit(db, r, rw.parseCommit(parent2)));
-  }
-
-  @Test
-  public void cannotReadAfterRollbackWithRestrictedRead() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/heads/branch1");
-
-    RevCommit parent1 = repo.commit().create();
-    ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
-
-    ProjectControl pc = newProjectControl();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(parent1)));
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id1)));
-
-    repo.branch("branch1").update(parent1);
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(parent1)));
-    assertFalse(pc.canReadCommit(db, r, rw.parseCommit(id1)));
-  }
-
-  @Test
-  public void canReadAfterRollbackWithAllRefsVisible() throws Exception {
-    allow(project, READ, REGISTERED_USERS, "refs/*");
-
-    RevCommit parent1 = repo.commit().create();
-    ObjectId id1 = repo.branch("branch1").commit().parent(parent1).create();
-
-    ProjectControl pc = newProjectControl();
-    RevWalk rw = repo.getRevWalk();
-    Repository r = repo.getRepository();
-
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(parent1)));
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(id1)));
-
-    repo.branch("branch1").update(parent1);
-    assertTrue(pc.canReadCommit(db, r, rw.parseCommit(parent1)));
-    assertFalse(pc.canReadCommit(db, r, rw.parseCommit(id1)));
-  }
-
-  private ProjectControl newProjectControl() throws Exception {
-    return projectControlFactory.controlFor(project.getName(), user);
-  }
-
-  protected void allow(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
-    Util.allow(project, permission, id, ref);
-    saveProjectConfig(project);
-  }
-
-  protected void deny(ProjectConfig project, String permission, AccountGroup.UUID id, String ref)
-      throws Exception {
-    Util.deny(project, permission, id, ref);
-    saveProjectConfig(project);
-  }
-
-  protected void saveProjectConfig(ProjectConfig cfg) throws Exception {
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(cfg.getName())) {
-      cfg.commit(md);
-    }
-    projectCache.evict(cfg.getProject());
-  }
-
-  private void setUpPermissions() throws Exception {
-    // Remove read permissions for all users besides admin, because by default
-    // Anonymous user group has ALLOW READ permission in refs/*.
-    // This method is idempotent, so is safe to call on every test setup.
-    ProjectConfig pc = projectCache.checkedGet(allProjects).getConfig();
-    for (AccessSection sec : pc.getAccessSections()) {
-      sec.removePermission(Permission.READ);
-    }
-    allow(pc, Permission.READ, admins, "refs/*");
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
index 4f2284c..1fc95c1 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/RefControlTest.java
@@ -48,7 +48,6 @@
 import com.google.gerrit.rules.RulesCache;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.account.CapabilityCollection;
-import com.google.gerrit.server.account.CapabilityControl;
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.gerrit.server.account.ListGroupMembership;
 import com.google.gerrit.server.config.AllProjectsName;
@@ -59,7 +58,9 @@
 import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.index.SingleVersionModule.SingleVersionListener;
-import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.ProjectPermission;
+import com.google.gerrit.server.permissions.RefPermission;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
@@ -102,20 +103,18 @@
     assertThat(u.isOwner()).named("not owner").isFalse();
   }
 
-  private void assertOwnerAnyRef(ProjectControl u) {
-    assertThat(u.isOwnerAnyRef()).named("owns ref").isTrue();
-  }
-
   private void assertNotOwner(String ref, ProjectControl u) {
     assertThat(u.controlForRef(ref).isOwner()).named("NOT OWN " + ref).isFalse();
   }
 
-  private void assertCanRead(ProjectControl u) {
-    assertThat(u.isVisible()).named("can read").isTrue();
+  private void assertCanAccess(ProjectControl u) {
+    boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
+    assertThat(access).named("can access").isTrue();
   }
 
-  private void assertCannotRead(ProjectControl u) {
-    assertThat(u.isVisible()).named("cannot read").isFalse();
+  private void assertAccessDenied(ProjectControl u) {
+    boolean access = u.asForProject().testOrFalse(ProjectPermission.ACCESS);
+    assertThat(access).named("cannot access").isFalse();
   }
 
   private void assertCanRead(String ref, ProjectControl u) {
@@ -138,16 +137,18 @@
     assertThat(u.canPushToAtLeastOneRef()).named("can upload").isEqualTo(Capable.OK);
   }
 
-  private void assertCanUpload(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpload()).named("can upload " + ref).isTrue();
+  private void assertCreateChange(String ref, ProjectControl u) {
+    boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
+    assertThat(create).named("can create change " + ref).isTrue();
   }
 
   private void assertCannotUpload(ProjectControl u) {
     assertThat(u.canPushToAtLeastOneRef()).named("cannot upload").isNotEqualTo(Capable.OK);
   }
 
-  private void assertCannotUpload(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpload()).named("cannot upload " + ref).isFalse();
+  private void assertCannotCreateChange(String ref, ProjectControl u) {
+    boolean create = u.asForProject().ref(ref).testOrFalse(RefPermission.CREATE_CHANGE);
+    assertThat(create).named("cannot create change " + ref).isFalse();
   }
 
   private void assertBlocked(String p, String ref, ProjectControl u) {
@@ -159,19 +160,23 @@
   }
 
   private void assertCanUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpdate()).named("can update " + ref).isTrue();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
+    assertThat(update).named("can update " + ref).isTrue();
   }
 
   private void assertCannotUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canUpdate()).named("cannot update " + ref).isFalse();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.UPDATE);
+    assertThat(update).named("cannot update " + ref).isFalse();
   }
 
   private void assertCanForceUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canForceUpdate()).named("can force push " + ref).isTrue();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
+    assertThat(update).named("can force push " + ref).isTrue();
   }
 
   private void assertCannotForceUpdate(String ref, ProjectControl u) {
-    assertThat(u.controlForRef(ref).canForceUpdate()).named("cannot force push " + ref).isFalse();
+    boolean update = u.asForProject().ref(ref).testOrFalse(RefPermission.FORCE_UPDATE);
+    assertThat(update).named("cannot force push " + ref).isFalse();
   }
 
   private void assertCanVote(int score, PermissionRange range) {
@@ -197,14 +202,12 @@
   private ChangeControl.Factory changeControlFactory;
   private ReviewDb db;
 
+  @Inject private PermissionBackend permissionBackend;
   @Inject private CapabilityCollection.Factory capabilityCollectionFactory;
-  @Inject private CapabilityControl.Factory capabilityControlFactory;
   @Inject private SchemaCreator schemaCreator;
   @Inject private SingleVersionListener singleVersionListener;
   @Inject private InMemoryDatabase schemaFactory;
   @Inject private ThreadLocalRequestContext requestContext;
-  @Inject private Provider<InternalChangeQuery> queryProvider;
-  @Inject private ProjectControl.Metrics metrics;
   @Inject private TransferConfig transferConfig;
 
   @Before
@@ -261,6 +264,12 @@
 
           @Override
           public void evict(Project.NameKey p) {}
+
+          @Override
+          public ProjectState checkedGet(Project.NameKey projectName, boolean strict)
+              throws Exception {
+            return all.get(projectName);
+          }
         };
 
     Injector injector = Guice.createInjector(new InMemoryModule());
@@ -353,7 +362,6 @@
 
     ProjectControl uDev = user(local, DEVS);
     assertNotOwner(uDev);
-    assertOwnerAnyRef(uDev);
 
     assertOwner("refs/heads/x/*", uDev);
     assertOwner("refs/heads/x/y", uDev);
@@ -372,7 +380,6 @@
 
     ProjectControl uDev = user(local, DEVS);
     assertNotOwner(uDev);
-    assertOwnerAnyRef(uDev);
 
     assertOwner("refs/heads/x/*", uDev);
     assertOwner("refs/heads/x/y", uDev);
@@ -382,7 +389,6 @@
 
     ProjectControl uFix = user(local, fixers);
     assertNotOwner(uFix);
-    assertOwnerAnyRef(uFix);
 
     assertOwner("refs/heads/x/y/*", uFix);
     assertOwner("refs/heads/x/y/bar", uFix);
@@ -402,8 +408,8 @@
 
     ProjectControl u = user(local);
     assertCanUpload(u);
-    assertCanUpload("refs/heads/master", u);
-    assertCannotUpload("refs/heads/foobar", u);
+    assertCreateChange("refs/heads/master", u);
+    assertCannotCreateChange("refs/heads/foobar", u);
   }
 
   @Test
@@ -412,7 +418,7 @@
     block(parent, PUSH, ANONYMOUS_USERS, "refs/drafts/*");
 
     ProjectControl u = user(local);
-    assertCanUpload("refs/heads/master", u);
+    assertCreateChange("refs/heads/master", u);
     assertBlocked(PUSH, "refs/drafts/refs/heads/master", u);
   }
 
@@ -435,21 +441,21 @@
 
     ProjectControl u = user(local);
     assertCanUpload(u);
-    assertCanUpload("refs/heads/master", u);
-    assertCanUpload("refs/heads/foobar", u);
+    assertCreateChange("refs/heads/master", u);
+    assertCreateChange("refs/heads/foobar", u);
   }
 
   @Test
   public void inheritDuplicateSections() throws Exception {
     allow(parent, READ, ADMIN, "refs/*");
     allow(local, READ, DEVS, "refs/heads/*");
-    assertCanRead(user(local, "a", ADMIN));
+    assertCanAccess(user(local, "a", ADMIN));
 
     local = new ProjectConfig(localKey);
     local.load(newRepository(localKey));
     local.getProject().setParentName(parentKey);
     allow(local, READ, DEVS, "refs/*");
-    assertCanRead(user(local, "d", DEVS));
+    assertCanAccess(user(local, "d", DEVS));
   }
 
   @Test
@@ -457,7 +463,7 @@
     allow(parent, READ, REGISTERED_USERS, "refs/*");
     deny(local, READ, REGISTERED_USERS, "refs/*");
 
-    assertCannotRead(user(local));
+    assertAccessDenied(user(local));
   }
 
   @Test
@@ -466,7 +472,7 @@
     deny(local, READ, REGISTERED_USERS, "refs/heads/*");
 
     ProjectControl u = user(local);
-    assertCanRead(u);
+    assertCanAccess(u);
     assertCanRead("refs/master", u);
     assertCanRead("refs/tags/foobar", u);
     assertCanRead("refs/heads/master", u);
@@ -479,7 +485,7 @@
     allow(local, READ, REGISTERED_USERS, "refs/heads/*");
 
     ProjectControl u = user(local);
-    assertCanRead(u);
+    assertCanAccess(u);
     assertCannotRead("refs/foobar", u);
     assertCannotRead("refs/tags/foobar", u);
     assertCanRead("refs/heads/foobar", u);
@@ -505,7 +511,7 @@
 
     ProjectControl u = user(local);
     assertCannotUpload(u);
-    assertCannotUpload("refs/heads/master", u);
+    assertCannotCreateChange("refs/heads/master", u);
   }
 
   @Test
@@ -717,28 +723,6 @@
   }
 
   @Test
-  public void unblockVisibilityByRegisteredUsers() {
-    block(local, READ, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/*");
-
-    ProjectControl u = user(local, REGISTERED_USERS);
-    assertThat(u.controlForRef("refs/heads/master").isVisibleByRegisteredUsers())
-        .named("u can read")
-        .isTrue();
-  }
-
-  @Test
-  public void unblockInLocalVisibilityByRegisteredUsers_Fails() {
-    block(parent, READ, ANONYMOUS_USERS, "refs/heads/*");
-    allow(local, READ, REGISTERED_USERS, "refs/heads/*");
-
-    ProjectControl u = user(local, REGISTERED_USERS);
-    assertThat(u.controlForRef("refs/heads/master").isVisibleByRegisteredUsers())
-        .named("u can't read")
-        .isFalse();
-  }
-
-  @Test
   public void unblockForceEditTopicName() {
     block(local, EDIT_TOPIC_NAME, ANONYMOUS_USERS, "refs/heads/*");
     allow(local, EDIT_TOPIC_NAME, DEVS, "refs/heads/*").setForce(true);
@@ -895,22 +879,15 @@
   }
 
   private ProjectControl user(ProjectConfig local, String name, AccountGroup.UUID... memberOf) {
-    String canonicalWebUrl = "http://localhost";
-
     return new ProjectControl(
         Collections.<AccountGroup.UUID>emptySet(),
         Collections.<AccountGroup.UUID>emptySet(),
-        projectCache,
         sectionSorter,
-        null,
+        null, // commitsCollection
         changeControlFactory,
-        null,
-        queryProvider,
-        null,
-        canonicalWebUrl,
+        permissionBackend,
         new MockUser(name, memberOf),
-        newProjectState(local),
-        metrics);
+        newProjectState(local));
   }
 
   private ProjectState newProjectState(ProjectConfig local) {
@@ -918,12 +895,11 @@
     return all.get(local.getProject().getNameKey());
   }
 
-  private class MockUser extends CurrentUser {
+  private static class MockUser extends CurrentUser {
     private final String username;
     private final GroupMembership groups;
 
     MockUser(String name, AccountGroup.UUID[] groupId) {
-      super(capabilityControlFactory);
       username = name;
       ArrayList<AccountGroup.UUID> groupIds = Lists.newArrayList(groupId);
       groupIds.add(REGISTERED_USERS);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
index 5a72d5c..6604641 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/project/Util.java
@@ -17,6 +17,7 @@
 import com.google.gerrit.common.data.AccessSection;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelValue;
 import com.google.gerrit.common.data.Permission;
@@ -47,7 +48,7 @@
   public static final LabelType patchSetLock() {
     LabelType label =
         category("Patch-Set-Lock", value(1, "Patch Set Locked"), value(0, "Patch Set Unlocked"));
-    label.setFunctionName("PatchSetLock");
+    label.setFunction(LabelFunction.PATCH_SET_LOCK);
     return label;
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
deleted file mode 100644
index cc59081..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/AndPredicateTest.java
+++ /dev/null
@@ -1,124 +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;
-
-import static com.google.common.collect.ImmutableList.of;
-import static com.google.gerrit.server.query.Predicate.and;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotSame;
-import static org.junit.Assert.assertSame;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
-
-import java.util.List;
-import org.junit.Test;
-
-public class AndPredicateTest extends PredicateTest {
-  @Test
-  public void children() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final Predicate<String> n = and(a, b);
-    assertEquals(2, n.getChildCount());
-    assertSame(a, n.getChild(0));
-    assertSame(b, n.getChild(1));
-  }
-
-  @Test
-  public void childrenUnmodifiable() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final Predicate<String> n = and(a, b);
-
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
-    assertChildren("clear", n, of(a, b));
-
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
-    assertChildren("remove(0)", n, of(a, b));
-
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
-    assertChildren("iterator().remove()", n, of(a, b));
-  }
-
-  private static void assertChildren(
-      String o, Predicate<String> p, List<? extends Predicate<String>> l) {
-    assertEquals(o + " did not affect child", l, p.getChildren());
-  }
-
-  @Test
-  public void testToString() {
-    final TestPredicate a = f("q", "alice");
-    final TestPredicate b = f("q", "bob");
-    final TestPredicate c = f("q", "charlie");
-    assertEquals("(q:alice q:bob)", and(a, b).toString());
-    assertEquals("(q:alice q:bob q:charlie)", and(a, b, c).toString());
-  }
-
-  @Test
-  public void testEquals() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
-
-    assertTrue(and(a, b).equals(and(a, b)));
-    assertTrue(and(a, b, c).equals(and(a, b, c)));
-
-    assertFalse(and(a, b).equals(and(b, a)));
-    assertFalse(and(a, c).equals(and(a, b)));
-
-    assertFalse(and(a, c).equals(a));
-  }
-
-  @Test
-  public void testHashCode() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
-
-    assertTrue(and(a, b).hashCode() == and(a, b).hashCode());
-    assertTrue(and(a, b, c).hashCode() == and(a, b, c).hashCode());
-    assertFalse(and(a, c).hashCode() == and(a, b).hashCode());
-  }
-
-  @Test
-  public void testCopy() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
-    final List<TestPredicate> s2 = of(a, b);
-    final List<TestPredicate> s3 = of(a, b, c);
-    final Predicate<String> n2 = and(a, b);
-
-    assertNotSame(n2, n2.copy(s2));
-    assertEquals(s2, n2.copy(s2).getChildren());
-    assertEquals(s3, n2.copy(s3).getChildren());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
deleted file mode 100644
index 6a72fce..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/FieldPredicateTest.java
+++ /dev/null
@@ -1,68 +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;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertSame;
-import static org.junit.Assert.assertTrue;
-
-import java.util.Collections;
-import org.junit.Test;
-
-public class FieldPredicateTest extends PredicateTest {
-  @Test
-  public void testToString() {
-    assertEquals("author:bob", f("author", "bob").toString());
-    assertEquals("author:\"\"", f("author", "").toString());
-    assertEquals("owner:\"A U Thor\"", f("owner", "A U Thor").toString());
-  }
-
-  @SuppressWarnings("unlikely-arg-type")
-  @Test
-  public void testEquals() {
-    assertTrue(f("author", "bob").equals(f("author", "bob")));
-    assertFalse(f("author", "bob").equals(f("author", "alice")));
-    assertFalse(f("owner", "bob").equals(f("author", "bob")));
-    assertFalse(f("author", "bob").equals("author"));
-  }
-
-  @Test
-  public void testHashCode() {
-    assertTrue(f("a", "bob").hashCode() == f("a", "bob").hashCode());
-    assertFalse(f("a", "bob").hashCode() == f("a", "alice").hashCode());
-  }
-
-  @Test
-  public void nameValue() {
-    final String name = "author";
-    final String value = "alice";
-    final OperatorPredicate<String> f = f(name, value);
-    assertSame(name, f.getOperator());
-    assertSame(value, f.getValue());
-    assertEquals(0, f.getChildren().size());
-  }
-
-  @Test
-  public void testCopy() {
-    final OperatorPredicate<String> f = f("author", "alice");
-    assertSame(f, f.copy(Collections.<Predicate<String>>emptyList()));
-    assertSame(f, f.copy(f.getChildren()));
-
-    exception.expect(IllegalArgumentException.class);
-    exception.expectMessage("Expected 0 children");
-    f.copy(Collections.singleton(f("owner", "bob")));
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
deleted file mode 100644
index 13a566c..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/NotPredicateTest.java
+++ /dev/null
@@ -1,129 +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;
-
-import static com.google.gerrit.server.query.Predicate.and;
-import static com.google.gerrit.server.query.Predicate.not;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotSame;
-import static org.junit.Assert.assertSame;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
-
-import java.util.Collections;
-import java.util.List;
-import org.junit.Test;
-
-public class NotPredicateTest extends PredicateTest {
-  @Test
-  public void notNot() {
-    final TestPredicate p = f("author", "bob");
-    final Predicate<String> n = not(p);
-    assertTrue(n instanceof NotPredicate);
-    assertNotSame(p, n);
-    assertSame(p, not(n));
-  }
-
-  @Test
-  public void children() {
-    final TestPredicate p = f("author", "bob");
-    final Predicate<String> n = not(p);
-    assertEquals(1, n.getChildCount());
-    assertSame(p, n.getChild(0));
-  }
-
-  @Test
-  public void childrenUnmodifiable() {
-    final TestPredicate p = f("author", "bob");
-    final Predicate<String> n = not(p);
-
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      assertOnlyChild("clear", p, n);
-    }
-
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      assertOnlyChild("remove(0)", p, n);
-    }
-
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      assertOnlyChild("remove()", p, n);
-    }
-  }
-
-  private static void assertOnlyChild(String o, Predicate<String> c, Predicate<String> p) {
-    assertEquals(o + " did not affect child", 1, p.getChildCount());
-    assertSame(o + " did not affect child", c, p.getChild(0));
-  }
-
-  @Test
-  public void testToString() {
-    assertEquals("-author:bob", not(f("author", "bob")).toString());
-  }
-
-  @SuppressWarnings("unlikely-arg-type")
-  @Test
-  public void testEquals() {
-    assertTrue(not(f("author", "bob")).equals(not(f("author", "bob"))));
-    assertFalse(not(f("author", "bob")).equals(not(f("author", "alice"))));
-    assertFalse(not(f("author", "bob")).equals(f("author", "bob")));
-    assertFalse(not(f("author", "bob")).equals("author"));
-  }
-
-  @Test
-  public void testHashCode() {
-    assertTrue(not(f("a", "b")).hashCode() == not(f("a", "b")).hashCode());
-    assertFalse(not(f("a", "b")).hashCode() == not(f("a", "a")).hashCode());
-  }
-
-  @Test
-  @SuppressWarnings({"rawtypes", "unchecked"})
-  public void testCopy() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final List<TestPredicate> sa = Collections.singletonList(a);
-    final List<TestPredicate> sb = Collections.singletonList(b);
-    final Predicate n = not(a);
-
-    assertNotSame(n, n.copy(sa));
-    assertEquals(sa, n.copy(sa).getChildren());
-
-    assertNotSame(n, n.copy(sb));
-    assertEquals(sb, n.copy(sb).getChildren());
-
-    try {
-      n.copy(Collections.<Predicate>emptyList());
-      fail("Expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      assertEquals("Expected exactly one child", e.getMessage());
-    }
-
-    try {
-      n.copy(and(a, b).getChildren());
-      fail("Expected IllegalArgumentException");
-    } catch (IllegalArgumentException e) {
-      assertEquals("Expected exactly one child", e.getMessage());
-    }
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
deleted file mode 100644
index 7d97a0d..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/OrPredicateTest.java
+++ /dev/null
@@ -1,124 +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;
-
-import static com.google.common.collect.ImmutableList.of;
-import static com.google.gerrit.server.query.Predicate.or;
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotSame;
-import static org.junit.Assert.assertSame;
-import static org.junit.Assert.assertTrue;
-import static org.junit.Assert.fail;
-
-import java.util.List;
-import org.junit.Test;
-
-public class OrPredicateTest extends PredicateTest {
-  @Test
-  public void children() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final Predicate<String> n = or(a, b);
-    assertEquals(2, n.getChildCount());
-    assertSame(a, n.getChild(0));
-    assertSame(b, n.getChild(1));
-  }
-
-  @Test
-  public void childrenUnmodifiable() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final Predicate<String> n = or(a, b);
-
-    try {
-      n.getChildren().clear();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
-    assertChildren("clear", n, of(a, b));
-
-    try {
-      n.getChildren().remove(0);
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
-    assertChildren("remove(0)", n, of(a, b));
-
-    try {
-      n.getChildren().iterator().remove();
-      fail("Expected UnsupportedOperationException");
-    } catch (UnsupportedOperationException e) {
-      // Expected
-    }
-    assertChildren("iterator().remove()", n, of(a, b));
-  }
-
-  private static void assertChildren(
-      String o, Predicate<String> p, List<? extends Predicate<String>> l) {
-    assertEquals(o + " did not affect child", l, p.getChildren());
-  }
-
-  @Test
-  public void testToString() {
-    final TestPredicate a = f("q", "alice");
-    final TestPredicate b = f("q", "bob");
-    final TestPredicate c = f("q", "charlie");
-    assertEquals("(q:alice OR q:bob)", or(a, b).toString());
-    assertEquals("(q:alice OR q:bob OR q:charlie)", or(a, b, c).toString());
-  }
-
-  @Test
-  public void testEquals() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
-
-    assertTrue(or(a, b).equals(or(a, b)));
-    assertTrue(or(a, b, c).equals(or(a, b, c)));
-
-    assertFalse(or(a, b).equals(or(b, a)));
-    assertFalse(or(a, c).equals(or(a, b)));
-
-    assertFalse(or(a, c).equals(a));
-  }
-
-  @Test
-  public void testHashCode() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
-
-    assertTrue(or(a, b).hashCode() == or(a, b).hashCode());
-    assertTrue(or(a, b, c).hashCode() == or(a, b, c).hashCode());
-    assertFalse(or(a, c).hashCode() == or(a, b).hashCode());
-  }
-
-  @Test
-  public void testCopy() {
-    final TestPredicate a = f("author", "alice");
-    final TestPredicate b = f("author", "bob");
-    final TestPredicate c = f("author", "charlie");
-    final List<TestPredicate> s2 = of(a, b);
-    final List<TestPredicate> s3 = of(a, b, c);
-    final Predicate<String> n2 = or(a, b);
-
-    assertNotSame(n2, n2.copy(s2));
-    assertEquals(s2, n2.copy(s2).getChildren());
-    assertEquals(s3, n2.copy(s3).getChildren());
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
deleted file mode 100644
index 2d13876..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/PredicateTest.java
+++ /dev/null
@@ -1,31 +0,0 @@
-// Copyright (C) 2015 The Android Open Source Project
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package com.google.gerrit.server.query;
-
-import com.google.gerrit.testutil.GerritBaseTests;
-import org.junit.Ignore;
-
-@Ignore
-public abstract class PredicateTest extends GerritBaseTests {
-  protected static final class TestPredicate extends OperatorPredicate<String> {
-    protected TestPredicate(String name, String value) {
-      super(name, value);
-    }
-  }
-
-  protected static TestPredicate f(String name, String value) {
-    return new TestPredicate(name, value);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/QueryParserTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/QueryParserTest.java
deleted file mode 100644
index 4c0bcc0..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/QueryParserTest.java
+++ /dev/null
@@ -1,47 +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;
-
-import static org.junit.Assert.assertEquals;
-
-import org.antlr.runtime.tree.Tree;
-import org.junit.Test;
-
-public class QueryParserTest {
-  @Test
-  public void projectBare() throws QueryParseException {
-    Tree r;
-
-    r = parse("project:tools/gerrit");
-    assertSingleWord("project", "tools/gerrit", r);
-
-    r = parse("project:tools/*");
-    assertSingleWord("project", "tools/*", r);
-  }
-
-  private static void assertSingleWord(final String name, final String value, final Tree r) {
-    assertEquals(QueryParser.FIELD_NAME, r.getType());
-    assertEquals(name, r.getText());
-    assertEquals(1, r.getChildCount());
-    final Tree c = r.getChild(0);
-    assertEquals(QueryParser.SINGLE_WORD, c.getType());
-    assertEquals(value, c.getText());
-    assertEquals(0, c.getChildCount());
-  }
-
-  private static Tree parse(final String str) throws QueryParseException {
-    return QueryParser.parse(str);
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
index 0075042..05f6796 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/AbstractQueryAccountsTest.java
@@ -18,10 +18,8 @@
 import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.fail;
 
-import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.GerritApi;
 import com.google.gerrit.extensions.api.access.AccessSectionInfo;
 import com.google.gerrit.extensions.api.access.PermissionInfo;
@@ -44,12 +42,20 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.AnonymousUser;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.AccountConfig;
 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.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.git.MetaDataUpdate;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
 import com.google.gerrit.server.schema.SchemaCreator;
@@ -66,11 +72,12 @@
 import com.google.inject.util.Providers;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.HashMap;
 import java.util.Iterator;
 import java.util.List;
 import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Repository;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Ignore;
@@ -85,12 +92,18 @@
     return cfg;
   }
 
+  @Inject protected Accounts accounts;
+
+  @Inject protected AccountsUpdate.Server accountsUpdate;
+
   @Inject protected AccountCache accountCache;
 
   @Inject protected AccountManager accountManager;
 
   @Inject protected GerritApi gApi;
 
+  @Inject @GerritPersonIdent Provider<PersonIdent> serverIdent;
+
   @Inject protected IdentifiedUser.GenericFactory userFactory;
 
   @Inject private Provider<AnonymousUser> anonymousUser;
@@ -103,14 +116,18 @@
 
   @Inject protected OneOffRequestContext oneOffRequestContext;
 
-  @Inject protected InternalAccountQuery internalAccountQuery;
+  @Inject protected Provider<InternalAccountQuery> queryProvider;
 
   @Inject protected AllProjectsName allProjects;
 
+  @Inject protected AllUsersName allUsers;
+
+  @Inject protected GitRepositoryManager repoManager;
+
   @Inject protected AccountIndexCollection accountIndexes;
 
-  protected Injector injector;
   protected LifecycleManager lifecycle;
+  protected Injector injector;
   protected ReviewDb db;
   protected AccountInfo currentUserInfo;
   protected CurrentUser user;
@@ -125,6 +142,16 @@
     injector.injectMembers(this);
     lifecycle.start();
     initAfterLifecycleStart();
+    setUpDatabase();
+  }
+
+  @After
+  public void cleanUp() {
+    lifecycle.stop();
+    db.close();
+  }
+
+  protected void setUpDatabase() throws Exception {
     db = schemaFactory.open();
     schemaCreator.create(db);
 
@@ -134,12 +161,6 @@
     currentUserInfo = gApi.accounts().id(userId.get()).get();
   }
 
-  @After
-  public void cleanUp() {
-    lifecycle.stop();
-    db.close();
-  }
-
   protected void initAfterLifecycleStart() throws Exception {}
 
   protected RequestContext newRequestContext(Account.Id requestUserId) {
@@ -276,6 +297,7 @@
     assertQuery("Jo Do", user1);
     assertQuery("jo do", user1);
     assertQuery("self", currentUserInfo, user3);
+    assertQuery("me", currentUserInfo);
     assertQuery("name:John", user1);
     assertQuery("name:john", user1);
     assertQuery("name:Doe", user1);
@@ -311,19 +333,19 @@
     AccountInfo user2 = newAccountWithFullName("jroe", "Jane Roe");
     AccountInfo user3 = newAccountWithFullName("user3", "Mr Selfish");
 
-    assertThat(internalAccountQuery.byWatchedProject(p)).isEmpty();
+    assertThat(queryProvider.get().byWatchedProject(p)).isEmpty();
 
     watch(user1, p, null);
-    assertAccounts(internalAccountQuery.byWatchedProject(p), user1);
+    assertAccounts(queryProvider.get().byWatchedProject(p), user1);
 
     watch(user2, p, "keyword");
-    assertAccounts(internalAccountQuery.byWatchedProject(p), user1, user2);
+    assertAccounts(queryProvider.get().byWatchedProject(p), user1, user2);
 
     watch(user3, p2, "keyword");
     watch(user3, allProjects, "keyword");
-    assertAccounts(internalAccountQuery.byWatchedProject(p), user1, user2);
-    assertAccounts(internalAccountQuery.byWatchedProject(p2), user3);
-    assertAccounts(internalAccountQuery.byWatchedProject(allProjects), user3);
+    assertAccounts(queryProvider.get().byWatchedProject(p), user1, user2);
+    assertAccounts(queryProvider.get().byWatchedProject(p2), user3);
+    assertAccounts(queryProvider.get().byWatchedProject(allProjects), user3);
   }
 
   @Test
@@ -426,11 +448,19 @@
   public void reindex() throws Exception {
     AccountInfo user1 = newAccountWithFullName("tester", "Test Usre");
 
-    // update account in the database so that account index is stale
+    // update account without reindex so that account index is stale
+    Account.Id accountId = new Account.Id(user1._accountId);
     String newName = "Test User";
-    Account account = db.accounts().get(new Account.Id(user1._accountId));
-    account.setFullName(newName);
-    db.accounts().update(Collections.singleton(account));
+    try (Repository repo = repoManager.openRepository(allUsers)) {
+      MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, allUsers, repo);
+      PersonIdent ident = serverIdent.get();
+      md.getCommitBuilder().setAuthor(ident);
+      md.getCommitBuilder().setCommitter(ident);
+      AccountConfig accountConfig = new AccountConfig(null, accountId);
+      accountConfig.load(repo);
+      accountConfig.getAccount().setFullName(newName);
+      accountConfig.commit(md);
+    }
 
     assertQuery("name:" + quote(user1.name), user1);
     assertQuery("name:" + quote(newName));
@@ -487,7 +517,7 @@
     PermissionInfo p = new PermissionInfo(null, null);
     p.rules =
         ImmutableMap.of(group.id, new PermissionRuleInfo(PermissionRuleInfo.Action.BLOCK, false));
-    a.permissions = ImmutableMap.of(Permission.READ, p);
+    a.permissions = ImmutableMap.of("read", p);
     in.add = ImmutableMap.of("refs/*", a);
 
     gApi.projects().name(project.get()).access(in);
@@ -530,7 +560,8 @@
     if (name == null) {
       return null;
     }
-    String suffix = testName.getMethodName().toLowerCase();
+
+    String suffix = getSanitizedMethodName();
     if (name.contains("@")) {
       return name + "." + suffix;
     }
@@ -544,12 +575,15 @@
       if (email != null) {
         accountManager.link(id, AuthRequest.forEmail(email));
       }
-      Account a = db.accounts().get(id);
-      a.setFullName(fullName);
-      a.setPreferredEmail(email);
-      a.setActive(active);
-      db.accounts().update(ImmutableList.of(a));
-      accountCache.evict(id);
+      accountsUpdate
+          .create()
+          .update(
+              id,
+              a -> {
+                a.setFullName(fullName);
+                a.setPreferredEmail(email);
+                a.setActive(active);
+              });
       return id;
     }
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
index 978283a..fa130ca 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/account/LuceneQueryAccountsTest.java
@@ -14,12 +14,26 @@
 
 package com.google.gerrit.server.query.account;
 
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
+import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testutil.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
 public class LuceneQueryAccountsTest extends AbstractQueryAccountsTest {
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions =
+        IndexVersions.getWithoutLatest(AccountSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        AccountSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
   @Override
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 0e03871..934d893 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -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.DETAILED_LABELS;
 import static com.google.gerrit.extensions.client.ListChangesOption.REVIEWED;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static com.google.gerrit.server.project.Util.category;
@@ -25,6 +26,7 @@
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
 import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.FluentIterable;
@@ -33,6 +35,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.collect.Streams;
 import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
@@ -50,14 +53,22 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.StarsInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.api.projects.ProjectInput;
+import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectWatchInfo;
 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.ChangeInput;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
 import com.google.gerrit.extensions.common.CommentInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.IndexConfig;
+import com.google.gerrit.index.QueryOptions;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
@@ -73,17 +84,19 @@
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.Sequences;
 import com.google.gerrit.server.StarredChangesUtil;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.Accounts;
+import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIdsUpdate;
 import com.google.gerrit.server.change.ChangeInserter;
 import com.google.gerrit.server.change.ChangeTriplet;
 import com.google.gerrit.server.change.PatchSetInserter;
 import com.google.gerrit.server.config.AllUsersName;
 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.index.IndexConfig;
-import com.google.gerrit.server.index.QueryOptions;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.index.change.ChangeIndexCollection;
 import com.google.gerrit.server.index.change.ChangeIndexer;
@@ -92,11 +105,12 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.NoteDbChangeState;
 import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.Util;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.update.BatchUpdate;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.server.util.RequestContext;
 import com.google.gerrit.server.util.ThreadLocalRequestContext;
 import com.google.gerrit.testutil.ConfigSuite;
@@ -106,6 +120,7 @@
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
 import com.google.gerrit.testutil.TestTimeUtil;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
 import com.google.inject.Provider;
@@ -113,6 +128,7 @@
 import java.sql.Timestamp;
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.HashSet;
 import java.util.Iterator;
@@ -123,10 +139,14 @@
 import java.util.concurrent.TimeUnit;
 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.ObjectReader;
+import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
 import org.eclipse.jgit.util.SystemReader;
 import org.junit.After;
 import org.junit.Before;
@@ -148,6 +168,9 @@
     return cfg;
   }
 
+  @Inject protected Accounts accounts;
+  @Inject protected AccountCache accountCache;
+  @Inject protected AccountsUpdate.Server accountsUpdate;
   @Inject protected AccountManager accountManager;
   @Inject protected AllUsersName allUsersName;
   @Inject protected BatchUpdate.Factory updateFactory;
@@ -158,21 +181,26 @@
   @Inject protected ChangeIndexCollection indexes;
   @Inject protected ChangeIndexer indexer;
   @Inject protected IndexConfig indexConfig;
-  @Inject protected InMemoryDatabase schemaFactory;
   @Inject protected InMemoryRepositoryManager repoManager;
-  @Inject protected InternalChangeQuery internalChangeQuery;
+  @Inject protected Provider<InternalChangeQuery> queryProvider;
   @Inject protected ChangeNotes.Factory notesFactory;
+  @Inject protected OneOffRequestContext oneOffRequestContext;
   @Inject protected PatchSetInserter.Factory patchSetFactory;
   @Inject protected PatchSetUtil psUtil;
-  @Inject protected ChangeControl.GenericFactory changeControlFactory;
-  @Inject protected ChangeQueryProcessor queryProcessor;
+  @Inject protected ChangeNotes.Factory changeNotesFactory;
+  @Inject protected Provider<ChangeQueryProcessor> queryProcessorProvider;
   @Inject protected SchemaCreator schemaCreator;
+  @Inject protected SchemaFactory<ReviewDb> schemaFactory;
   @Inject protected Sequences seq;
   @Inject protected ThreadLocalRequestContext requestContext;
   @Inject protected ProjectCache projectCache;
   @Inject protected MetaDataUpdate.Server metaDataUpdateFactory;
+  @Inject protected ExternalIdsUpdate.Server externalIdsUpdate;
   @Inject protected IdentifiedUser.GenericFactory identifiedUserFactory;
 
+  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
+  @Inject private InMemoryDatabase inMemoryDatabase;
+
   protected Injector injector;
   protected LifecycleManager lifecycle;
   protected ReviewDb db;
@@ -203,15 +231,17 @@
   protected void initAfterLifecycleStart() throws Exception {}
 
   protected void setUpDatabase() throws Exception {
+    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
+      schemaCreator.create(underlyingDb);
+    }
     db = schemaFactory.open();
-    schemaCreator.create(db);
 
     userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
-    Account userAccount = db.accounts().get(userId);
-    userAccount.setPreferredEmail("user@example.com");
-    db.accounts().update(ImmutableList.of(userAccount));
+    String email = "user@example.com";
+    externalIdsUpdate.create().insert(ExternalId.createEmail(userId, email));
+    accountsUpdate.create().update(userId, a -> a.setPreferredEmail(email));
     user = userFactory.create(userId);
-    requestContext.setContext(newRequestContext(userAccount.getId()));
+    requestContext.setContext(newRequestContext(userId));
   }
 
   protected RequestContext newRequestContext(Account.Id requestUserId) {
@@ -238,7 +268,7 @@
     if (db != null) {
       db.close();
     }
-    InMemoryDatabase.drop(schemaFactory);
+    InMemoryDatabase.drop(inMemoryDatabase);
   }
 
   @Before
@@ -320,6 +350,8 @@
     assertQuery("is:new", change1);
     assertQuery("status:merged", change2);
     assertQuery("is:merged", change2);
+    assertQuery("status:draft");
+    assertQuery("is:draft");
   }
 
   @Test
@@ -327,11 +359,9 @@
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.NEW);
     Change change1 = insert(repo, ins1);
-    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.DRAFT);
-    Change change2 = insert(repo, ins2);
     insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
 
-    Change[] expected = new Change[] {change2, change1};
+    Change[] expected = new Change[] {change1};
     assertQuery("status:open", expected);
     assertQuery("status:OPEN", expected);
     assertQuery("status:o", expected);
@@ -347,23 +377,6 @@
   }
 
   @Test
-  public void byStatusDraft() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    insert(repo, newChangeWithStatus(repo, Change.Status.NEW));
-    ChangeInserter ins2 = newChangeWithStatus(repo, Change.Status.DRAFT);
-    Change change2 = insert(repo, ins2);
-
-    Change[] expected = new Change[] {change2};
-    assertQuery("status:draft", expected);
-    assertQuery("status:DRAFT", expected);
-    assertQuery("status:d", expected);
-    assertQuery("status:dr", expected);
-    assertQuery("status:dra", expected);
-    assertQuery("status:draf", expected);
-    assertQuery("is:draft", expected);
-  }
-
-  @Test
   public void byStatusClosed() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChangeWithStatus(repo, Change.Status.MERGED);
@@ -411,10 +424,205 @@
     assertQuery("status:N", change1);
     assertQuery("status:nE", change1);
     assertQuery("status:neW", change1);
-    assertThatQueryException("status:nx").hasMessageThat().isEqualTo("invalid change status: nx");
-    assertThatQueryException("status:newx")
-        .hasMessageThat()
-        .isEqualTo("invalid change status: newx");
+    assertQuery("status:nx");
+    assertQuery("status:newx");
+  }
+
+  @Test
+  public void byPrivate() throws Exception {
+    if (getSchemaVersion() < 40) {
+      assertMissingField(ChangeField.PRIVATE);
+      assertFailingQuery(
+          "is:private", "'is:private' operator is not supported by change index version");
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    // No private changes.
+    assertQuery("is:open", change2, change1);
+    assertQuery("is:private");
+
+    gApi.changes().id(change1.getChangeId()).setPrivate(true, null);
+
+    // Change1 is not private, but should be still visible to its owner.
+    assertQuery("is:open", change1, change2);
+    assertQuery("is:private", change1);
+
+    // Switch request context to user2.
+    requestContext.setContext(newRequestContext(user2));
+    assertQuery("is:open", change2);
+    assertQuery("is:private");
+  }
+
+  @Test
+  public void byWip() throws Exception {
+    if (getSchemaVersion() < 42) {
+      assertMissingField(ChangeField.WIP);
+      assertFailingQuery("is:wip", "'is:wip' operator is not supported by change index version");
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo), userId);
+
+    assertQuery("is:open", change1);
+    assertQuery("is:wip");
+
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+
+    assertQuery("is:wip", change1);
+
+    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+
+    assertQuery("is:wip");
+  }
+
+  @Test
+  public void excludeWipChangeFromReviewersDashboardsBeforeSchema42() throws Exception {
+    assume().that(getSchemaVersion()).isLessThan(42);
+
+    assertMissingField(ChangeField.WIP);
+    assertFailingQuery("is:wip", "'is:wip' operator is not supported by change index version");
+
+    Account.Id user1 = createAccount("user1");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
+    assertQuery("reviewer:" + user1, change1);
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+    assertQuery("reviewer:" + user1, change1);
+  }
+
+  @Test
+  public void excludeWipChangeFromReviewersDashboards() throws Exception {
+    assume().that(getSchemaVersion()).isAtLeast(42);
+
+    Account.Id user1 = createAccount("user1");
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWorkInProgress(repo), userId);
+
+    assertQuery("is:wip", change1);
+    assertQuery("reviewer:" + user1);
+
+    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+    assertQuery("is:wip");
+    assertQuery("reviewer:" + user1);
+
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+    assertQuery("is:wip", change1);
+    assertQuery("reviewer:" + user1);
+  }
+
+  @Test
+  public void byStartedBeforeSchema44() throws Exception {
+    assume().that(getSchemaVersion()).isLessThan(44);
+    assertMissingField(ChangeField.STARTED);
+    assertFailingQuery(
+        "is:started", "'is:started' operator is not supported by change index version");
+  }
+
+  @Test
+  public void byStarted() throws Exception {
+    assume().that(getSchemaVersion()).isAtLeast(44);
+
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChangeWorkInProgress(repo));
+
+    assertQuery("is:started");
+
+    gApi.changes().id(change1.getChangeId()).setReadyForReview();
+    assertQuery("is:started", change1);
+
+    gApi.changes().id(change1.getChangeId()).setWorkInProgress();
+    assertQuery("is:started", change1);
+  }
+
+  private void assertReviewers(Collection<AccountInfo> reviewers, Object... expected)
+      throws Exception {
+    if (expected.length == 0) {
+      assertThat(reviewers).isNull();
+      return;
+    }
+
+    // Convert AccountInfos to strings, either account ID or email.
+    List<String> reviewerIds =
+        reviewers
+            .stream()
+            .map(
+                ai -> {
+                  if (ai._accountId != null) {
+                    return ai._accountId.toString();
+                  }
+                  return ai.email;
+                })
+            .collect(toList());
+    assertThat(reviewerIds).containsExactly(expected);
+  }
+
+  @Test
+  public void restorePendingReviewers() throws Exception {
+    assume().that(getSchemaVersion()).isAtLeast(44);
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+    Change change1 = insert(repo, newChangeWorkInProgress(repo));
+    Account.Id user1 = createAccount("user1");
+    Account.Id user2 = createAccount("user2");
+    String email1 = "email1@example.com";
+    String email2 = "email2@example.com";
+
+    ReviewInput in =
+        ReviewInput.noScore()
+            .reviewer(user1.toString())
+            .reviewer(user2.toString(), ReviewerState.CC, false)
+            .reviewer(email1)
+            .reviewer(email2, ReviewerState.CC, false);
+    gApi.changes().id(change1.getId().get()).revision("current").review(in);
+
+    List<ChangeInfo> changeInfos =
+        assertQuery(newQuery("is:wip").withOption(DETAILED_LABELS), change1);
+    assertThat(changeInfos).isNotEmpty();
+
+    Map<ReviewerState, Collection<AccountInfo>> pendingReviewers =
+        changeInfos.get(0).pendingReviewers;
+    assertThat(pendingReviewers).isNotNull();
+
+    assertReviewers(
+        pendingReviewers.get(ReviewerState.REVIEWER), userId.toString(), user1.toString(), email1);
+    assertReviewers(pendingReviewers.get(ReviewerState.CC), user2.toString(), email2);
+    assertReviewers(pendingReviewers.get(ReviewerState.REMOVED));
+
+    // Pending reviewers may also be presented in the REMOVED state. Toggle the
+    // change to ready and then back to WIP and remove reviewers to produce.
+    assertThat(pendingReviewers.get(ReviewerState.REMOVED)).isNull();
+    gApi.changes().id(change1.getId().get()).setReadyForReview();
+    gApi.changes().id(change1.getId().get()).setWorkInProgress();
+    gApi.changes().id(change1.getId().get()).reviewer(user1.toString()).remove();
+    gApi.changes().id(change1.getId().get()).reviewer(user2.toString()).remove();
+    gApi.changes().id(change1.getId().get()).reviewer(email1).remove();
+    gApi.changes().id(change1.getId().get()).reviewer(email2).remove();
+
+    changeInfos = assertQuery(newQuery("is:wip").withOption(DETAILED_LABELS), change1);
+    assertThat(changeInfos).isNotEmpty();
+
+    pendingReviewers = changeInfos.get(0).pendingReviewers;
+    assertThat(pendingReviewers).isNotNull();
+    assertReviewers(pendingReviewers.get(ReviewerState.REVIEWER));
+    assertReviewers(pendingReviewers.get(ReviewerState.CC));
+    assertReviewers(
+        pendingReviewers.get(ReviewerState.REMOVED),
+        user1.toString(),
+        user2.toString(),
+        email1,
+        email2);
   }
 
   @Test
@@ -422,7 +630,7 @@
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins = newChange(repo);
     Change change = insert(repo, ins);
-    String sha = ins.getCommit().name();
+    String sha = ins.getCommitId().name();
 
     assertQuery("0000000000000000000000000000000000000000");
     assertQuery("commit:0000000000000000000000000000000000000000");
@@ -450,57 +658,84 @@
   }
 
   @Test
-  public void byAuthor() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
-
-    // By exact email address
-    assertQuery("author:jauthor@example.com", change1);
-
-    // By email address part
-    assertQuery("author:jauthor", change1);
-    assertQuery("author:example", change1);
-    assertQuery("author:example.com", change1);
-
-    // By name part
-    assertQuery("author:Author", change1);
-
-    // Case insensitive
-    assertQuery("author:jAuThOr", change1);
-    assertQuery("author:ExAmPlE", change1);
-
-    // By non-existing email address / name / part
-    assertQuery("author:jcommitter@example.com");
-    assertQuery("author:somewhere.com");
-    assertQuery("author:jcommitter");
-    assertQuery("author:Committer");
+  public void byAuthorExact() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.EXACT_AUTHOR)).isTrue();
+    byAuthorOrCommitterExact("author:");
   }
 
   @Test
-  public void byCommitter() throws Exception {
+  public void byAuthorFullText() throws Exception {
+    byAuthorOrCommitterFullText("author:");
+  }
+
+  @Test
+  public void byCommitterExact() throws Exception {
+    assume().that(getSchema().hasField(ChangeField.EXACT_COMMITTER)).isTrue();
+    byAuthorOrCommitterExact("committer:");
+  }
+
+  @Test
+  public void byCommitterFullText() throws Exception {
+    byAuthorOrCommitterFullText("committer:");
+  }
+
+  private void byAuthorOrCommitterExact(String searchOperator) throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
+    PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
+    PersonIdent john = new PersonIdent("John", "john@example.com");
+    PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
+    Change change1 = createChange(repo, johnDoe);
+    Change change2 = createChange(repo, john);
+    Change change3 = createChange(repo, doeSmith);
 
-    // By exact email address
-    assertQuery("committer:jcommitter@example.com", change1);
+    // Only email address.
+    assertQuery(searchOperator + "john.doe@example.com", change1);
+    assertQuery(searchOperator + "john@example.com", change2);
+    assertQuery(searchOperator + "Doe_SmIth@example.com", change3); // Case insensitive.
 
-    // By email address part
-    assertQuery("committer:jcommitter", change1);
-    assertQuery("committer:example", change1);
-    assertQuery("committer:example.com", change1);
+    // Right combination of email address and name.
+    assertQuery(searchOperator + "\"John Doe <john.doe@example.com>\"", change1);
+    assertQuery(searchOperator + "\" John <john@example.com> \"", change2);
+    assertQuery(searchOperator + "\"doE SMITH <doe_smitH@example.com>\"", change3);
 
-    // By name part
-    assertQuery("committer:Committer", change1);
+    // Wrong combination of email address and name.
+    assertQuery(searchOperator + "\"John <john.doe@example.com>\"");
+    assertQuery(searchOperator + "\"Doe John <john@example.com>\"");
+    assertQuery(searchOperator + "\"Doe John <doe_smith@example.com>\"");
+  }
 
-    // Case insensitive
-    assertQuery("committer:jCoMmItTeR", change1);
-    assertQuery("committer:ExAmPlE", change1);
+  private void byAuthorOrCommitterFullText(String searchOperator) throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    PersonIdent johnDoe = new PersonIdent("John Doe", "john.doe@example.com");
+    PersonIdent john = new PersonIdent("John", "john@example.com");
+    PersonIdent doeSmith = new PersonIdent("Doe Smith", "doe_smith@example.com");
+    Change change1 = createChange(repo, johnDoe);
+    Change change2 = createChange(repo, john);
+    Change change3 = createChange(repo, doeSmith);
 
-    // By non-existing email address / name / part
-    assertQuery("committer:jauthor@example.com");
-    assertQuery("committer:somewhere.com");
-    assertQuery("committer:jauthor");
-    assertQuery("committer:Author");
+    // By exact name.
+    assertQuery(searchOperator + "\"John Doe\"", change1);
+    assertQuery(searchOperator + "\"john\"", change2, change1);
+    assertQuery(searchOperator + "\"Doe smith\"", change3);
+
+    // By name part.
+    assertQuery(searchOperator + "Doe", change3, change1);
+    assertQuery(searchOperator + "smith", change3);
+
+    // By wrong combination.
+    assertQuery(searchOperator + "\"John Smith\"");
+
+    // By invalid query.
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("invalid value");
+    // SchemaUtil.getNameParts will return an empty set for query only containing these characters.
+    assertQuery(searchOperator + "@.- /_");
+  }
+
+  private Change createChange(TestRepository<Repo> repo, PersonIdent person) throws Exception {
+    RevCommit commit =
+        repo.parseBody(repo.commit().message("message").author(person).committer(person).create());
+    return insert(repo, newChangeForCommit(repo, commit), null);
   }
 
   @Test
@@ -604,15 +839,29 @@
     assertQuery("topic:feature2", change2);
     assertQuery("intopic:feature2", change4, change3, change2);
     assertQuery("intopic:fixup", change4);
-    assertQuery("intopic:^feature2.*", change4, change2);
-    assertQuery("intopic:{^.*feature2$}", change3, change2);
     assertQuery("intopic:gerrit", change6, change5);
-
     assertQuery("topic:\"\"", change_no_topic);
     assertQuery("intopic:\"\"", change_no_topic);
   }
 
   @Test
+  public void byTopicRegex() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+
+    ChangeInserter ins1 = newChangeWithTopic(repo, "feature1");
+    Change change1 = insert(repo, ins1);
+
+    ChangeInserter ins2 = newChangeWithTopic(repo, "Cherrypick-feature1");
+    Change change2 = insert(repo, ins2);
+
+    ChangeInserter ins3 = newChangeWithTopic(repo, "feature1-fixup");
+    Change change3 = insert(repo, ins3);
+
+    assertQuery("intopic:^feature1.*", change3, change1);
+    assertQuery("intopic:{^.*feature1$}", change2, change1);
+  }
+
+  @Test
   public void byMessageExact() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     RevCommit commit1 = repo.parseBody(repo.commit().message("one").create());
@@ -662,11 +911,11 @@
   public void byLabel() throws Exception {
     accountManager.authenticate(AuthRequest.forUser("anotheruser"));
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins = newChange(repo, null, null, null, null);
-    ChangeInserter ins2 = newChange(repo, null, null, null, null);
-    ChangeInserter ins3 = newChange(repo, null, null, null, null);
-    ChangeInserter ins4 = newChange(repo, null, null, null, null);
-    ChangeInserter ins5 = newChange(repo, null, null, null, null);
+    ChangeInserter ins = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins2 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins3 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins4 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins5 = newChange(repo, null, null, null, null, false);
 
     Change reviewMinus2Change = insert(repo, ins);
     gApi.changes().id(reviewMinus2Change.getId().get()).current().review(ReviewInput.reject());
@@ -763,11 +1012,11 @@
     projectCache.evict(cfg.getProject());
 
     ReviewInput reviewVerified = new ReviewInput().label("Verified", 1);
-    ChangeInserter ins = newChange(repo, null, null, null, null);
-    ChangeInserter ins2 = newChange(repo, null, null, null, null);
-    ChangeInserter ins3 = newChange(repo, null, null, null, null);
-    ChangeInserter ins4 = newChange(repo, null, null, null, null);
-    ChangeInserter ins5 = newChange(repo, null, null, null, null);
+    ChangeInserter ins = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins2 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins3 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins4 = newChange(repo, null, null, null, null, false);
+    ChangeInserter ins5 = newChange(repo, null, null, null, null, false);
 
     // CR+1
     Change reviewCRplus1 = insert(repo, ins);
@@ -808,7 +1057,7 @@
   @Test
   public void byLabelNotOwner() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins = newChange(repo, null, null, null, null);
+    ChangeInserter ins = newChange(repo, null, null, null, null, false);
     Account.Id user1 = createAccount("user1");
 
     Change reviewPlus1Change = insert(repo, ins);
@@ -976,38 +1225,14 @@
   }
 
   @Test
-  public void updatedOrderWithMinuteResolution() throws Exception {
-    resetTimeWithClockStep(2, MINUTES);
-    TestRepository<Repo> repo = createProject("repo");
-    ChangeInserter ins1 = newChange(repo);
-    Change change1 = insert(repo, ins1);
-    Change change2 = insert(repo, newChange(repo));
-
-    assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
-    assertQuery("status:new", change2, change1);
-
-    gApi.changes().id(change1.getId().get()).topic("new-topic");
-    change1 = notesFactory.create(db, change1.getProject(), change1.getId()).getChange();
-
-    assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
-    assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
-        .isGreaterThan(MILLISECONDS.convert(1, MINUTES));
-
-    // change1 moved to the top.
-    assertQuery("status:new", change1, change2);
-  }
-
-  @Test
-  public void updatedOrderWithSubMinuteResolution() throws Exception {
+  public void updatedOrder() throws Exception {
     resetTimeWithClockStep(1, SECONDS);
-
     TestRepository<Repo> repo = createProject("repo");
     ChangeInserter ins1 = newChange(repo);
     Change change1 = insert(repo, ins1);
     Change change2 = insert(repo, newChange(repo));
 
     assertThat(lastUpdatedMs(change1)).isLessThan(lastUpdatedMs(change2));
-
     assertQuery("status:new", change2, change1);
 
     gApi.changes().id(change1.getId().get()).topic("new-topic");
@@ -1015,7 +1240,7 @@
 
     assertThat(lastUpdatedMs(change1)).isGreaterThan(lastUpdatedMs(change2));
     assertThat(lastUpdatedMs(change1) - lastUpdatedMs(change2))
-        .isLessThan(MILLISECONDS.convert(1, MINUTES));
+        .isAtLeast(MILLISECONDS.convert(1, SECONDS));
 
     // change1 moved to the top.
     assertQuery("status:new", change1, change2);
@@ -1384,31 +1609,17 @@
   }
 
   @Test
-  public void implicitVisibleTo() throws Exception {
+  public void visible() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
-    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.DRAFT), userId);
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+
+    gApi.changes().id(change2.getChangeId()).setPrivate(true, "private");
 
     String q = "project:repo";
     assertQuery(q, change2, change1);
 
-    // Second user cannot see first user's drafts.
-    requestContext.setContext(
-        newRequestContext(
-            accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId()));
-    assertQuery(q, change1);
-  }
-
-  @Test
-  public void explicitVisibleTo() throws Exception {
-    TestRepository<Repo> repo = createProject("repo");
-    Change change1 = insert(repo, newChange(repo), userId);
-    Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.DRAFT), userId);
-
-    String q = "project:repo";
-    assertQuery(q, change2, change1);
-
-    // Second user cannot see first user's drafts.
+    // Second user cannot see first user's private change.
     Account.Id user2 =
         accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
     assertQuery(q + " visibleto:" + user2.get(), change1);
@@ -1509,7 +1720,8 @@
     allUsers.update(draftsRef.getName(), draftsRef.getObjectId());
     assertThat(allUsers.getRepository().exactRef(draftsRef.getName())).isNotNull();
 
-    if (PrimaryStorage.of(change) == PrimaryStorage.REVIEW_DB) {
+    if (PrimaryStorage.of(change) == PrimaryStorage.REVIEW_DB
+        && !notesMigration.disableChangeReviewDb()) {
       // Record draft ref in noteDbState as well.
       ReviewDb db = ReviewDbUtil.unwrapDb(this.db);
       change = db.changes().get(id);
@@ -1546,7 +1758,8 @@
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
     Change change2 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
-    insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change3 = insert(repo, newChangeWithStatus(repo, Change.Status.MERGED));
+    Change change4 = insert(repo, newChange(repo));
 
     gApi.accounts()
         .self()
@@ -1560,16 +1773,42 @@
             new StarsInput(
                 new HashSet<>(Arrays.asList(StarredChangesUtil.DEFAULT_LABEL, "green", "blue"))));
 
+    gApi.accounts()
+        .self()
+        .setStars(
+            change4.getId().toString(), new StarsInput(new HashSet<>(Arrays.asList("ignore"))));
+
     // check labeled stars
     assertQuery("star:red", change1);
     assertQuery("star:blue", change2, change1);
-    assertQuery("has:stars", change2, change1);
+    assertQuery("has:stars", change4, change2, change1);
 
     // check default star
     assertQuery("has:star", change2);
     assertQuery("is:starred", change2);
     assertQuery("starredby:self", change2);
     assertQuery("star:" + StarredChangesUtil.DEFAULT_LABEL, change2);
+
+    // check ignored
+    assertQuery("is:ignored", change4);
+    assertQuery("-is:ignored", change3, change2, change1);
+  }
+
+  @Test
+  public void byIgnore() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Account.Id user2 =
+        accountManager.authenticate(AuthRequest.forUser("anotheruser")).getAccountId();
+    Change change1 = insert(repo, newChange(repo), user2);
+    Change change2 = insert(repo, newChange(repo), user2);
+
+    gApi.changes().id(change1.getId().toString()).ignore(true);
+    assertQuery("is:ignored", change1);
+    assertQuery("-is:ignored", change2);
+
+    gApi.changes().id(change1.getId().toString()).ignore(false);
+    assertQuery("is:ignored");
+    assertQuery("-is:ignored", change2, change1);
   }
 
   @Test
@@ -1635,7 +1874,7 @@
     gApi.changes().id(change1.getChangeId()).revision("current").review(ReviewInput.approve());
     gApi.changes().id(change1.getChangeId()).revision("current").submit();
 
-    assertQuery("conflicts:" + change2.getId().get());
+    assertQuery("status:open conflicts:" + change2.getId().get());
     assertQuery("status:open is:mergeable");
     assertQuery("status:open -is:mergeable", change2);
   }
@@ -1807,6 +2046,93 @@
   }
 
   @Test
+  public void reviewerAndCcByEmail() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    String userByEmail = "un.registered@reviewer.com";
+    String userByEmailWithName = "John Doe <" + userByEmail + ">";
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = userByEmailWithName;
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = userByEmailWithName;
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    if (getSchemaVersion() >= 41) {
+      assertQuery("reviewer:\"" + userByEmailWithName + "\"", change1);
+      assertQuery("cc:\"" + userByEmailWithName + "\"", change2);
+
+      // Omitting the name:
+      assertQuery("reviewer:\"" + userByEmail + "\"", change1);
+      assertQuery("cc:\"" + userByEmail + "\"", change2);
+    } else {
+      assertMissingField(ChangeField.REVIEWER_BY_EMAIL);
+
+      assertFailingQuery(
+          "reviewer:\"" + userByEmailWithName + "\"", "User " + userByEmailWithName + " not found");
+      assertFailingQuery(
+          "cc:\"" + userByEmailWithName + "\"", "User " + userByEmailWithName + " not found");
+
+      // Omitting the name:
+      assertFailingQuery("reviewer:\"" + userByEmail + "\"", "User " + userByEmail + " not found");
+      assertFailingQuery("cc:\"" + userByEmail + "\"", "User " + userByEmail + " not found");
+    }
+  }
+
+  @Test
+  public void reviewerAndCcByEmailWithQueryForDifferentUser() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+
+    Project.NameKey project = new Project.NameKey("repo");
+    TestRepository<Repo> repo = createProject(project.get());
+    ConfigInput conf = new ConfigInput();
+    conf.enableReviewerByEmail = InheritableBoolean.TRUE;
+    gApi.projects().name(project.get()).config(conf);
+
+    String userByEmail = "John Doe <un.registered@reviewer.com>";
+
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo));
+    insert(repo, newChange(repo));
+
+    AddReviewerInput rin = new AddReviewerInput();
+    rin.reviewer = userByEmail;
+    rin.state = ReviewerState.REVIEWER;
+    gApi.changes().id(change1.getId().get()).addReviewer(rin);
+
+    rin = new AddReviewerInput();
+    rin.reviewer = userByEmail;
+    rin.state = ReviewerState.CC;
+    gApi.changes().id(change2.getId().get()).addReviewer(rin);
+
+    if (getSchemaVersion() >= 41) {
+      assertQuery("reviewer:\"someone@example.com\"");
+      assertQuery("cc:\"someone@example.com\"");
+    } else {
+      assertMissingField(ChangeField.REVIEWER_BY_EMAIL);
+
+      String someoneEmail = "someone@example.com";
+      assertFailingQuery(
+          "reviewer:\"" + someoneEmail + "\"", "User " + someoneEmail + " not found");
+      assertFailingQuery("cc:\"" + someoneEmail + "\"", "User " + someoneEmail + " not found");
+    }
+  }
+
+  @Test
   public void submitRecords() throws Exception {
     Account.Id user1 = createAccount("user1");
     TestRepository<Repo> repo = createProject("repo");
@@ -1898,9 +2224,28 @@
 
   @Test
   public void byCommitsOnBranchNotMerged() throws Exception {
+    TestRepository<Repo> tr = createProject("repo");
+    testByCommitsOnBranchNotMerged(tr, ImmutableSet.of());
+  }
+
+  @Test
+  public void byCommitsOnBranchNotMergedSkipsMissingChanges() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
+    ObjectId missing =
+        repo.branch(new PatchSet.Id(new Change.Id(987654), 1).toRefName())
+            .commit()
+            .message("No change for this commit")
+            .insertChangeId()
+            .create()
+            .copy();
+    testByCommitsOnBranchNotMerged(repo, ImmutableSet.of(missing));
+  }
+
+  private void testByCommitsOnBranchNotMerged(TestRepository<Repo> repo, Collection<ObjectId> extra)
+      throws Exception {
     int n = 10;
-    List<String> shas = new ArrayList<>(n);
+    List<String> shas = new ArrayList<>(n + extra.size());
+    extra.forEach(i -> shas.add(i.name()));
     List<Integer> expectedIds = new ArrayList<>(n);
     Branch.NameKey dest = null;
     for (int i = 0; i < n; i++) {
@@ -1909,13 +2254,13 @@
       if (dest == null) {
         dest = ins.getChange().getDest();
       }
-      shas.add(ins.getCommit().name());
+      shas.add(ins.getCommitId().name());
       expectedIds.add(ins.getChange().getId().get());
     }
 
     for (int i = 1; i <= 11; i++) {
       Iterable<ChangeData> cds =
-          internalChangeQuery.byCommitsOnBranchNotMerged(repo.getRepository(), db, dest, shas, i);
+          queryProvider.get().byCommitsOnBranchNotMerged(repo.getRepository(), db, dest, shas, i);
       Iterable<Integer> ids = FluentIterable.from(cds).transform(in -> in.getId().get());
       String name = "limit " + i;
       assertThat(ids).named(name).hasSize(n);
@@ -1933,7 +2278,10 @@
     requestContext.setContext(newRequestContext(userId));
     // Use QueryProcessor directly instead of API so we get ChangeDatas back.
     List<ChangeData> cds =
-        queryProcessor.query(queryBuilder.parse(change.getId().toString())).entities();
+        queryProcessorProvider
+            .get()
+            .query(queryBuilder.parse(change.getId().toString()))
+            .entities();
     assertThat(cds).hasSize(1);
 
     ChangeData cd = cds.get(0);
@@ -1963,7 +2311,8 @@
     requestContext.setContext(newRequestContext(userId));
     // Use QueryProcessor directly instead of API so we get ChangeDatas back.
     List<ChangeData> cds =
-        queryProcessor
+        queryProcessorProvider
+            .get()
             .setRequestedFields(
                 ImmutableSet.of(ChangeField.PATCH_SET.getName(), ChangeField.CHANGE.getName()))
             .query(queryBuilder.parse(change.getId().toString()))
@@ -2143,6 +2492,52 @@
   }
 
   @Test
+  public void selfAndMe() throws Exception {
+    TestRepository<Repo> repo = createProject("repo");
+    Change change1 = insert(repo, newChange(repo));
+    Change change2 = insert(repo, newChange(repo), userId);
+    insert(repo, newChange(repo));
+    gApi.accounts().self().starChange(change1.getId().toString());
+    gApi.accounts().self().starChange(change2.getId().toString());
+
+    assertQuery("starredby:self", change2, change1);
+    assertQuery("starredby:me", change2, change1);
+  }
+
+  @Test
+  public void defaultFieldWithManyUsers() throws Exception {
+    for (int i = 0; i < ChangeQueryBuilder.MAX_ACCOUNTS_PER_DEFAULT_FIELD * 2; i++) {
+      createAccount("user" + i, "User " + i, "user" + i + "@example.com", true);
+    }
+    assertQuery("us");
+  }
+
+  @Test
+  public void revertOf() throws Exception {
+    if (getSchemaVersion() < 45) {
+      assertMissingField(ChangeField.REVERT_OF);
+      assertFailingQuery(
+          "revertof:1", "'revertof' operator is not supported by change index version");
+      return;
+    }
+
+    TestRepository<Repo> repo = createProject("repo");
+    // Create two commits and revert second commit (initial commit can't be reverted)
+    Change initial = insert(repo, newChange(repo));
+    gApi.changes().id(initial.getChangeId()).current().review(ReviewInput.approve());
+    gApi.changes().id(initial.getChangeId()).current().submit();
+
+    ChangeInfo changeToRevert =
+        gApi.changes().create(new ChangeInput("repo", "master", "commit to revert")).get();
+    gApi.changes().id(changeToRevert.id).current().review(ReviewInput.approve());
+    gApi.changes().id(changeToRevert.id).current().submit();
+
+    ChangeInfo changeThatReverts = gApi.changes().id(changeToRevert.id).revert().get();
+    assertQueryByIds(
+        "revertof:" + changeToRevert._number, new Change.Id(changeThatReverts._number));
+  }
+
+  @Test
   public void assignee() throws Exception {
     TestRepository<Repo> repo = createProject("repo");
     Change change1 = insert(repo, newChange(repo));
@@ -2253,27 +2648,31 @@
   }
 
   protected ChangeInserter newChange(TestRepository<Repo> repo) throws Exception {
-    return newChange(repo, null, null, null, null);
+    return newChange(repo, null, null, null, null, false);
   }
 
   protected ChangeInserter newChangeForCommit(TestRepository<Repo> repo, RevCommit commit)
       throws Exception {
-    return newChange(repo, commit, null, null, null);
+    return newChange(repo, commit, null, null, null, false);
   }
 
   protected ChangeInserter newChangeForBranch(TestRepository<Repo> repo, String branch)
       throws Exception {
-    return newChange(repo, null, branch, null, null);
+    return newChange(repo, null, branch, null, null, false);
   }
 
   protected ChangeInserter newChangeWithStatus(TestRepository<Repo> repo, Change.Status status)
       throws Exception {
-    return newChange(repo, null, null, status, null);
+    return newChange(repo, null, null, status, null, false);
   }
 
   protected ChangeInserter newChangeWithTopic(TestRepository<Repo> repo, String topic)
       throws Exception {
-    return newChange(repo, null, null, null, topic);
+    return newChange(repo, null, null, null, topic, false);
+  }
+
+  protected ChangeInserter newChangeWorkInProgress(TestRepository<Repo> repo) throws Exception {
+    return newChange(repo, null, null, null, null, true);
   }
 
   protected ChangeInserter newChange(
@@ -2281,7 +2680,8 @@
       @Nullable RevCommit commit,
       @Nullable String branch,
       @Nullable Change.Status status,
-      @Nullable String topic)
+      @Nullable String topic,
+      boolean workInProgress)
       throws Exception {
     if (commit == null) {
       commit = repo.parseBody(repo.commit().message("message").create());
@@ -2296,9 +2696,10 @@
     ChangeInserter ins =
         changeFactory
             .create(id, commit, branch)
-            .setValidatePolicy(CommitValidators.Policy.NONE)
+            .setValidate(false)
             .setStatus(status)
-            .setTopic(topic);
+            .setTopic(topic)
+            .setWorkInProgress(workInProgress);
     return ins;
   }
 
@@ -2334,17 +2735,18 @@
     int n = c.currentPatchSetId().get() + 1;
     RevCommit commit =
         repo.parseBody(repo.commit().message("message").add("file" + n, "contents " + n).create());
-    ChangeControl ctl = changeControlFactory.controlFor(db, c, user);
 
     PatchSetInserter inserter =
         patchSetFactory
-            .create(ctl, new PatchSet.Id(c.getId(), n), commit)
+            .create(changeNotesFactory.createChecked(db, c), new PatchSet.Id(c.getId(), n), commit)
             .setNotify(NotifyHandling.NONE)
             .setFireRevisionCreated(false)
-            .setValidatePolicy(CommitValidators.Policy.NONE);
+            .setValidate(false);
     try (BatchUpdate bu = updateFactory.create(db, c.getProject(), user, TimeUtil.nowTs());
-        ObjectInserter oi = repo.getRepository().newObjectInserter()) {
-      bu.setRepository(repo.getRepository(), repo.getRevWalk(), oi);
+        ObjectInserter oi = repo.getRepository().newObjectInserter();
+        ObjectReader reader = oi.newReader();
+        RevWalk rw = new RevWalk(reader)) {
+      bu.setRepository(repo.getRepository(), rw, oi);
       bu.addOp(c.getId(), inserter);
       bu.execute();
     }
@@ -2386,36 +2788,47 @@
     return assertQuery(newQuery(query), changes);
   }
 
+  protected List<ChangeInfo> assertQueryByIds(Object query, Change.Id... changes) throws Exception {
+    return assertQueryByIds(newQuery(query), changes);
+  }
+
   protected List<ChangeInfo> assertQuery(QueryRequest query, Change... changes) throws Exception {
+    return assertQueryByIds(
+        query, Arrays.stream(changes).map(Change::getId).toArray(Change.Id[]::new));
+  }
+
+  protected List<ChangeInfo> assertQueryByIds(QueryRequest query, Change.Id... changes)
+      throws Exception {
     List<ChangeInfo> result = query.get();
-    Iterable<Integer> ids = ids(result);
+    Iterable<Change.Id> ids = ids(result);
     assertThat(ids)
         .named(format(query, ids, changes))
-        .containsExactlyElementsIn(ids(changes))
+        .containsExactlyElementsIn(Arrays.asList(changes))
         .inOrder();
     return result;
   }
 
-  private String format(QueryRequest query, Iterable<Integer> actualIds, Change... expectedChanges)
+  private String format(
+      QueryRequest query, Iterable<Change.Id> actualIds, Change.Id... expectedChanges)
       throws RestApiException {
     StringBuilder b = new StringBuilder();
     b.append("query '").append(query.getQuery()).append("' with expected changes ");
-    b.append(format(Arrays.stream(expectedChanges).map(Change::getChangeId).iterator()));
+    b.append(format(Arrays.asList(expectedChanges)));
     b.append(" and result ");
     b.append(format(actualIds));
     return b.toString();
   }
 
-  private String format(Iterable<Integer> changeIds) throws RestApiException {
+  private String format(Iterable<Change.Id> changeIds) throws RestApiException {
     return format(changeIds.iterator());
   }
 
-  private String format(Iterator<Integer> changeIds) throws RestApiException {
+  private String format(Iterator<Change.Id> changeIds) throws RestApiException {
     StringBuilder b = new StringBuilder();
     b.append("[");
     while (changeIds.hasNext()) {
-      int id = changeIds.next();
-      ChangeInfo c = gApi.changes().id(id).get();
+      Change.Id id = changeIds.next();
+      ChangeInfo c = gApi.changes().id(id.get()).get();
       b.append("{")
           .append(id)
           .append(" (")
@@ -2438,12 +2851,12 @@
     return b.toString();
   }
 
-  protected static Iterable<Integer> ids(Change... changes) {
-    return FluentIterable.from(Arrays.asList(changes)).transform(in -> in.getId().get());
+  protected static Iterable<Change.Id> ids(Change... changes) {
+    return Arrays.stream(changes).map(c -> c.getId()).collect(toList());
   }
 
-  protected static Iterable<Integer> ids(Iterable<ChangeInfo> changes) {
-    return FluentIterable.from(changes).transform(in -> in._number);
+  protected static Iterable<Change.Id> ids(Iterable<ChangeInfo> changes) {
+    return Streams.stream(changes).map(c -> new Change.Id(c._number)).collect(toList());
   }
 
   protected static long lastUpdatedMs(Change c) {
@@ -2461,4 +2874,47 @@
             Patch.COMMIT_MSG, ImmutableList.<ReviewInput.CommentInput>of(comment));
     gApi.changes().id(changeId).current().review(input);
   }
+
+  private Account.Id createAccount(String username, String fullName, String email, boolean active)
+      throws Exception {
+    try (ManualRequestContext ctx = oneOffRequestContext.open()) {
+      Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
+      if (email != null) {
+        accountManager.link(id, AuthRequest.forEmail(email));
+      }
+      accountsUpdate
+          .create()
+          .update(
+              id,
+              a -> {
+                a.setFullName(fullName);
+                a.setPreferredEmail(email);
+                a.setActive(active);
+              });
+      return id;
+    }
+  }
+
+  protected void assertMissingField(FieldDef<ChangeData, ?> field) {
+    assertThat(getSchema().hasField(field))
+        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+        .isFalse();
+  }
+
+  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
+  protected int getSchemaVersion() {
+    return getSchema().getVersion();
+  }
+
+  protected Schema<ChangeData> getSchema() {
+    return indexes.getSearchIndex().getSchema();
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
index 9362ce9..96c3d24 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/LuceneQueryChangesTest.java
@@ -16,16 +16,29 @@
 
 import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.gerrit.testutil.InMemoryRepositoryManager.Repo;
+import com.google.gerrit.testutil.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.revwalk.RevCommit;
 import org.junit.Test;
 
 public class LuceneQueryChangesTest extends AbstractQueryChangesTest {
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(ChangeSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        ChangeSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
   @Override
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
index 5cf5d23..b057267 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/AbstractQueryGroupsTest.java
@@ -16,14 +16,18 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static java.util.stream.Collectors.toList;
+import static org.junit.Assert.fail;
 
-import com.google.common.collect.ImmutableList;
+import com.google.common.base.CharMatcher;
 import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.api.accounts.AccountInput;
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.api.groups.Groups.QueryRequest;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.index.FieldDef;
+import com.google.gerrit.index.Schema;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
@@ -33,12 +37,17 @@
 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.Accounts;
+import com.google.gerrit.server.account.AccountsUpdate;
 import com.google.gerrit.server.account.AuthRequest;
 import com.google.gerrit.server.account.GroupCache;
 import com.google.gerrit.server.config.AllProjectsName;
+import com.google.gerrit.server.group.GroupsUpdate;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.ServerInitiated;
+import com.google.gerrit.server.index.group.GroupField;
 import com.google.gerrit.server.index.group.GroupIndex;
 import com.google.gerrit.server.index.group.GroupIndexCollection;
-import com.google.gerrit.server.query.account.InternalAccountQuery;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.util.ManualRequestContext;
 import com.google.gerrit.server.util.OneOffRequestContext;
@@ -52,7 +61,6 @@
 import com.google.inject.Provider;
 import com.google.inject.util.Providers;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Locale;
@@ -71,6 +79,10 @@
     return cfg;
   }
 
+  @Inject protected Accounts accounts;
+
+  @Inject protected AccountsUpdate.Server accountsUpdate;
+
   @Inject protected AccountCache accountCache;
 
   @Inject protected AccountManager accountManager;
@@ -89,16 +101,18 @@
 
   @Inject protected OneOffRequestContext oneOffRequestContext;
 
-  @Inject protected InternalAccountQuery internalAccountQuery;
-
   @Inject protected AllProjectsName allProjects;
 
   @Inject protected GroupCache groupCache;
 
+  @Inject @ServerInitiated protected Provider<GroupsUpdate> groupsUpdateProvider;
+
+  @Inject protected GroupIndexCollection indexes;
+
   @Inject private GroupIndexCollection groupIndexes;
 
-  protected Injector injector;
   protected LifecycleManager lifecycle;
+  protected Injector injector;
   protected ReviewDb db;
   protected AccountInfo currentUserInfo;
   protected CurrentUser user;
@@ -113,13 +127,7 @@
     injector.injectMembers(this);
     lifecycle.start();
     initAfterLifecycleStart();
-    db = schemaFactory.open();
-    schemaCreator.create(db);
-
-    Account.Id userId = createAccount("user", "User", "user@example.com", true);
-    user = userFactory.create(userId);
-    requestContext.setContext(newRequestContext(userId));
-    currentUserInfo = gApi.accounts().id(userId.get()).get();
+    setUpDatabase();
   }
 
   @After
@@ -128,6 +136,17 @@
     db.close();
   }
 
+  protected void setUpDatabase() throws Exception {
+    db = schemaFactory.open();
+    schemaCreator.create(db);
+
+    Account.Id userId =
+        createAccountOutsideRequestContext("user", "User", "user@example.com", true);
+    user = userFactory.create(userId);
+    requestContext.setContext(newRequestContext(userId));
+    currentUserInfo = gApi.accounts().id(userId.get()).get();
+  }
+
   protected void initAfterLifecycleStart() throws Exception {}
 
   protected RequestContext newRequestContext(Account.Id requestUserId) {
@@ -165,7 +184,9 @@
     if (lifecycle != null) {
       lifecycle.stop();
     }
-    requestContext.setContext(null);
+    if (requestContext != null) {
+      requestContext.setContext(null);
+    }
     if (db != null) {
       db.close();
     }
@@ -188,9 +209,9 @@
   public void byName() throws Exception {
     assertQuery("name:non-existing");
 
-    GroupInfo group = createGroup(name("group"));
+    GroupInfo group = createGroup(name("Group"));
     assertQuery("name:" + group.name, group);
-    assertQuery("name:" + group.name.toUpperCase(Locale.US), group);
+    assertQuery("name:" + group.name.toLowerCase(Locale.US));
 
     // only exact match
     GroupInfo groupWithHyphen = createGroup(name("group-with-hyphen"));
@@ -200,7 +221,9 @@
 
   @Test
   public void byInname() throws Exception {
-    String namePart = testName.getMethodName();
+    String namePart = getSanitizedMethodName();
+    namePart = CharMatcher.is('_').removeFrom(namePart);
+
     GroupInfo group1 = createGroup("group-" + namePart);
     GroupInfo group2 = createGroup("group-" + namePart + "-2");
     GroupInfo group3 = createGroup("group-" + namePart + "3");
@@ -248,6 +271,58 @@
   }
 
   @Test
+  public void byMember() throws Exception {
+    if (getSchemaVersion() < 4) {
+      assertMissingField(GroupField.MEMBER);
+      assertFailingQuery(
+          "member:someName", "'member' operator is not supported by group index version");
+      return;
+    }
+
+    AccountInfo user1 = createAccount("user1", "User1", "user1@example.com");
+    AccountInfo user2 = createAccount("user2", "User2", "user2@example.com");
+
+    GroupInfo group1 = createGroup(name("group1"), user1);
+    GroupInfo group2 = createGroup(name("group2"), user2);
+    GroupInfo group3 = createGroup(name("group3"), user1);
+
+    assertQuery("member:" + user1.name, group1, group3);
+    assertQuery("member:" + user1.email, group1, group3);
+
+    gApi.groups().id(group3.id).removeMembers(user1.username);
+    gApi.groups().id(group2.id).addMembers(user1.username);
+
+    assertQuery("member:" + user1.name, group1, group2);
+  }
+
+  @Test
+  public void bySubgroups() throws Exception {
+    if (getSchemaVersion() < 4) {
+      assertMissingField(GroupField.SUBGROUP);
+      assertFailingQuery(
+          "subgroup:someGroupName", "'subgroup' operator is not supported by group index version");
+      return;
+    }
+
+    GroupInfo superParentGroup = createGroup(name("superParentGroup"));
+    GroupInfo parentGroup1 = createGroup(name("parentGroup1"));
+    GroupInfo parentGroup2 = createGroup(name("parentGroup2"));
+    GroupInfo subGroup = createGroup(name("subGroup"));
+
+    gApi.groups().id(superParentGroup.id).addGroups(parentGroup1.id, parentGroup2.id);
+    gApi.groups().id(parentGroup1.id).addGroups(subGroup.id);
+    gApi.groups().id(parentGroup2.id).addGroups(subGroup.id);
+
+    assertQuery("subgroup:" + subGroup.id, parentGroup1, parentGroup2);
+    assertQuery("subgroup:" + parentGroup1.id, superParentGroup);
+
+    gApi.groups().id(superParentGroup.id).addGroups(subGroup.id);
+    gApi.groups().id(parentGroup1.id).removeGroups(subGroup.id);
+
+    assertQuery("subgroup:" + subGroup.id, superParentGroup, parentGroup2);
+  }
+
+  @Test
   public void byDefaultField() throws Exception {
     GroupInfo group1 = createGroup(name("foo-group"));
     GroupInfo group2 = createGroup(name("group2"));
@@ -301,9 +376,10 @@
 
     // update group in the database so that group index is stale
     String newDescription = "barY";
-    AccountGroup group = db.accountGroups().get(new AccountGroup.Id(group1.groupId));
-    group.setDescription(newDescription);
-    db.accountGroups().update(Collections.singleton(group));
+    AccountGroup.UUID groupUuid = new AccountGroup.UUID(group1.id);
+    groupsUpdateProvider
+        .get()
+        .updateGroupInDb(db, groupUuid, group -> group.setDescription(newDescription));
 
     assertQuery("description:" + group1.description, group1);
     assertQuery("description:" + newDescription);
@@ -326,23 +402,36 @@
     assertQuery(query);
   }
 
-  private Account.Id createAccount(String username, String fullName, String email, boolean active)
-      throws Exception {
+  private Account.Id createAccountOutsideRequestContext(
+      String username, String fullName, String email, boolean active) throws Exception {
     try (ManualRequestContext ctx = oneOffRequestContext.open()) {
       Account.Id id = accountManager.authenticate(AuthRequest.forUser(username)).getAccountId();
       if (email != null) {
         accountManager.link(id, AuthRequest.forEmail(email));
       }
-      Account a = db.accounts().get(id);
-      a.setFullName(fullName);
-      a.setPreferredEmail(email);
-      a.setActive(active);
-      db.accounts().update(ImmutableList.of(a));
-      accountCache.evict(id);
+      accountsUpdate
+          .create()
+          .update(
+              id,
+              a -> {
+                a.setFullName(fullName);
+                a.setPreferredEmail(email);
+                a.setActive(active);
+              });
       return id;
     }
   }
 
+  protected AccountInfo createAccount(String username, String fullName, String email)
+      throws Exception {
+    String uniqueName = name(username);
+    AccountInput accountInput = new AccountInput();
+    accountInput.username = uniqueName;
+    accountInput.name = fullName;
+    accountInput.email = email;
+    return gApi.accounts().create(accountInput).get();
+  }
+
   protected GroupInfo createGroup(String name, AccountInfo... members) throws Exception {
     return createGroupWithDescription(name, null, members);
   }
@@ -459,6 +548,30 @@
     if (name == null) {
       return null;
     }
-    return name + "_" + testName.getMethodName().toLowerCase();
+
+    return name + "_" + getSanitizedMethodName();
+  }
+
+  protected void assertMissingField(FieldDef<InternalGroup, ?> field) {
+    assertThat(getSchema().hasField(field))
+        .named("schema %s has field %s", getSchemaVersion(), field.getName())
+        .isFalse();
+  }
+
+  protected void assertFailingQuery(String query, String expectedMessage) throws Exception {
+    try {
+      assertQuery(query);
+      fail("expected BadRequestException for query '" + query + "'");
+    } catch (BadRequestException e) {
+      assertThat(e.getMessage()).isEqualTo(expectedMessage);
+    }
+  }
+
+  protected int getSchemaVersion() {
+    return getSchema().getVersion();
+  }
+
+  protected Schema<InternalGroup> getSchema() {
+    return indexes.getSearchIndex().getSchema();
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
index 0551e92..001a897 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/group/LuceneQueryGroupsTest.java
@@ -14,12 +14,25 @@
 
 package com.google.gerrit.server.query.group;
 
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
+import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gerrit.testutil.IndexVersions;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
+import java.util.List;
+import java.util.Map;
 import org.eclipse.jgit.lib.Config;
 
 public class LuceneQueryGroupsTest extends AbstractQueryGroupsTest {
+  @ConfigSuite.Configs
+  public static Map<String, Config> againstPreviousIndexVersion() {
+    // the current schema version is already tested by the inherited default config suite
+    List<Integer> schemaVersions = IndexVersions.getWithoutLatest(GroupSchemaDefinitions.INSTANCE);
+    return IndexVersions.asConfigMap(
+        GroupSchemaDefinitions.INSTANCE, schemaVersions, "againstIndexVersion", defaultConfig());
+  }
+
   @Override
   protected Injector createInjector() {
     Config luceneConfig = new Config(config);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java
index 76bee6f..ac58134 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/HANATest.java
@@ -33,11 +33,8 @@
 
   @Test
   public void getUrl() throws Exception {
-    config.setString("database", null, "instance", "3");
-    assertThat(hana.getUrl()).isEqualTo("jdbc:sap://my.host:30315");
-
-    config.setString("database", null, "instance", "77");
-    assertThat(hana.getUrl()).isEqualTo("jdbc:sap://my.host:37715");
+    config.setString("database", null, "port", "4242");
+    assertThat(hana.getUrl()).isEqualTo("jdbc:sap://my.host:4242");
   }
 
   @Test
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
index 7eda3cc..3cd1696 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaCreatorTest.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.data.LabelFunction;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.LabelValue;
@@ -105,7 +106,7 @@
     assertThat(codeReview).isNotNull();
     assertThat(codeReview.getName()).isEqualTo("Code-Review");
     assertThat(codeReview.getDefaultValue()).isEqualTo(0);
-    assertThat(codeReview.getFunctionName()).isEqualTo("MaxWithBlock");
+    assertThat(codeReview.getFunction()).isEqualTo(LabelFunction.MAX_WITH_BLOCK);
     assertThat(codeReview.isCopyMinScore()).isTrue();
     assertValueRange(codeReview, 2, 1, 0, -1, -2);
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
index 9a32365..5b86f46 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/SchemaUpdaterTest.java
@@ -30,21 +30,21 @@
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.notedb.ConfigNotesMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryH2Type;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gerrit.testutil.TestUpdateUI;
 import com.google.gwtorm.server.OrmException;
 import com.google.gwtorm.server.SchemaFactory;
-import com.google.gwtorm.server.StatementExecutor;
 import com.google.inject.Guice;
+import com.google.inject.Key;
 import com.google.inject.ProvisionException;
 import com.google.inject.TypeLiteral;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.nio.file.Path;
 import java.nio.file.Paths;
-import java.util.List;
 import java.util.UUID;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -82,7 +82,10 @@
                 new FactoryModule() {
                   @Override
                   protected void configure() {
-                    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {}).toInstance(db);
+                    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+                        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+                    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+                    bind(Key.get(schemaFactory, ReviewDbFactory.class)).toInstance(db);
                     bind(SitePaths.class).toInstance(paths);
 
                     Config cfg = new Config();
@@ -109,7 +112,7 @@
                     bind(DataSourceType.class).to(InMemoryH2Type.class);
 
                     bind(SystemGroupBackend.class);
-                    install(new ConfigNotesMigration.Module());
+                    install(new NotesMigration.Module());
                   }
                 })
             .getInstance(SchemaUpdater.class);
@@ -129,28 +132,7 @@
       }
     }
 
-    u.update(
-        new UpdateUI() {
-          @Override
-          public void message(String msg) {}
-
-          @Override
-          public boolean yesno(boolean def, String msg) {
-            return def;
-          }
-
-          @Override
-          public boolean isBatch() {
-            return true;
-          }
-
-          @Override
-          public void pruneSchema(StatementExecutor e, List<String> pruneList) throws OrmException {
-            for (String sql : pruneList) {
-              e.execute(sql);
-            }
-          }
-        });
+    u.update(new TestUpdateUI());
 
     db.assertSchemaVersion();
     final SystemConfig sc = db.getSystemConfig();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_150_to_151_Test.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_150_to_151_Test.java
new file mode 100644
index 0000000..dcd1ae5
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_150_to_151_Test.java
@@ -0,0 +1,102 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.AccountGroup.Id;
+import com.google.gerrit.reviewdb.client.AccountGroupMemberAudit;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.group.CreateGroup;
+import com.google.gerrit.testutil.SchemaUpgradeTestEnvironment;
+import com.google.gerrit.testutil.TestUpdateUI;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Inject;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.Month;
+import java.time.ZoneOffset;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class Schema_150_to_151_Test {
+
+  @Rule public SchemaUpgradeTestEnvironment testEnv = new SchemaUpgradeTestEnvironment();
+
+  @Inject private CreateGroup.Factory createGroupFactory;
+  @Inject private Schema_151 schema151;
+
+  private ReviewDb db;
+
+  @Before
+  public void setUp() throws Exception {
+    testEnv.getInjector().injectMembers(this);
+    db = testEnv.getDb();
+  }
+
+  @Test
+  public void createdOnIsPopulatedForGroupsCreatedAfterAudit() throws Exception {
+    Timestamp testStartTime = TimeUtil.nowTs();
+    AccountGroup.Id groupId = createGroup("Group for schema migration");
+    setCreatedOnToVeryOldTimestamp(groupId);
+
+    schema151.migrateData(db, new TestUpdateUI());
+
+    AccountGroup group = db.accountGroups().get(groupId);
+    assertThat(group.getCreatedOn()).isAtLeast(testStartTime);
+  }
+
+  @Test
+  public void createdOnIsPopulatedForGroupsCreatedBeforeAudit() throws Exception {
+    AccountGroup.Id groupId = createGroup("Ancient group for schema migration");
+    setCreatedOnToVeryOldTimestamp(groupId);
+    removeAuditEntriesFor(groupId);
+
+    schema151.migrateData(db, new TestUpdateUI());
+
+    AccountGroup group = db.accountGroups().get(groupId);
+    assertThat(group.getCreatedOn()).isEqualTo(AccountGroup.auditCreationInstantTs());
+  }
+
+  private AccountGroup.Id createGroup(String name) throws Exception {
+    GroupInput groupInput = new GroupInput();
+    groupInput.name = name;
+    GroupInfo groupInfo =
+        createGroupFactory.create(name).apply(TopLevelResource.INSTANCE, groupInput);
+    return new Id(groupInfo.groupId);
+  }
+
+  private void setCreatedOnToVeryOldTimestamp(Id groupId) throws OrmException {
+    AccountGroup group = db.accountGroups().get(groupId);
+    Instant instant = LocalDateTime.of(1800, Month.JANUARY, 1, 0, 0).toInstant(ZoneOffset.UTC);
+    group.setCreatedOn(Timestamp.from(instant));
+    db.accountGroups().update(ImmutableList.of(group));
+  }
+
+  private void removeAuditEntriesFor(AccountGroup.Id groupId) throws Exception {
+    ResultSet<AccountGroupMemberAudit> groupMemberAudits =
+        db.accountGroupMembersAudit().byGroup(groupId);
+    db.accountGroupMembersAudit().delete(groupMemberAudits);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_159_to_160_Test.java b/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
new file mode 100644
index 0000000..8bb68b4
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/schema/Schema_159_to_160_Test.java
@@ -0,0 +1,223 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.schema;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+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.reviewdb.client.RefNames.REFS_USERS_DEFAULT;
+import static com.google.gerrit.server.account.VersionedAccountPreferences.PREFERENCES;
+import static com.google.gerrit.server.git.UserConfigSections.KEY_URL;
+import static com.google.gerrit.server.git.UserConfigSections.MY;
+import static com.google.gerrit.server.schema.Schema_160.DEFAULT_DRAFT_ITEMS;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.api.GerritApi;
+import com.google.gerrit.extensions.client.GeneralPreferencesInfo;
+import com.google.gerrit.extensions.client.MenuItem;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
+import com.google.gerrit.server.account.VersionedAccountPreferences;
+import com.google.gerrit.server.config.AllUsersName;
+import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.testutil.SchemaUpgradeTestEnvironment;
+import com.google.gerrit.testutil.TestUpdateUI;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.List;
+import java.util.Optional;
+import java.util.function.Supplier;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.BlobBasedConfig;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.lib.Repository;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+
+public class Schema_159_to_160_Test {
+  @Rule public SchemaUpgradeTestEnvironment testEnv = new SchemaUpgradeTestEnvironment();
+
+  @Inject private AccountCache accountCache;
+  @Inject private AllUsersName allUsersName;
+  @Inject private GerritApi gApi;
+  @Inject private GitRepositoryManager repoManager;
+  @Inject private Provider<IdentifiedUser> userProvider;
+  @Inject private Schema_160 schema160;
+
+  private ReviewDb db;
+  private Account.Id accountId;
+
+  @Before
+  public void setUp() throws Exception {
+    testEnv.getInjector().injectMembers(this);
+    db = testEnv.getDb();
+    accountId = userProvider.get().getAccountId();
+  }
+
+  @Test
+  public void skipUnmodified() throws Exception {
+    ObjectId oldMetaId = metaRef(accountId);
+    ImmutableSet<String> fromNoteDb = myMenusFromNoteDb(accountId);
+    ImmutableSet<String> fromApi = myMenusFromApi(accountId);
+    for (String item : DEFAULT_DRAFT_ITEMS) {
+      assertThat(fromNoteDb).doesNotContain(item);
+      assertThat(fromApi).doesNotContain(item);
+    }
+
+    schema160.migrateData(db, new TestUpdateUI());
+
+    assertThat(metaRef(accountId)).isEqualTo(oldMetaId);
+  }
+
+  @Test
+  public void deleteItems() throws Exception {
+    ObjectId oldMetaId = metaRef(accountId);
+    ImmutableSet<String> defaultNames = myMenusFromApi(accountId);
+
+    GeneralPreferencesInfo prefs = gApi.accounts().id(accountId.get()).getPreferences();
+    prefs.my.add(0, new MenuItem("Something else", DEFAULT_DRAFT_ITEMS.get(0) + "+is:mergeable"));
+    for (int i = 0; i < DEFAULT_DRAFT_ITEMS.size(); i++) {
+      prefs.my.add(new MenuItem("Draft entry " + i, DEFAULT_DRAFT_ITEMS.get(i)));
+    }
+    gApi.accounts().id(accountId.get()).setPreferences(prefs);
+
+    List<String> oldNames =
+        ImmutableList.<String>builder()
+            .add("Something else")
+            .addAll(defaultNames)
+            .add("Draft entry 0")
+            .add("Draft entry 1")
+            .add("Draft entry 2")
+            .add("Draft entry 3")
+            .build();
+    assertThat(myMenusFromApi(accountId)).containsExactlyElementsIn(oldNames).inOrder();
+
+    schema160.migrateData(db, new TestUpdateUI());
+    accountCache.evict(accountId);
+    testEnv.setApiUser(accountId);
+
+    assertThat(metaRef(accountId)).isNotEqualTo(oldMetaId);
+
+    List<String> newNames =
+        ImmutableList.<String>builder().add("Something else").addAll(defaultNames).build();
+    assertThat(myMenusFromNoteDb(accountId)).containsExactlyElementsIn(newNames).inOrder();
+    assertThat(myMenusFromApi(accountId)).containsExactlyElementsIn(newNames).inOrder();
+  }
+
+  @Test
+  public void skipNonExistentRefsUsersDefault() throws Exception {
+    assertThat(readRef(REFS_USERS_DEFAULT)).isEmpty();
+    schema160.migrateData(db, new TestUpdateUI());
+    assertThat(readRef(REFS_USERS_DEFAULT)).isEmpty();
+  }
+
+  @Test
+  public void deleteDefaultItem() throws Exception {
+    assertThat(readRef(REFS_USERS_DEFAULT)).isEmpty();
+    ImmutableSet<String> defaultNames = defaultMenusFromApi();
+
+    // Setting *any* preference causes preferences.config to contain the full set of "my" sections.
+    // This mimics real-world behavior prior to the 2.15 upgrade; see Issue 8439 for details.
+    GeneralPreferencesInfo prefs = gApi.config().server().getDefaultPreferences();
+    prefs.signedOffBy = !firstNonNull(prefs.signedOffBy, false);
+    gApi.config().server().setDefaultPreferences(prefs);
+
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      Config cfg = new BlobBasedConfig(null, repo, readRef(REFS_USERS_DEFAULT).get(), PREFERENCES);
+      assertThat(cfg.getSubsections("my")).containsExactlyElementsIn(defaultNames).inOrder();
+
+      // Add more defaults directly in git, the SetPreferences endpoint doesn't respect the "my"
+      // field in the input in 2.15 and earlier.
+      cfg.setString("my", "Drafts", "url", "#/q/owner:self+is:draft");
+      cfg.setString("my", "Something else", "url", "#/q/owner:self+is:draft+is:mergeable");
+      cfg.setString("my", "Totally not drafts", "url", "#/q/owner:self+is:draft");
+      new TestRepository<>(repo)
+          .branch(REFS_USERS_DEFAULT)
+          .commit()
+          .add(PREFERENCES, cfg.toText())
+          .create();
+    }
+
+    List<String> oldNames =
+        ImmutableList.<String>builder()
+            .addAll(defaultNames)
+            .add("Drafts")
+            .add("Something else")
+            .add("Totally not drafts")
+            .build();
+    assertThat(defaultMenusFromApi()).containsExactlyElementsIn(oldNames).inOrder();
+
+    schema160.migrateData(db, new TestUpdateUI());
+
+    assertThat(readRef(REFS_USERS_DEFAULT)).isPresent();
+
+    List<String> newNames =
+        ImmutableList.<String>builder().addAll(defaultNames).add("Something else").build();
+    assertThat(myMenusFromNoteDb(VersionedAccountPreferences::forDefault).keySet())
+        .containsExactlyElementsIn(newNames)
+        .inOrder();
+    assertThat(defaultMenusFromApi()).containsExactlyElementsIn(newNames).inOrder();
+  }
+
+  private ImmutableSet<String> myMenusFromNoteDb(Account.Id id) throws Exception {
+    return myMenusFromNoteDb(() -> VersionedAccountPreferences.forUser(id)).keySet();
+  }
+
+  // Raw config values, bypassing the defaults set by GeneralPreferencesLoader.
+  private ImmutableMap<String, String> myMenusFromNoteDb(
+      Supplier<VersionedAccountPreferences> prefsSupplier) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      VersionedAccountPreferences prefs = prefsSupplier.get();
+      prefs.load(repo);
+      Config cfg = prefs.getConfig();
+      return cfg.getSubsections(MY)
+          .stream()
+          .collect(toImmutableMap(i -> i, i -> cfg.getString(MY, i, KEY_URL)));
+    }
+  }
+
+  private ImmutableSet<String> myMenusFromApi(Account.Id id) throws Exception {
+    return myMenus(gApi.accounts().id(id.get()).getPreferences()).keySet();
+  }
+
+  private ImmutableSet<String> defaultMenusFromApi() throws Exception {
+    return myMenus(gApi.config().server().getDefaultPreferences()).keySet();
+  }
+
+  private static ImmutableMap<String, String> myMenus(GeneralPreferencesInfo prefs) {
+
+    return prefs.my.stream().collect(toImmutableMap(i -> i.name, i -> i.url));
+  }
+
+  private ObjectId metaRef(Account.Id id) throws Exception {
+    return readRef(RefNames.refsUsers(id))
+        .orElseThrow(() -> new AssertionError("missing ref for account " + id));
+  }
+
+  private Optional<ObjectId> readRef(String ref) throws Exception {
+    try (Repository repo = repoManager.openRepository(allUsersName)) {
+      return Optional.ofNullable(repo.exactRef(ref)).map(Ref::getObjectId);
+    }
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
index f53a59b..bd54ddc 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/CommitMsgHookTest.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.server.tools.hooks;
 
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertTrue;
@@ -621,11 +621,11 @@
                 "RealTag: abc\n"));
   }
 
-  private void hookDoesNotModify(final String in) throws Exception {
+  private void hookDoesNotModify(String in) throws Exception {
     assertEquals(in, call(in));
   }
 
-  private String call(final String body) throws Exception {
+  private String call(String body) throws Exception {
     final File tmp = write(body);
     try {
       final File hook = getHook("commit-msg");
@@ -636,7 +636,7 @@
     }
   }
 
-  private DirCacheEntry file(final String name) throws IOException {
+  private DirCacheEntry file(String name) throws IOException {
     try (ObjectInserter oi = repository.newObjectInserter()) {
       final DirCacheEntry e = new DirCacheEntry(name);
       e.setFileMode(FileMode.REGULAR_FILE);
@@ -658,8 +658,7 @@
       final RefUpdate ref = repository.updateRef(Constants.HEAD);
       ref.setNewObjectId(commitId);
       Result result = ref.forceUpdate();
-      assert_()
-          .withFailureMessage(Constants.HEAD + " did not change: " + ref.getResult())
+      assertWithMessage(Constants.HEAD + " did not change: " + ref.getResult())
           .that(result)
           .isAnyOf(Result.FAST_FORWARD, Result.FORCED, Result.NEW, Result.NO_CHANGE);
     }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java b/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java
index 3d4a1a0..ac1ce53 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/tools/hooks/HookTestCase.java
@@ -50,7 +50,7 @@
 
 package com.google.gerrit.server.tools.hooks;
 
-import static com.google.common.truth.Truth.assert_;
+import static com.google.common.truth.Truth.assertWithMessage;
 
 import com.google.common.io.ByteStreams;
 import java.io.File;
@@ -94,7 +94,7 @@
     cleanup.clear();
   }
 
-  protected File getHook(final String name) throws IOException {
+  protected File getHook(String name) throws IOException {
     File hook = hooks.get(name);
     if (hook != null) {
       return hook;
@@ -104,14 +104,14 @@
     String path = scproot + "/hooks/" + name;
     String errorMessage = "Cannot locate " + path + " in CLASSPATH";
     URL url = cl().getResource(path);
-    assert_().withFailureMessage(errorMessage).that(url).isNotNull();
+    assertWithMessage(errorMessage).that(url).isNotNull();
 
     String protocol = url.getProtocol();
-    assert_().withFailureMessage("Cannot invoke " + url).that(protocol).isAnyOf("file", "jar");
+    assertWithMessage("Cannot invoke " + url).that(protocol).isAnyOf("file", "jar");
 
     if ("file".equals(protocol)) {
       hook = new File(url.getPath());
-      assert_().withFailureMessage(errorMessage).that(hook.isFile()).isTrue();
+      assertWithMessage(errorMessage).that(hook.isFile()).isTrue();
       long time = hook.lastModified();
       hook.setExecutable(true);
       hook.setLastModified(time);
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
index 892d037..dba3b3d 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/update/BatchUpdateTest.java
@@ -1,3 +1,17 @@
+// 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.update;
 
 import static org.junit.Assert.assertEquals;
@@ -17,6 +31,7 @@
 import com.google.gerrit.testutil.InMemoryDatabase;
 import com.google.gerrit.testutil.InMemoryModule;
 import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Guice;
 import com.google.inject.Inject;
 import com.google.inject.Injector;
@@ -25,26 +40,22 @@
 import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.eclipse.jgit.transport.ReceiveCommand;
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
 public class BatchUpdateTest {
   @Inject private AccountManager accountManager;
-
   @Inject private IdentifiedUser.GenericFactory userFactory;
-
-  @Inject private InMemoryDatabase schemaFactory;
-
+  @Inject private SchemaFactory<ReviewDb> schemaFactory;
   @Inject private InMemoryRepositoryManager repoManager;
-
   @Inject private SchemaCreator schemaCreator;
-
   @Inject private ThreadLocalRequestContext requestContext;
-
   @Inject private BatchUpdate.Factory batchUpdateFactory;
 
+  // Only for use in setting up/tearing down injector; other users should use schemaFactory.
+  @Inject private InMemoryDatabase inMemoryDatabase;
+
   private LifecycleManager lifecycle;
   private ReviewDb db;
   private TestRepository<InMemoryRepository> repo;
@@ -59,8 +70,10 @@
     lifecycle.add(injector);
     lifecycle.start();
 
+    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
+      schemaCreator.create(underlyingDb);
+    }
     db = schemaFactory.open();
-    schemaCreator.create(db);
     Account.Id userId = accountManager.authenticate(AuthRequest.forUser("user")).getAccountId();
     user = userFactory.create(userId);
 
@@ -95,7 +108,7 @@
     if (db != null) {
       db.close();
     }
-    InMemoryDatabase.drop(schemaFactory);
+    InMemoryDatabase.drop(inMemoryDatabase);
   }
 
   @Test
@@ -108,9 +121,7 @@
           new RepoOnlyOp() {
             @Override
             public void updateRepo(RepoContext ctx) throws Exception {
-              ctx.addRefUpdate(
-                  new ReceiveCommand(
-                      masterCommit.getId(), branchCommit.getId(), "refs/heads/master"));
+              ctx.addRefUpdate(masterCommit.getId(), branchCommit.getId(), "refs/heads/master");
             }
           });
       bu.execute();
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/RefUpdateUtilTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/RefUpdateUtilTest.java
new file mode 100644
index 0000000..286827a
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/update/RefUpdateUtilTest.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.server.update;
+
+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 com.google.common.collect.ImmutableList;
+import com.google.gerrit.server.git.LockFailureException;
+import java.io.IOException;
+import java.util.function.Consumer;
+import org.eclipse.jgit.internal.storage.dfs.DfsRepositoryDescription;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.lib.BatchRefUpdate;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class RefUpdateUtilTest {
+  private static final Consumer<ReceiveCommand> OK = c -> c.setResult(ReceiveCommand.Result.OK);
+  private static final Consumer<ReceiveCommand> LOCK_FAILURE =
+      c -> c.setResult(ReceiveCommand.Result.LOCK_FAILURE);
+  private static final Consumer<ReceiveCommand> REJECTED =
+      c -> c.setResult(ReceiveCommand.Result.REJECTED_OTHER_REASON);
+  private static final Consumer<ReceiveCommand> ABORTED =
+      c -> {
+        c.setResult(ReceiveCommand.Result.NOT_ATTEMPTED);
+        ReceiveCommand.abort(ImmutableList.of(c));
+        checkState(
+            c.getResult() != ReceiveCommand.Result.NOT_ATTEMPTED
+                && c.getResult() != ReceiveCommand.Result.LOCK_FAILURE
+                && c.getResult() != ReceiveCommand.Result.OK,
+            "unexpected state after abort: %s",
+            c);
+      };
+
+  @Test
+  public void checkBatchRefUpdateResults() throws Exception {
+    checkResults(OK);
+    checkResults(OK, OK);
+
+    assertIoException(REJECTED);
+    assertIoException(OK, REJECTED);
+    assertIoException(LOCK_FAILURE, REJECTED);
+    assertIoException(LOCK_FAILURE, OK);
+    assertIoException(LOCK_FAILURE, REJECTED, OK);
+    assertIoException(LOCK_FAILURE, LOCK_FAILURE, REJECTED);
+    assertIoException(LOCK_FAILURE, ABORTED, REJECTED);
+    assertIoException(LOCK_FAILURE, ABORTED, OK);
+
+    assertLockFailureException(LOCK_FAILURE);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED);
+    assertLockFailureException(LOCK_FAILURE, LOCK_FAILURE, ABORTED, ABORTED);
+    assertLockFailureException(ABORTED);
+    assertLockFailureException(ABORTED, ABORTED);
+  }
+
+  @SafeVarargs
+  private static void checkResults(Consumer<ReceiveCommand>... resultSetters) throws Exception {
+    RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
+  }
+
+  @SafeVarargs
+  private static void assertIoException(Consumer<ReceiveCommand>... resultSetters) {
+    try {
+      RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
+      assert_().fail("expected IOException");
+    } catch (IOException e) {
+      assertThat(e).isNotInstanceOf(LockFailureException.class);
+    }
+  }
+
+  @SafeVarargs
+  private static void assertLockFailureException(Consumer<ReceiveCommand>... resultSetters)
+      throws Exception {
+    try {
+      RefUpdateUtil.checkResults(newBatchRefUpdate(resultSetters));
+      assert_().fail("expected LockFailureException");
+    } catch (LockFailureException e) {
+      // Expected.
+    }
+  }
+
+  @SafeVarargs
+  private static BatchRefUpdate newBatchRefUpdate(Consumer<ReceiveCommand>... resultSetters) {
+    try (Repository repo = new InMemoryRepository(new DfsRepositoryDescription("repo"))) {
+      BatchRefUpdate bru = repo.getRefDatabase().newBatchUpdate();
+      for (int i = 0; i < resultSetters.length; i++) {
+        ReceiveCommand cmd =
+            new ReceiveCommand(
+                ObjectId.fromString(String.format("%039x1", i)),
+                ObjectId.fromString(String.format("%039x2", i)),
+                "refs/heads/branch" + i);
+        bru.addCommand(cmd);
+        resultSetters[i].accept(cmd);
+      }
+      return bru;
+    }
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.java
new file mode 100644
index 0000000..0ea9f83
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/update/RepoViewTest.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.server.update;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
+import static org.eclipse.jgit.lib.Constants.R_HEADS;
+
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.testutil.InMemoryRepositoryManager;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.lib.StoredConfig;
+import org.eclipse.jgit.transport.ReceiveCommand;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class RepoViewTest {
+  private static final String MASTER = "refs/heads/master";
+  private static final String BRANCH = "refs/heads/branch";
+
+  private Repository repo;
+  private TestRepository<?> tr;
+  private RepoView view;
+
+  @Before
+  public void setUp() throws Exception {
+    InMemoryRepositoryManager repoManager = new InMemoryRepositoryManager();
+    Project.NameKey project = new Project.NameKey("project");
+    repo = repoManager.createRepository(project);
+    tr = new TestRepository<>(repo);
+    tr.branch(MASTER).commit().create();
+    view = new RepoView(repoManager, project);
+  }
+
+  @After
+  public void tearDown() {
+    view.close();
+    repo.close();
+  }
+
+  @Test
+  public void getConfigIsDefensiveCopy() throws Exception {
+    StoredConfig orig = repo.getConfig();
+    orig.setString("a", "config", "option", "yes");
+    orig.save();
+
+    Config copy = view.getConfig();
+    copy.setString("a", "config", "option", "no");
+
+    assertThat(orig.getString("a", "config", "option")).isEqualTo("yes");
+    assertThat(repo.getConfig().getString("a", "config", "option")).isEqualTo("yes");
+  }
+
+  @Test
+  public void getRef() throws Exception {
+    ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(oldMaster);
+    assertThat(repo.exactRef(BRANCH)).isNull();
+    assertThat(view.getRef(MASTER)).hasValue(oldMaster);
+    assertThat(view.getRef(BRANCH)).isEmpty();
+
+    tr.branch(MASTER).commit().create();
+    tr.branch(BRANCH).commit().create();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isNotEqualTo(oldMaster);
+    assertThat(repo.exactRef(BRANCH)).isNotNull();
+    assertThat(view.getRef(MASTER)).hasValue(oldMaster);
+    assertThat(view.getRef(BRANCH)).isEmpty();
+  }
+
+  @Test
+  public void getRefsRescansWhenNotCaching() throws Exception {
+    ObjectId oldMaster = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster);
+
+    ObjectId newBranch = tr.branch(BRANCH).commit().create();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", oldMaster, "branch", newBranch);
+  }
+
+  @Test
+  public void getRefsUsesCachedValueMatchingGetRef() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+    assertThat(view.getRef(MASTER)).hasValue(master1);
+
+    // Doesn't reflect new value for master.
+    ObjectId master2 = tr.branch(MASTER).commit().create();
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+
+    // Branch wasn't previously cached, so does reflect new value.
+    ObjectId branch1 = tr.branch(BRANCH).commit().create();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
+
+    // Looking up branch causes it to be cached.
+    assertThat(view.getRef(BRANCH)).hasValue(branch1);
+    ObjectId branch2 = tr.branch(BRANCH).commit().create();
+    assertThat(repo.exactRef(BRANCH).getObjectId()).isEqualTo(branch2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1, "branch", branch1);
+  }
+
+  @Test
+  public void getRefsReflectsCommands() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master1);
+
+    ObjectId master2 = tr.commit().create();
+    view.getCommands().add(new ReceiveCommand(master1, master2, MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).hasValue(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master2);
+
+    view.getCommands().add(new ReceiveCommand(master2, ObjectId.zeroId(), MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).isEmpty();
+    assertThat(view.getRefs(R_HEADS)).isEmpty();
+  }
+
+  @Test
+  public void getRefsOverwritesCachedValueWithCommand() throws Exception {
+    ObjectId master1 = repo.exactRef(MASTER).getObjectId();
+    assertThat(view.getRef(MASTER)).hasValue(master1);
+
+    ObjectId master2 = tr.commit().create();
+    view.getCommands().add(new ReceiveCommand(master1, master2, MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).hasValue(master2);
+    assertThat(view.getRefs(R_HEADS)).containsExactly("master", master2);
+
+    view.getCommands().add(new ReceiveCommand(master2, ObjectId.zeroId(), MASTER));
+
+    assertThat(repo.exactRef(MASTER).getObjectId()).isEqualTo(master1);
+    assertThat(view.getRef(MASTER)).isEmpty();
+    assertThat(view.getRefs(R_HEADS)).isEmpty();
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/util/LabelVoteTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/util/LabelVoteTest.java
index 0592041..9069928 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/util/LabelVoteTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/util/LabelVoteTest.java
@@ -14,75 +14,79 @@
 
 package com.google.gerrit.server.util;
 
-import static org.junit.Assert.assertEquals;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assert_;
+import static com.google.gerrit.server.util.LabelVote.parse;
+import static com.google.gerrit.server.util.LabelVote.parseWithEquals;
 
 import org.junit.Test;
 
 public class LabelVoteTest {
   @Test
-  public void parse() {
-    LabelVote l;
-    l = LabelVote.parse("Code-Review-2");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) -2, l.value());
-    l = LabelVote.parse("Code-Review-1");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) -1, l.value());
-    l = LabelVote.parse("-Code-Review");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) 0, l.value());
-    l = LabelVote.parse("Code-Review");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) 1, l.value());
-    l = LabelVote.parse("Code-Review+1");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) 1, l.value());
-    l = LabelVote.parse("Code-Review+2");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) 2, l.value());
+  public void labelVoteParse() {
+    assertLabelVoteEquals(parse("Code-Review-2"), "Code-Review", -2);
+    assertLabelVoteEquals(parse("Code-Review-1"), "Code-Review", -1);
+    assertLabelVoteEquals(parse("-Code-Review"), "Code-Review", 0);
+    assertLabelVoteEquals(parse("Code-Review"), "Code-Review", 1);
+    assertLabelVoteEquals(parse("Code-Review+1"), "Code-Review", 1);
+    assertLabelVoteEquals(parse("Code-Review+2"), "Code-Review", 2);
   }
 
   @Test
-  public void format() {
-    assertEquals("Code-Review-2", LabelVote.parse("Code-Review-2").format());
-    assertEquals("Code-Review-1", LabelVote.parse("Code-Review-1").format());
-    assertEquals("-Code-Review", LabelVote.parse("-Code-Review").format());
-    assertEquals("Code-Review+1", LabelVote.parse("Code-Review+1").format());
-    assertEquals("Code-Review+2", LabelVote.parse("Code-Review+2").format());
+  public void labelVoteFormat() {
+    assertThat(parse("Code-Review-2").format()).isEqualTo("Code-Review-2");
+    assertThat(parse("Code-Review-1").format()).isEqualTo("Code-Review-1");
+    assertThat(parse("-Code-Review").format()).isEqualTo("-Code-Review");
+    assertThat(parse("Code-Review+1").format()).isEqualTo("Code-Review+1");
+    assertThat(parse("Code-Review+2").format()).isEqualTo("Code-Review+2");
   }
 
   @Test
-  public void parseWithEquals() {
-    LabelVote l;
-    l = LabelVote.parseWithEquals("Code-Review=-2");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) -2, l.value());
-    l = LabelVote.parseWithEquals("Code-Review=-1");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) -1, l.value());
-    l = LabelVote.parseWithEquals("Code-Review=0");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) 0, l.value());
-    l = LabelVote.parseWithEquals("Code-Review=1");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) 1, l.value());
-    l = LabelVote.parseWithEquals("Code-Review=+1");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) 1, l.value());
-    l = LabelVote.parseWithEquals("Code-Review=2");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) 2, l.value());
-    l = LabelVote.parseWithEquals("Code-Review=+2");
-    assertEquals("Code-Review", l.label());
-    assertEquals((short) 2, l.value());
+  public void labelVoteParseWithEquals() {
+    assertLabelVoteEquals(parseWithEquals("Code-Review=-2"), "Code-Review", -2);
+    assertLabelVoteEquals(parseWithEquals("Code-Review=-1"), "Code-Review", -1);
+    assertLabelVoteEquals(parseWithEquals("Code-Review=0"), "Code-Review", 0);
+    assertLabelVoteEquals(parseWithEquals("Code-Review=1"), "Code-Review", 1);
+    assertLabelVoteEquals(parseWithEquals("Code-Review=+1"), "Code-Review", 1);
+    assertLabelVoteEquals(parseWithEquals("Code-Review=2"), "Code-Review", 2);
+    assertLabelVoteEquals(parseWithEquals("Code-Review=+2"), "Code-Review", 2);
+    assertLabelVoteEquals(parseWithEquals("R=0"), "R", 0);
+
+    String longName = "A-loooooooooooooooooooooooooooooooooooooooooooooooooong-label";
+    // Regression test: an old bug passed the string length as a radix to Short#parseShort.
+    assertThat(longName.length()).isGreaterThan(Character.MAX_RADIX);
+    assertLabelVoteEquals(parseWithEquals(longName + "=+1"), longName, 1);
+
+    assertParseWithEqualsFails(null);
+    assertParseWithEqualsFails("");
+    assertParseWithEqualsFails("Code-Review");
+    assertParseWithEqualsFails("=1");
+    assertParseWithEqualsFails("=.1");
+    assertParseWithEqualsFails("=a1");
+    assertParseWithEqualsFails("=1a");
+    assertParseWithEqualsFails("=.");
   }
 
   @Test
-  public void formatWithEquals() {
-    assertEquals("Code-Review=-2", LabelVote.parseWithEquals("Code-Review=-2").formatWithEquals());
-    assertEquals("Code-Review=-1", LabelVote.parseWithEquals("Code-Review=-1").formatWithEquals());
-    assertEquals("Code-Review=0", LabelVote.parseWithEquals("Code-Review=0").formatWithEquals());
-    assertEquals("Code-Review=+1", LabelVote.parseWithEquals("Code-Review=+1").formatWithEquals());
-    assertEquals("Code-Review=+2", LabelVote.parseWithEquals("Code-Review=+2").formatWithEquals());
+  public void labelVoteFormatWithEquals() {
+    assertThat(parseWithEquals("Code-Review=-2").formatWithEquals()).isEqualTo("Code-Review=-2");
+    assertThat(parseWithEquals("Code-Review=-1").formatWithEquals()).isEqualTo("Code-Review=-1");
+    assertThat(parseWithEquals("Code-Review=0").formatWithEquals()).isEqualTo("Code-Review=0");
+    assertThat(parseWithEquals("Code-Review=+1").formatWithEquals()).isEqualTo("Code-Review=+1");
+    assertThat(parseWithEquals("Code-Review=+2").formatWithEquals()).isEqualTo("Code-Review=+2");
+  }
+
+  private void assertLabelVoteEquals(LabelVote actual, String expectedLabel, int expectedValue) {
+    assertThat(actual.label()).isEqualTo(expectedLabel);
+    assertThat((int) actual.value()).isEqualTo(expectedValue);
+  }
+
+  private void assertParseWithEqualsFails(String value) {
+    try {
+      parseWithEquals(value);
+      assert_().fail("expected IllegalArgumentException when parsing \"%s\"", value);
+    } catch (IllegalArgumentException e) {
+      // Expected.
+    }
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java
index 8d6036a..255cd3e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/ConfigSuite.java
@@ -18,8 +18,10 @@
 import static java.lang.annotation.ElementType.FIELD;
 import static java.lang.annotation.ElementType.METHOD;
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
+import static java.util.stream.Collectors.toSet;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import java.lang.annotation.Annotation;
@@ -29,7 +31,10 @@
 import java.lang.reflect.InvocationTargetException;
 import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
+import java.lang.reflect.ParameterizedType;
+import java.lang.reflect.Type;
 import java.util.List;
+import java.util.Map;
 import org.junit.runner.Runner;
 import org.junit.runners.BlockJUnit4ClassRunner;
 import org.junit.runners.Suite;
@@ -80,11 +85,26 @@
  * Additionally, config values used by <strong>default</strong> can be set in a method annotated
  * with {@code @ConfigSuite.Default}.
  *
+ * <p>In addition groups of tests for different configurations can be defined by annotating a method
+ * that returns a Map&lt;String, Config&gt; with {@link Configs}. The map keys define the test suite
+ * names, while the values define the configurations for the test suites.
+ *
+ * <pre>
+ * {@literal @}ConfigSuite.Configs
+ * public static Map&lt;String, Config&gt; configs() {
+ *   Config cfgA = new Config();
+ *   cfgA.setString("gerrit", null, "testValue", "a");
+ *   Config cfgB = new Config();
+ *   cfgB.setString("gerrit", null, "testValue", "b");
+ *   return ImmutableMap.of("testWithValueA", cfgA, "testWithValueB", cfgB);
+ * }
+ * </pre>
+ *
  * <p>The name of the config method corresponding to the currently-running test can be stored in a
  * field annotated with {@code @ConfigSuite.Name}.
  */
 public class ConfigSuite extends Suite {
-  private static final String DEFAULT = "default";
+  public static final String DEFAULT = "default";
 
   @Target({METHOD})
   @Retention(RUNTIME)
@@ -94,6 +114,10 @@
   @Retention(RUNTIME)
   public static @interface Config {}
 
+  @Target({METHOD})
+  @Retention(RUNTIME)
+  public static @interface Configs {}
+
   @Target({FIELD})
   @Retention(RUNTIME)
   public static @interface Parameter {}
@@ -103,25 +127,29 @@
   public static @interface Name {}
 
   private static class ConfigRunner extends BlockJUnit4ClassRunner {
-    private final Method configMethod;
+    private final org.eclipse.jgit.lib.Config cfg;
     private final Field parameterField;
     private final Field nameField;
     private final String name;
 
     private ConfigRunner(
-        Class<?> clazz, Field parameterField, Field nameField, String name, Method configMethod)
+        Class<?> clazz,
+        Field parameterField,
+        Field nameField,
+        String name,
+        org.eclipse.jgit.lib.Config cfg)
         throws InitializationError {
       super(clazz);
       this.parameterField = parameterField;
       this.nameField = nameField;
       this.name = name;
-      this.configMethod = configMethod;
+      this.cfg = cfg;
     }
 
     @Override
     public Object createTest() throws Exception {
       Object test = getTestClass().getJavaClass().getDeclaredConstructor().newInstance();
-      parameterField.set(test, callConfigMethod(configMethod));
+      parameterField.set(test, new org.eclipse.jgit.lib.Config(cfg));
       if (nameField != null) {
         nameField.set(test, name);
       }
@@ -143,14 +171,23 @@
   private static List<Runner> runnersFor(Class<?> clazz) {
     Method defaultConfig = getDefaultConfig(clazz);
     List<Method> configs = getConfigs(clazz);
+    Map<String, org.eclipse.jgit.lib.Config> configMap =
+        callConfigMapMethod(getConfigMap(clazz), configs);
+
     Field parameterField = getOnlyField(clazz, Parameter.class);
     checkArgument(parameterField != null, "No @ConfigSuite.Parameter found");
     Field nameField = getOnlyField(clazz, Name.class);
     List<Runner> result = Lists.newArrayListWithCapacity(configs.size() + 1);
     try {
-      result.add(new ConfigRunner(clazz, parameterField, nameField, null, defaultConfig));
+      result.add(
+          new ConfigRunner(
+              clazz, parameterField, nameField, null, callConfigMethod(defaultConfig)));
       for (Method m : configs) {
-        result.add(new ConfigRunner(clazz, parameterField, nameField, m.getName(), m));
+        result.add(
+            new ConfigRunner(clazz, parameterField, nameField, m.getName(), callConfigMethod(m)));
+      }
+      for (Map.Entry<String, org.eclipse.jgit.lib.Config> e : configMap.entrySet()) {
+        result.add(new ConfigRunner(clazz, parameterField, nameField, e.getKey(), e.getValue()));
       }
       return result;
     } catch (InitializationError e) {
@@ -163,15 +200,20 @@
   }
 
   private static Method getDefaultConfig(Class<?> clazz) {
+    return getAnnotatedMethod(clazz, Default.class);
+  }
+
+  private static Method getConfigMap(Class<?> clazz) {
+    return getAnnotatedMethod(clazz, Configs.class);
+  }
+
+  private static <T extends Annotation> Method getAnnotatedMethod(
+      Class<?> clazz, Class<T> annotationClass) {
     Method result = null;
     for (Method m : clazz.getMethods()) {
-      Default ann = m.getAnnotation(Default.class);
+      T ann = m.getAnnotation(annotationClass);
       if (ann != null) {
-        checkArgument(
-            result == null,
-            "Multiple methods annotated with @ConfigSuite.Method: %s, %s",
-            result,
-            m);
+        checkArgument(result == null, "Multiple methods annotated with %s: %s, %s", ann, result, m);
         result = m;
       }
     }
@@ -183,8 +225,7 @@
     for (Method m : clazz.getMethods()) {
       Config ann = m.getAnnotation(Config.class);
       if (ann != null) {
-        checkArgument(
-            !m.getName().equals(DEFAULT), "@ConfigSuite.Config cannot be named %s", DEFAULT);
+        checkArgument(!m.getName().equals(DEFAULT), "%s cannot be named %s", ann, DEFAULT);
         result.add(m);
       }
     }
@@ -208,6 +249,45 @@
     }
   }
 
+  private static Map<String, org.eclipse.jgit.lib.Config> callConfigMapMethod(
+      Method m, List<Method> configs) {
+    if (m == null) {
+      return ImmutableMap.of();
+    }
+    checkArgument(Map.class.isAssignableFrom(m.getReturnType()), "%s must return Map", m);
+    Type[] types = ((ParameterizedType) m.getGenericReturnType()).getActualTypeArguments();
+    checkArgument(
+        String.class.isAssignableFrom((Class<?>) types[0]),
+        "The map returned by %s must have String as key",
+        m);
+    checkArgument(
+        org.eclipse.jgit.lib.Config.class.isAssignableFrom((Class<?>) types[1]),
+        "The map returned by %s must have Config as value",
+        m);
+    checkArgument((m.getModifiers() & Modifier.STATIC) != 0, "%s must be static", m);
+    checkArgument(m.getParameterTypes().length == 0, "%s must take no parameters", m);
+    try {
+      @SuppressWarnings("unchecked")
+      Map<String, org.eclipse.jgit.lib.Config> configMap =
+          (Map<String, org.eclipse.jgit.lib.Config>) m.invoke(null);
+      checkArgument(
+          !configMap.containsKey(DEFAULT),
+          "The map returned by %s cannot contain key %s (duplicate test suite name)",
+          m,
+          DEFAULT);
+      for (String name : configs.stream().map(cm -> cm.getName()).collect(toSet())) {
+        checkArgument(
+            !configMap.containsKey(name),
+            "The map returned by %s cannot contain key %s (duplicate test suite name)",
+            m,
+            name);
+      }
+      return configMap;
+    } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
+      throw new IllegalArgumentException(e);
+    }
+  }
+
   private static Field getOnlyField(Class<?> clazz, Class<? extends Annotation> ann) {
     List<Field> fields = Lists.newArrayListWithExpectedSize(1);
     for (Field f : clazz.getFields()) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
index 885a1f5..123645e 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/DisabledReviewDb.java
@@ -14,8 +14,6 @@
 
 package com.google.gerrit.testutil;
 
-import com.google.gerrit.reviewdb.server.AccountAccess;
-import com.google.gerrit.reviewdb.server.AccountExternalIdAccess;
 import com.google.gerrit.reviewdb.server.AccountGroupAccess;
 import com.google.gerrit.reviewdb.server.AccountGroupByIdAccess;
 import com.google.gerrit.reviewdb.server.AccountGroupByIdAudAccess;
@@ -84,16 +82,6 @@
   }
 
   @Override
-  public AccountAccess accounts() {
-    throw new Disabled();
-  }
-
-  @Override
-  public AccountExternalIdAccess accountExternalIds() {
-    throw new Disabled();
-  }
-
-  @Override
   public AccountGroupAccess accountGroups() {
     throw new Disabled();
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
index 3c5bc85..b1711e2 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAccountCache.java
@@ -20,6 +20,8 @@
 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.AllUsersName;
+import com.google.gerrit.server.config.AllUsersNameProvider;
 import java.util.HashMap;
 import java.util.Map;
 
@@ -35,7 +37,7 @@
 
   @Override
   public synchronized AccountState get(Account.Id accountId) {
-    AccountState state = getIfPresent(accountId);
+    AccountState state = byId.get(accountId);
     if (state != null) {
       return state;
     }
@@ -49,11 +51,6 @@
   }
 
   @Override
-  public synchronized AccountState getIfPresent(Account.Id accountId) {
-    return byId.get(accountId);
-  }
-
-  @Override
   public synchronized AccountState getByUsername(String username) {
     return byUsername.get(username);
   }
@@ -64,12 +61,7 @@
   }
 
   @Override
-  public synchronized void evictByUsername(String username) {
-    byUsername.remove(username);
-  }
-
-  @Override
-  public synchronized void evictAll() {
+  public synchronized void evictAllNoReindex() {
     byId.clear();
     byUsername.clear();
   }
@@ -83,6 +75,10 @@
   }
 
   private static AccountState newState(Account account) {
-    return new AccountState(account, ImmutableSet.of(), ImmutableSet.of(), new HashMap<>());
+    return new AccountState(
+        new AllUsersName(AllUsersNameProvider.DEFAULT),
+        account,
+        ImmutableSet.of(),
+        new HashMap<>());
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAuditService.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAuditService.java
new file mode 100644
index 0000000..1eb5bdb
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeAuditService.java
@@ -0,0 +1,89 @@
+// 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.testutil;
+
+import com.google.gerrit.audit.AuditEvent;
+import com.google.gerrit.audit.AuditService;
+import com.google.gerrit.audit.GroupMemberAuditListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.AccountGroupById;
+import com.google.gerrit.reviewdb.client.AccountGroupMember;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+
+@Singleton
+public class FakeAuditService implements AuditService {
+
+  private final DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners;
+
+  public static class Module extends AbstractModule {
+    @Override
+    public void configure() {
+      DynamicSet.setOf(binder(), GroupMemberAuditListener.class);
+      bind(AuditService.class).to(FakeAuditService.class);
+    }
+  }
+
+  @Inject
+  public FakeAuditService(DynamicSet<GroupMemberAuditListener> groupMemberAuditListeners) {
+    this.groupMemberAuditListeners = groupMemberAuditListeners;
+  }
+
+  public List<AuditEvent> auditEvents = new ArrayList<>();
+
+  public void clearEvents() {
+    auditEvents.clear();
+  }
+
+  @Override
+  public void dispatch(AuditEvent action) {
+    auditEvents.add(action);
+  }
+
+  @Override
+  public void dispatchAddAccountsToGroup(Account.Id actor, Collection<AccountGroupMember> added) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      auditListener.onAddAccountsToGroup(actor, added);
+    }
+  }
+
+  @Override
+  public void dispatchDeleteAccountsFromGroup(
+      Account.Id actor, Collection<AccountGroupMember> removed) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      auditListener.onDeleteAccountsFromGroup(actor, removed);
+    }
+  }
+
+  @Override
+  public void dispatchAddGroupsToGroup(Account.Id actor, Collection<AccountGroupById> added) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      auditListener.onAddGroupsToGroup(actor, added);
+    }
+  }
+
+  @Override
+  public void dispatchDeleteGroupsFromGroup(
+      Account.Id actor, Collection<AccountGroupById> removed) {
+    for (GroupMemberAuditListener auditListener : groupMemberAuditListeners) {
+      auditListener.onDeleteGroupsFromGroup(actor, removed);
+    }
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
index ed3e41f..c70d241 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/FakeEmailSender.java
@@ -82,11 +82,13 @@
 
   private final WorkQueue workQueue;
   private final List<Message> messages;
+  private int messagesRead;
 
   @Inject
   FakeEmailSender(WorkQueue workQueue) {
     this.workQueue = workQueue;
     messages = Collections.synchronizedList(new ArrayList<Message>());
+    messagesRead = 0;
   }
 
   @Override
@@ -121,9 +123,23 @@
     waitForEmails();
     synchronized (messages) {
       messages.clear();
+      messagesRead = 0;
     }
   }
 
+  public synchronized @Nullable Message peekMessage() {
+    if (messagesRead >= messages.size()) {
+      return null;
+    }
+    return messages.get(messagesRead);
+  }
+
+  public synchronized @Nullable Message nextMessage() {
+    Message msg = peekMessage();
+    messagesRead++;
+    return msg;
+  }
+
   public ImmutableList<Message> getMessages() {
     waitForEmails();
     synchronized (messages) {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
index 9135c553..44e5d74 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritBaseTests.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.testutil;
 
+import com.google.common.base.CharMatcher;
 import com.google.gwtorm.client.KeyUtil;
 import com.google.gwtorm.server.StandardKeyEncoder;
 import org.junit.Ignore;
@@ -29,4 +30,16 @@
 
   @Rule public ExpectedException exception = ExpectedException.none();
   @Rule public final TestName testName = new TestName();
+
+  protected String getSanitizedMethodName() {
+    String name = testName.getMethodName().toLowerCase();
+    name =
+        CharMatcher.inRange('a', 'z')
+            .or(CharMatcher.inRange('A', 'Z'))
+            .or(CharMatcher.inRange('0', '9'))
+            .negate()
+            .replaceFrom(name, '_');
+    name = CharMatcher.is('_').trimTrailingFrom(name);
+    return name;
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
index 038baac..b84b8ed 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/GerritServerTests.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.testutil;
 
+import com.google.gerrit.server.notedb.MutableNotesMigration;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Rule;
 import org.junit.rules.TestRule;
@@ -27,13 +28,13 @@
 
   @ConfigSuite.Name private String configName;
 
-  protected TestNotesMigration notesMigration;
+  protected MutableNotesMigration notesMigration;
 
   @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 {
@@ -49,8 +50,10 @@
       };
 
   public void beforeTest() throws Exception {
-    notesMigration = new TestNotesMigration().setFromEnv();
+    notesMigration = NoteDbMode.newNotesMigrationFromEnv();
   }
 
-  public void afterTest() {}
+  public void afterTest() {
+    NoteDbMode.resetFromEnv(notesMigration);
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java
index bff27ca..21b21ef 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryDatabase.java
@@ -65,7 +65,7 @@
   }
 
   /** Drop the database from memory; does nothing if the instance was null. */
-  public static void drop(final InMemoryDatabase db) {
+  public static void drop(InMemoryDatabase db) {
     if (db != null) {
       db.drop();
     }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
index f70e39e..8835150 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
@@ -14,13 +14,17 @@
 
 package com.google.gerrit.testutil;
 
+import static com.google.common.base.Preconditions.checkState;
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
+import com.google.gerrit.audit.AuditModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.extensions.config.FactoryModule;
+import com.google.gerrit.extensions.systemstatus.ServerInformation;
 import com.google.gerrit.gpg.GpgModule;
+import com.google.gerrit.index.SchemaDefinitions;
 import com.google.gerrit.metrics.DisabledMetricMaker;
 import com.google.gerrit.metrics.MetricMaker;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -49,17 +53,25 @@
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.SendEmailExecutor;
 import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.account.AccountSchemaDefinitions;
 import com.google.gerrit.server.index.account.AllAccountsIndexer;
 import com.google.gerrit.server.index.change.AllChangesIndexer;
 import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
 import com.google.gerrit.server.index.group.AllGroupsIndexer;
+import com.google.gerrit.server.index.group.GroupSchemaDefinitions;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.GwtormChangeBundleReader;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
 import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutor;
+import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.plugins.ServerInformationImpl;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.schema.DataSourceType;
 import com.google.gerrit.server.schema.InMemoryAccountPatchReviewStore;
+import com.google.gerrit.server.schema.NotesMigrationSchemaFactory;
+import com.google.gerrit.server.schema.ReviewDbFactory;
 import com.google.gerrit.server.schema.SchemaCreator;
 import com.google.gerrit.server.securestore.DefaultSecureStore;
 import com.google.gerrit.server.securestore.SecureStore;
@@ -70,6 +82,7 @@
 import com.google.inject.AbstractModule;
 import com.google.inject.Guice;
 import com.google.inject.Injector;
+import com.google.inject.Key;
 import com.google.inject.Module;
 import com.google.inject.Provider;
 import com.google.inject.Provides;
@@ -111,13 +124,13 @@
   }
 
   private final Config cfg;
-  private final TestNotesMigration notesMigration;
+  private final MutableNotesMigration notesMigration;
 
   public InMemoryModule() {
-    this(newDefaultConfig(), new TestNotesMigration());
+    this(newDefaultConfig(), NoteDbMode.newNotesMigrationFromEnv());
   }
 
-  public InMemoryModule(Config cfg, TestNotesMigration notesMigration) {
+  public InMemoryModule(Config cfg, MutableNotesMigration notesMigration) {
     this.cfg = cfg;
     this.notesMigration = notesMigration;
   }
@@ -147,8 +160,10 @@
             });
     bind(MetricMaker.class).to(DisabledMetricMaker.class);
     install(cfgInjector.getInstance(GerritGlobalModule.class));
+    install(new DefaultPermissionBackendModule());
     install(new SearchingChangeCacheImpl.Module());
     factory(GarbageCollection.Factory.class);
+    install(new AuditModule());
 
     bindScope(RequestScoped.class, PerThreadRequestScope.REQUEST);
 
@@ -168,17 +183,20 @@
     bind(GitRepositoryManager.class).to(InMemoryRepositoryManager.class);
     bind(InMemoryRepositoryManager.class).in(SINGLETON);
     bind(TrackingFooters.class).toProvider(TrackingFootersProvider.class).in(SINGLETON);
-    bind(NotesMigration.class).toInstance(notesMigration);
+    bind(MutableNotesMigration.class).toInstance(notesMigration);
+    bind(NotesMigration.class).to(MutableNotesMigration.class);
     bind(ListeningExecutorService.class)
         .annotatedWith(ChangeUpdateExecutor.class)
         .toInstance(MoreExecutors.newDirectExecutorService());
-
     bind(DataSourceType.class).to(InMemoryH2Type.class);
-    bind(new TypeLiteral<SchemaFactory<ReviewDb>>() {}).to(InMemoryDatabase.class);
     bind(ChangeBundleReader.class).to(GwtormChangeBundleReader.class);
-
     bind(SecureStore.class).to(DefaultSecureStore.class);
 
+    TypeLiteral<SchemaFactory<ReviewDb>> schemaFactory =
+        new TypeLiteral<SchemaFactory<ReviewDb>>() {};
+    bind(schemaFactory).to(NotesMigrationSchemaFactory.class);
+    bind(Key.get(schemaFactory, ReviewDbFactory.class)).to(InMemoryDatabase.class);
+
     install(NoSshKeyCache.module());
     install(
         new CanonicalWebUrlModule() {
@@ -229,6 +247,9 @@
           throw new ProvisionException("index type unsupported in tests: " + indexType);
       }
     }
+    bind(ServerInformationImpl.class);
+    bind(ServerInformation.class).to(ServerInformationImpl.class);
+    install(new PluginRestApiModule());
   }
 
   @Provides
@@ -254,14 +275,9 @@
 
   private Module indexModule(String moduleClassName) {
     try {
-      Map<String, Integer> singleVersions = new HashMap<>();
-      int version = cfg.getInt("index", "lucene", "testVersion", -1);
-      if (version > 0) {
-        singleVersions.put(ChangeSchemaDefinitions.INSTANCE.getName(), version);
-      }
       Class<?> clazz = Class.forName(moduleClassName);
       Method m = clazz.getMethod("singleVersionWithExplicitVersions", Map.class, int.class);
-      return (Module) m.invoke(null, singleVersions, 0);
+      return (Module) m.invoke(null, getSingleSchemaVersions(), 0);
     } catch (ClassNotFoundException
         | SecurityException
         | NoSuchMethodException
@@ -274,4 +290,25 @@
       throw pe;
     }
   }
+
+  private Map<String, Integer> getSingleSchemaVersions() {
+    Map<String, Integer> singleVersions = new HashMap<>();
+    putSchemaVersion(singleVersions, AccountSchemaDefinitions.INSTANCE);
+    putSchemaVersion(singleVersions, ChangeSchemaDefinitions.INSTANCE);
+    putSchemaVersion(singleVersions, GroupSchemaDefinitions.INSTANCE);
+    return singleVersions;
+  }
+
+  private void putSchemaVersion(
+      Map<String, Integer> singleVersions, SchemaDefinitions<?> schemaDef) {
+    String schemaName = schemaDef.getName();
+    int version = cfg.getInt("index", "lucene", schemaName + "TestVersion", -1);
+    if (version > 0) {
+      checkState(
+          !singleVersions.containsKey(schemaName),
+          "version for schema %s was alreay set",
+          schemaName);
+      singleVersions.put(schemaName, version);
+    }
+  }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
index 4826d9e..e0c51b7 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryRepositoryManager.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.RepositoryCaseMismatchException;
+import com.google.inject.Inject;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.SortedSet;
@@ -51,9 +52,7 @@
 
     private Repo(Project.NameKey name) {
       super(new Description(name));
-      // TODO(dborowitz): Allow atomic transactions when this is supported:
-      // https://git.eclipse.org/r/#/c/61841/2/org.eclipse.jgit/src/org/eclipse/jgit/internal/storage/dfs/InMemoryRepository.java@313
-      setPerformsAtomicTransactions(false);
+      setPerformsAtomicTransactions(true);
     }
 
     @Override
@@ -72,7 +71,12 @@
     }
   }
 
-  private Map<String, Repo> repos = new HashMap<>();
+  private final Map<String, Repo> repos;
+
+  @Inject
+  public InMemoryRepositoryManager() {
+    this.repos = new HashMap<>();
+  }
 
   @Override
   public synchronized Repo openRepository(Project.NameKey name) throws RepositoryNotFoundException {
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersions.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersions.java
new file mode 100644
index 0000000..c2ba740
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersions.java
@@ -0,0 +1,146 @@
+// 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.testutil;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.stream.Collectors.toMap;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.primitives.Ints;
+import com.google.gerrit.index.Schema;
+import com.google.gerrit.index.SchemaDefinitions;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.SortedMap;
+import org.eclipse.jgit.lib.Config;
+
+public class IndexVersions {
+  static final String ALL = "all";
+  static final String CURRENT = "current";
+  static final String PREVIOUS = "previous";
+
+  /**
+   * Returns the index versions from {@link IndexVersions#get(SchemaDefinitions)} without the latest
+   * schema version.
+   *
+   * @param schemaDef the schema definition
+   * @return the index versions from {@link IndexVersions#get(SchemaDefinitions)} without the latest
+   *     schema version
+   */
+  public static <V> ImmutableList<Integer> getWithoutLatest(SchemaDefinitions<V> schemaDef) {
+    List<Integer> schemaVersions = new ArrayList<>(get(schemaDef));
+    schemaVersions.remove(Integer.valueOf(schemaDef.getLatest().getVersion()));
+    return ImmutableList.copyOf(schemaVersions);
+  }
+
+  /**
+   * Returns the schema versions against which the query tests should be executed.
+   *
+   * <p>The schema versions are read from the '<schema-name>_INDEX_VERSIONS' env var if it is set,
+   * e.g. 'ACCOUNTS_INDEX_VERSIONS', 'CHANGES_INDEX_VERSIONS', 'GROUPS_INDEX_VERSIONS'.
+   *
+   * <p>If schema versions were not specified by an env var, they are read from the
+   * 'gerrit.index.<schema-name>.versions' system property, e.g. 'gerrit.index.accounts.version',
+   * 'gerrit.index.changes.version', 'gerrit.index.groups.version'.
+   *
+   * <p>As value a comma-separated list of schema versions is expected. {@code current} can be used
+   * for the latest schema version and {@code previous} is resolved to the second last schema
+   * version. Alternatively the value can also be {@code all} for all schema versions.
+   *
+   * <p>If schema versions were neither specified by an env var nor by a system property, the
+   * current and the second last schema versions are returned. If there is no other schema version
+   * than the current schema version, only the current schema version is returned.
+   *
+   * @param schemaDef the schema definition
+   * @return the schema versions against which the query tests should be executed
+   * @throws IllegalArgumentException if the value of the env var or system property is invalid or
+   *     if any of the specified schema versions doesn't exist
+   */
+  public static <V> ImmutableList<Integer> get(SchemaDefinitions<V> schemaDef) {
+    String envVar = schemaDef.getName().toUpperCase() + "_INDEX_VERSIONS";
+    String value = System.getenv(envVar);
+    if (!Strings.isNullOrEmpty(value)) {
+      return get(schemaDef, "env variable " + envVar, value);
+    }
+
+    String systemProperty = "gerrit.index." + schemaDef.getName().toLowerCase() + ".versions";
+    value = System.getProperty(systemProperty);
+    return get(schemaDef, "system property " + systemProperty, value);
+  }
+
+  @VisibleForTesting
+  static <V> ImmutableList<Integer> get(SchemaDefinitions<V> schemaDef, String name, String value) {
+    if (value != null) {
+      value = value.trim();
+    }
+
+    SortedMap<Integer, Schema<V>> schemas = schemaDef.getSchemas();
+    if (!Strings.isNullOrEmpty(value)) {
+      if (ALL.equals(value)) {
+        return ImmutableList.copyOf(schemas.keySet());
+      }
+
+      List<Integer> versions = new ArrayList<>();
+      for (String s : Splitter.on(',').trimResults().split(value)) {
+        if (CURRENT.equals(s)) {
+          versions.add(schemaDef.getLatest().getVersion());
+        } else if (PREVIOUS.equals(s)) {
+          checkArgument(schemaDef.getPrevious() != null, "previous version does not exist");
+          versions.add(schemaDef.getPrevious().getVersion());
+        } else {
+          Integer version = Ints.tryParse(s);
+          checkArgument(version != null, "Invalid value for %s: %s", name, s);
+          checkArgument(
+              schemas.containsKey(version),
+              "Index version %s that was specified by %s not found." + " Possible versions are: %s",
+              version,
+              name,
+              schemas.keySet());
+          versions.add(version);
+        }
+      }
+      return ImmutableList.copyOf(versions);
+    }
+
+    List<Integer> schemaVersions = new ArrayList<>(2);
+    if (schemaDef.getPrevious() != null) {
+      schemaVersions.add(schemaDef.getPrevious().getVersion());
+    }
+    schemaVersions.add(schemaDef.getLatest().getVersion());
+    return ImmutableList.copyOf(schemaVersions);
+  }
+
+  public static <V> Map<String, Config> asConfigMap(
+      SchemaDefinitions<V> schemaDef,
+      List<Integer> schemaVersions,
+      String testSuiteNamePrefix,
+      Config baseConfig) {
+    return schemaVersions
+        .stream()
+        .collect(
+            toMap(
+                i -> testSuiteNamePrefix + i,
+                i -> {
+                  Config cfg = baseConfig;
+                  cfg.setInt(
+                      "index", "lucene", schemaDef.getName().toLowerCase() + "TestVersion", i);
+                  return cfg;
+                }));
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersionsTest.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersionsTest.java
new file mode 100644
index 0000000..d3c889a
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/IndexVersionsTest.java
@@ -0,0 +1,140 @@
+// 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.testutil;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.testutil.IndexVersions.ALL;
+import static com.google.gerrit.testutil.IndexVersions.CURRENT;
+import static com.google.gerrit.testutil.IndexVersions.PREVIOUS;
+
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.Test;
+
+public class IndexVersionsTest extends GerritBaseTests {
+  private static final ChangeSchemaDefinitions SCHEMA_DEF = ChangeSchemaDefinitions.INSTANCE;
+
+  @Test
+  public void noValue() {
+    List<Integer> expected = new ArrayList<>();
+    if (SCHEMA_DEF.getPrevious() != null) {
+      expected.add(SCHEMA_DEF.getPrevious().getVersion());
+    }
+    expected.add(SCHEMA_DEF.getLatest().getVersion());
+
+    assertThat(get(null)).containsExactlyElementsIn(expected).inOrder();
+  }
+
+  @Test
+  public void emptyValue() {
+    List<Integer> expected = new ArrayList<>();
+    if (SCHEMA_DEF.getPrevious() != null) {
+      expected.add(SCHEMA_DEF.getPrevious().getVersion());
+    }
+    expected.add(SCHEMA_DEF.getLatest().getVersion());
+
+    assertThat(get("")).containsExactlyElementsIn(expected).inOrder();
+  }
+
+  @Test
+  public void all() {
+    assertThat(get(ALL)).containsExactlyElementsIn(SCHEMA_DEF.getSchemas().keySet()).inOrder();
+  }
+
+  @Test
+  public void current() {
+    assertThat(get(CURRENT)).containsExactly(SCHEMA_DEF.getLatest().getVersion());
+  }
+
+  @Test
+  public void previous() {
+    assume().that(SCHEMA_DEF.getPrevious()).isNotNull();
+
+    assertThat(get(PREVIOUS)).containsExactly(SCHEMA_DEF.getPrevious().getVersion());
+  }
+
+  @Test
+  public void versionNumber() {
+    assume().that(SCHEMA_DEF.getPrevious()).isNotNull();
+
+    assertThat(get(Integer.toString(SCHEMA_DEF.getPrevious().getVersion())))
+        .containsExactly(SCHEMA_DEF.getPrevious().getVersion());
+  }
+
+  @Test
+  public void invalid() {
+    assertIllegalArgument("foo", "Invalid value for test: foo");
+  }
+
+  @Test
+  public void currentAndPrevious() {
+    if (SCHEMA_DEF.getPrevious() == null) {
+      assertIllegalArgument(CURRENT + "," + PREVIOUS, "previous version does not exist");
+      return;
+    }
+
+    assertThat(get(CURRENT + "," + PREVIOUS))
+        .containsExactly(SCHEMA_DEF.getLatest().getVersion(), SCHEMA_DEF.getPrevious().getVersion())
+        .inOrder();
+    assertThat(get(PREVIOUS + "," + CURRENT))
+        .containsExactly(SCHEMA_DEF.getPrevious().getVersion(), SCHEMA_DEF.getLatest().getVersion())
+        .inOrder();
+  }
+
+  @Test
+  public void currentAndVersionNumber() {
+    assume().that(SCHEMA_DEF.getPrevious()).isNotNull();
+
+    assertThat(get(CURRENT + "," + SCHEMA_DEF.getPrevious().getVersion()))
+        .containsExactly(SCHEMA_DEF.getLatest().getVersion(), SCHEMA_DEF.getPrevious().getVersion())
+        .inOrder();
+    assertThat(get(SCHEMA_DEF.getPrevious().getVersion() + "," + CURRENT))
+        .containsExactly(SCHEMA_DEF.getPrevious().getVersion(), SCHEMA_DEF.getLatest().getVersion())
+        .inOrder();
+  }
+
+  @Test
+  public void currentAndAll() {
+    assertIllegalArgument(CURRENT + "," + ALL, "Invalid value for test: " + ALL);
+  }
+
+  @Test
+  public void currentAndInvalid() {
+    assertIllegalArgument(CURRENT + ",foo", "Invalid value for test: foo");
+  }
+
+  @Test
+  public void nonExistingVersion() {
+    int nonExistingVersion = SCHEMA_DEF.getLatest().getVersion() + 1;
+    assertIllegalArgument(
+        Integer.toString(nonExistingVersion),
+        "Index version "
+            + nonExistingVersion
+            + " that was specified by test not found. Possible versions are: "
+            + SCHEMA_DEF.getSchemas().keySet());
+  }
+
+  private static List<Integer> get(String value) {
+    return IndexVersions.get(ChangeSchemaDefinitions.INSTANCE, "test", value);
+  }
+
+  private void assertIllegalArgument(String value, String expectedMessage) {
+    exception.expect(IllegalArgumentException.class);
+    exception.expectMessage(expectedMessage);
+    get(value);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
index aeaaa47..5ce0810 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbChecker.java
@@ -19,6 +19,8 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.Joiner;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.ListMultimap;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
@@ -29,6 +31,7 @@
 import com.google.gerrit.server.notedb.ChangeBundle;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder;
 import com.google.gwtorm.client.IntKey;
 import com.google.gwtorm.server.OrmException;
@@ -52,7 +55,7 @@
 
   private final Provider<ReviewDb> dbProvider;
   private final GitRepositoryManager repoManager;
-  private final TestNotesMigration notesMigration;
+  private final MutableNotesMigration notesMigration;
   private final ChangeBundleReader bundleReader;
   private final ChangeNotes.Factory notesFactory;
   private final ChangeRebuilder changeRebuilder;
@@ -62,7 +65,7 @@
   NoteDbChecker(
       Provider<ReviewDb> dbProvider,
       GitRepositoryManager repoManager,
-      TestNotesMigration notesMigration,
+      MutableNotesMigration notesMigration,
       ChangeBundleReader bundleReader,
       ChangeNotes.Factory notesFactory,
       ChangeRebuilder changeRebuilder,
@@ -77,19 +80,22 @@
   }
 
   public void rebuildAndCheckAllChanges() throws Exception {
-    rebuildAndCheckChanges(getUnwrappedDb().changes().all().toList().stream().map(Change::getId));
+    rebuildAndCheckChanges(
+        getUnwrappedDb().changes().all().toList().stream().map(Change::getId),
+        ImmutableListMultimap.of());
   }
 
   public void rebuildAndCheckChanges(Change.Id... changeIds) throws Exception {
-    rebuildAndCheckChanges(Arrays.stream(changeIds));
+    rebuildAndCheckChanges(Arrays.stream(changeIds), ImmutableListMultimap.of());
   }
 
-  private void rebuildAndCheckChanges(Stream<Change.Id> changeIds) throws Exception {
+  private void rebuildAndCheckChanges(
+      Stream<Change.Id> changeIds, ListMultimap<Change.Id, String> expectedDiffs) throws Exception {
     ReviewDb db = getUnwrappedDb();
 
     List<ChangeBundle> allExpected = readExpected(changeIds);
 
-    boolean oldWrite = notesMigration.writeChanges();
+    boolean oldWrite = notesMigration.rawWriteChangesSetting();
     boolean oldRead = notesMigration.readChanges();
     try {
       notesMigration.setWriteChanges(true);
@@ -104,7 +110,7 @@
         }
       }
 
-      checkActual(allExpected, msgs);
+      checkActual(allExpected, expectedDiffs, msgs);
     } finally {
       notesMigration.setReadChanges(oldRead);
       notesMigration.setWriteChanges(oldWrite);
@@ -112,7 +118,14 @@
   }
 
   public void checkChanges(Change.Id... changeIds) throws Exception {
-    checkActual(readExpected(Arrays.stream(changeIds)), new ArrayList<>());
+    checkActual(
+        readExpected(Arrays.stream(changeIds)), ImmutableListMultimap.of(), new ArrayList<>());
+  }
+
+  public void rebuildAndCheckChange(Change.Id changeId, String... expectedDiff) throws Exception {
+    ImmutableListMultimap.Builder<Change.Id, String> b = ImmutableListMultimap.builder();
+    b.putAll(changeId, Arrays.asList(expectedDiff));
+    rebuildAndCheckChanges(Stream.of(changeId), b.build());
   }
 
   public void assertNoChangeRef(Project.NameKey project, Change.Id changeId) throws Exception {
@@ -159,10 +172,14 @@
     }
   }
 
-  private void checkActual(List<ChangeBundle> allExpected, List<String> msgs) throws Exception {
+  private void checkActual(
+      List<ChangeBundle> allExpected,
+      ListMultimap<Change.Id, String> expectedDiffs,
+      List<String> msgs)
+      throws Exception {
     ReviewDb db = getUnwrappedDb();
     boolean oldRead = notesMigration.readChanges();
-    boolean oldWrite = notesMigration.writeChanges();
+    boolean oldWrite = notesMigration.rawWriteChangesSetting();
     try {
       notesMigration.setWriteChanges(true);
       notesMigration.setReadChanges(true);
@@ -180,9 +197,14 @@
           continue;
         }
         List<String> diff = expected.differencesFrom(actual);
-        if (!diff.isEmpty()) {
+        List<String> expectedDiff = expectedDiffs.get(c.getId());
+        if (!diff.equals(expectedDiff)) {
           msgs.add("Differences between ReviewDb and NoteDb for " + c + ":");
           msgs.addAll(diff);
+          if (!expectedDiff.isEmpty()) {
+            msgs.add("Expected differences:");
+            msgs.addAll(expectedDiff);
+          }
           msgs.add("");
         } else {
           System.err.println("NoteDb conversion of change " + c.getId() + " successful");
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
index 552f6f1..078ce43 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/NoteDbMode.java
@@ -18,28 +18,30 @@
 
 import com.google.common.base.Enums;
 import com.google.common.base.Strings;
+import com.google.gerrit.server.notedb.MutableNotesMigration;
+import com.google.gerrit.server.notedb.NotesMigrationState;
 
 public enum NoteDbMode {
   /** NoteDb is disabled. */
-  OFF(false),
+  OFF(NotesMigrationState.REVIEW_DB),
 
   /** Writing data to NoteDb is enabled. */
-  WRITE(false),
+  WRITE(NotesMigrationState.WRITE),
 
   /** Reading and writing all data to NoteDb is enabled. */
-  READ_WRITE(true),
+  READ_WRITE(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_REVIEW_DB_PRIMARY),
 
   /** Changes are created with their primary storage as NoteDb. */
-  PRIMARY(true),
+  PRIMARY(NotesMigrationState.READ_WRITE_WITH_SEQUENCE_NOTE_DB_PRIMARY),
 
-  /** All change tables are entirely disabled. */
-  DISABLE_CHANGE_REVIEW_DB(true),
+  /** All change tables are entirely disabled, and code/meta ref updates are fused. */
+  ON(NotesMigrationState.NOTE_DB),
 
   /**
    * Run tests with NoteDb disabled, then convert ReviewDb to NoteDb and check that the results
    * match.
    */
-  CHECK(false);
+  CHECK(NotesMigrationState.REVIEW_DB);
 
   private static final String ENV_VAR = "GERRIT_NOTEDB";
   private static final String SYS_PROP = "gerrit.notedb";
@@ -67,13 +69,19 @@
     return mode;
   }
 
-  public static boolean readWrite() {
-    return get().readWrite;
+  public static MutableNotesMigration newNotesMigrationFromEnv() {
+    MutableNotesMigration m = MutableNotesMigration.newDisabled();
+    resetFromEnv(m);
+    return m;
   }
 
-  private final boolean readWrite;
+  public static void resetFromEnv(MutableNotesMigration migration) {
+    migration.setFrom(get().state);
+  }
 
-  private NoteDbMode(boolean readWrite) {
-    this.readWrite = readWrite;
+  private final NotesMigrationState state;
+
+  private NoteDbMode(NotesMigrationState state) {
+    this.state = state;
   }
 }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/SchemaUpgradeTestEnvironment.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/SchemaUpgradeTestEnvironment.java
new file mode 100644
index 0000000..adcde40
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/SchemaUpgradeTestEnvironment.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.testutil;
+
+import com.google.gerrit.lifecycle.LifecycleManager;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountManager;
+import com.google.gerrit.server.account.AuthRequest;
+import com.google.gerrit.server.schema.SchemaCreator;
+import com.google.gerrit.server.util.RequestContext;
+import com.google.gerrit.server.util.ThreadLocalRequestContext;
+import com.google.gwtorm.server.SchemaFactory;
+import com.google.inject.Guice;
+import com.google.inject.Inject;
+import com.google.inject.Injector;
+import com.google.inject.Provider;
+import com.google.inject.util.Providers;
+import org.junit.rules.TestRule;
+import org.junit.runner.Description;
+import org.junit.runners.model.Statement;
+
+public final class SchemaUpgradeTestEnvironment implements TestRule {
+  @Inject private AccountManager accountManager;
+  @Inject private IdentifiedUser.GenericFactory userFactory;
+  @Inject private SchemaFactory<ReviewDb> schemaFactory;
+  @Inject private SchemaCreator schemaCreator;
+  @Inject private ThreadLocalRequestContext requestContext;
+  // Only for use in setting up/tearing down injector.
+  @Inject private InMemoryDatabase inMemoryDatabase;
+
+  private ReviewDb db;
+  private Injector injector;
+  private LifecycleManager lifecycle;
+
+  @Override
+  public Statement apply(Statement statement, Description description) {
+    return new Statement() {
+      @Override
+      public void evaluate() throws Throwable {
+        try {
+          setUp();
+          statement.evaluate();
+        } finally {
+          tearDown();
+        }
+      }
+    };
+  }
+
+  public ReviewDb getDb() {
+    return db;
+  }
+
+  public Injector getInjector() {
+    return injector;
+  }
+
+  public void setApiUser(Account.Id id) {
+    IdentifiedUser user = userFactory.create(id);
+    requestContext.setContext(
+        new RequestContext() {
+          @Override
+          public CurrentUser getUser() {
+            return user;
+          }
+
+          @Override
+          public Provider<ReviewDb> getReviewDbProvider() {
+            return Providers.of(db);
+          }
+        });
+  }
+
+  private void setUp() throws Exception {
+    injector = Guice.createInjector(new InMemoryModule());
+    injector.injectMembers(this);
+    lifecycle = new LifecycleManager();
+    lifecycle.add(injector);
+    lifecycle.start();
+
+    try (ReviewDb underlyingDb = inMemoryDatabase.getDatabase().open()) {
+      schemaCreator.create(underlyingDb);
+    }
+    db = schemaFactory.open();
+    setApiUser(accountManager.authenticate(AuthRequest.forUser("user")).getAccountId());
+  }
+
+  private void tearDown() {
+    if (lifecycle != null) {
+      lifecycle.stop();
+    }
+    if (requestContext != null) {
+      requestContext.setContext(null);
+    }
+    if (db != null) {
+      db.close();
+    }
+    InMemoryDatabase.drop(inMemoryDatabase);
+  }
+}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/SshMode.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/SshMode.java
index 9320331..0bf643cc 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/SshMode.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/SshMode.java
@@ -19,6 +19,12 @@
 import com.google.common.base.Enums;
 import com.google.common.base.Strings;
 
+/**
+ * Whether to enable/disable tests using SSH by inspecting the global environment.
+ *
+ * <p>Acceptance tests should generally not inspect this directly, since SSH may also be disabled on
+ * a per-class or per-method basis. Inject {@code @SshEnabled boolean} instead.
+ */
 public enum SshMode {
   /** Tests annotated with UseSsh will be disabled. */
   NO,
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
index 459bccd..a47a0e5 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestChanges.java
@@ -15,7 +15,6 @@
 package com.google.gerrit.testutil;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static org.easymock.EasyMock.expect;
 
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.TimeUtil;
@@ -33,12 +32,9 @@
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gwtorm.server.OrmException;
 import com.google.inject.Injector;
 import java.util.TimeZone;
 import java.util.concurrent.atomic.AtomicInteger;
-import org.easymock.EasyMock;
 import org.eclipse.jgit.junit.TestRepository;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
@@ -81,7 +77,7 @@
     return ps;
   }
 
-  public static ChangeUpdate newUpdate(Injector injector, Change c, final CurrentUser user)
+  public static ChangeUpdate newUpdate(Injector injector, Change c, CurrentUser user)
       throws Exception {
     injector =
         injector.createChildInjector(
@@ -95,7 +91,8 @@
         injector
             .getInstance(ChangeUpdate.Factory.class)
             .create(
-                stubChangeControl(injector.getInstance(AbstractChangeNotes.Args.class), c, user),
+                new ChangeNotes(injector.getInstance(AbstractChangeNotes.Args.class), c).load(),
+                user,
                 TimeUtil.nowTs(),
                 Ordering.<String>natural());
 
@@ -129,19 +126,6 @@
     }
   }
 
-  private static ChangeControl stubChangeControl(
-      AbstractChangeNotes.Args args, Change c, CurrentUser user) throws OrmException {
-    ChangeControl ctl = EasyMock.createMock(ChangeControl.class);
-    expect(ctl.getChange()).andStubReturn(c);
-    expect(ctl.getProject()).andStubReturn(new Project(c.getProject()));
-    expect(ctl.getUser()).andStubReturn(user);
-    ChangeNotes notes = new ChangeNotes(args, c).load();
-    expect(ctl.getNotes()).andStubReturn(notes);
-    expect(ctl.getId()).andStubReturn(c.getId());
-    EasyMock.replay(ctl);
-    return ctl;
-  }
-
   public static void incrementPatchSet(Change change) {
     PatchSet.Id curr = change.currentPatchSetId();
     PatchSetInfo ps =
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
deleted file mode 100644
index e6a72fc..0000000
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestNotesMigration.java
+++ /dev/null
@@ -1,132 +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.testutil;
-
-import static com.google.common.base.Preconditions.checkNotNull;
-
-import com.google.gerrit.server.notedb.NoteDbChangeState.PrimaryStorage;
-import com.google.gerrit.server.notedb.NotesMigration;
-import com.google.inject.Singleton;
-
-/** {@link NotesMigration} with bits that can be flipped live for testing. */
-@Singleton
-public class TestNotesMigration extends NotesMigration {
-  private volatile boolean readChanges;
-  private volatile boolean writeChanges;
-  private volatile PrimaryStorage changePrimaryStorage = PrimaryStorage.REVIEW_DB;
-  private volatile boolean disableChangeReviewDb;
-  private volatile boolean failOnLoad;
-
-  @Override
-  public boolean readChanges() {
-    return readChanges;
-  }
-
-  @Override
-  public boolean readChangeSequence() {
-    // Unlike ConfigNotesMigration, read change numbers from NoteDb by default
-    // when reads are enabled, to improve test coverage.
-    return readChanges;
-  }
-
-  @Override
-  public PrimaryStorage changePrimaryStorage() {
-    return changePrimaryStorage;
-  }
-
-  @Override
-  public boolean disableChangeReviewDb() {
-    return disableChangeReviewDb;
-  }
-
-  // Increase visbility from superclass, as tests may want to check whether
-  // NoteDb data is written in specific migration scenarios.
-  @Override
-  public boolean writeChanges() {
-    return writeChanges;
-  }
-
-  @Override
-  public boolean failOnLoad() {
-    return failOnLoad;
-  }
-
-  public TestNotesMigration setReadChanges(boolean readChanges) {
-    this.readChanges = readChanges;
-    return this;
-  }
-
-  public TestNotesMigration setWriteChanges(boolean writeChanges) {
-    this.writeChanges = writeChanges;
-    return this;
-  }
-
-  public TestNotesMigration setChangePrimaryStorage(PrimaryStorage changePrimaryStorage) {
-    this.changePrimaryStorage = checkNotNull(changePrimaryStorage);
-    return this;
-  }
-
-  public TestNotesMigration setDisableChangeReviewDb(boolean disableChangeReviewDb) {
-    this.disableChangeReviewDb = disableChangeReviewDb;
-    return this;
-  }
-
-  public TestNotesMigration setFailOnLoad(boolean failOnLoad) {
-    this.failOnLoad = failOnLoad;
-    return this;
-  }
-
-  public TestNotesMigration setAllEnabled(boolean enabled) {
-    return setReadChanges(enabled).setWriteChanges(enabled);
-  }
-
-  public TestNotesMigration setFromEnv() {
-    switch (NoteDbMode.get()) {
-      case READ_WRITE:
-        setWriteChanges(true);
-        setReadChanges(true);
-        setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
-        setDisableChangeReviewDb(false);
-        break;
-      case WRITE:
-        setWriteChanges(true);
-        setReadChanges(false);
-        setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
-        setDisableChangeReviewDb(false);
-        break;
-      case PRIMARY:
-        setWriteChanges(true);
-        setReadChanges(true);
-        setChangePrimaryStorage(PrimaryStorage.NOTE_DB);
-        setDisableChangeReviewDb(false);
-        break;
-      case DISABLE_CHANGE_REVIEW_DB:
-        setWriteChanges(true);
-        setReadChanges(true);
-        setChangePrimaryStorage(PrimaryStorage.NOTE_DB);
-        setDisableChangeReviewDb(true);
-        break;
-      case CHECK:
-      case OFF:
-      default:
-        setWriteChanges(false);
-        setReadChanges(false);
-        setChangePrimaryStorage(PrimaryStorage.REVIEW_DB);
-        setDisableChangeReviewDb(false);
-        break;
-    }
-    return this;
-  }
-}
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/TestUpdateUI.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestUpdateUI.java
new file mode 100644
index 0000000..d4acbcb
--- /dev/null
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/TestUpdateUI.java
@@ -0,0 +1,51 @@
+// 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.testutil;
+
+import com.google.gerrit.server.schema.UpdateUI;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.StatementExecutor;
+import java.util.List;
+import java.util.Set;
+
+public class TestUpdateUI implements UpdateUI {
+  @Override
+  public void message(String message) {}
+
+  @Override
+  public boolean yesno(boolean defaultValue, String message) {
+    return defaultValue;
+  }
+
+  @Override
+  public void waitForUser() {}
+
+  @Override
+  public String readString(String defaultValue, Set<String> allowedValues, String message) {
+    return defaultValue;
+  }
+
+  @Override
+  public boolean isBatch() {
+    return true;
+  }
+
+  @Override
+  public void pruneSchema(StatementExecutor e, List<String> pruneList) throws OrmException {
+    for (String sql : pruneList) {
+      e.execute(sql);
+    }
+  }
+}
diff --git a/gerrit-server/src/test/resources/com/google/gerrit/rules/gerrit_common_test.pl b/gerrit-server/src/test/resources/com/google/gerrit/rules/gerrit_common_test.pl
index c993394..a7df2b9 100644
--- a/gerrit-server/src/test/resources/com/google/gerrit/rules/gerrit_common_test.pl
+++ b/gerrit-server/src/test/resources/com/google/gerrit/rules/gerrit_common_test.pl
@@ -65,7 +65,7 @@
 test(default_submit_fails) :-
   findall(P, default_submit(P), All),
   All = [submit(C, V)],
-  C = label('Code-Review', ok(test_user(alice))),
+  C = label('Code-Review', ok(_)),
   V = label('Verified', need(1)).
 
 
@@ -84,7 +84,7 @@
 test(can_submit_not_ready) :-
   can_submit(gerrit:default_submit, S),
   S = not_ready(submit(C, V)),
-  C = label('Code-Review', ok(test_user(alice))),
+  C = label('Code-Review', ok(_)),
   V = label('Verified', need(1)).
 
 test(can_submit_only_verified_not_ready) :-
@@ -99,7 +99,7 @@
   can_submit(gerrit:default_submit, R),
   filter_submit_results(filter_out_v, [R], S),
   S = [ok(submit(C))],
-  C = label('Code-Review', ok(test_user(alice))).
+  C = label('Code-Review', ok(_)).
 
 test(filter_submit_add_code_review) :-
   set_commit_labels([
@@ -119,7 +119,7 @@
   can_submit(gerrit:default_submit, R),
   arg(1, R, S),
   find_label(S, 'Code-Review', L),
-  L = label('Code-Review', ok(test_user(alice))).
+  L = label('Code-Review', ok(_)).
 
 test(find_default_verified) :-
   can_submit(gerrit:default_submit, R),
@@ -133,7 +133,7 @@
 test(remove_default_code_review) :-
   can_submit(gerrit:default_submit, R),
   arg(1, R, S),
-  C = label('Code-Review', ok(test_user(alice))),
+  C = label('Code-Review', ok(_)),
   remove_label(S, C, Out),
   Out = submit(V),
   V = label('Verified', need(1)).
diff --git a/gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt b/gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt
deleted file mode 100644
index 9edf6a4..0000000
--- a/gerrit-server/src/test/resources/com/google/gerrit/server/mail/send/tlds-alpha-by-domain.txt
+++ /dev/null
@@ -1,1329 +0,0 @@
-# Version 2016060601, Last Updated Tue Jun  7 07:07:01 2016 UTC
-# From http://data.iana.org/TLD/tlds-alpha-by-domain.txt
-AAA
-AARP
-ABB
-ABBOTT
-ABBVIE
-ABOGADO
-ABUDHABI
-AC
-ACADEMY
-ACCENTURE
-ACCOUNTANT
-ACCOUNTANTS
-ACO
-ACTIVE
-ACTOR
-AD
-ADAC
-ADS
-ADULT
-AE
-AEG
-AERO
-#! AETNA
-AF
-AFL
-AG
-AGAKHAN
-AGENCY
-AI
-AIG
-AIRFORCE
-AIRTEL
-AKDN
-AL
-ALIBABA
-ALIPAY
-ALLFINANZ
-ALLY
-ALSACE
-AM
-AMICA
-AMSTERDAM
-ANALYTICS
-ANDROID
-ANQUAN
-AO
-APARTMENTS
-APP
-APPLE
-AQ
-AQUARELLE
-AR
-ARAMCO
-ARCHI
-ARMY
-ARPA
-ARTE
-AS
-ASIA
-ASSOCIATES
-AT
-ATTORNEY
-AU
-AUCTION
-AUDI
-AUDIO
-AUTHOR
-AUTO
-AUTOS
-AVIANCA
-AW
-AWS
-AX
-AXA
-AZ
-AZURE
-BA
-BABY
-BAIDU
-BAND
-BANK
-BAR
-BARCELONA
-BARCLAYCARD
-BARCLAYS
-BAREFOOT
-BARGAINS
-BAUHAUS
-BAYERN
-BB
-BBC
-BBVA
-BCG
-BCN
-BD
-BE
-BEATS
-BEER
-BENTLEY
-BERLIN
-BEST
-BET
-BF
-BG
-BH
-BHARTI
-BI
-BIBLE
-BID
-BIKE
-BING
-BINGO
-BIO
-BIZ
-BJ
-BLACK
-BLACKFRIDAY
-#! BLOG
-BLOOMBERG
-BLUE
-BM
-BMS
-BMW
-BN
-BNL
-BNPPARIBAS
-BO
-BOATS
-BOEHRINGER
-BOM
-BOND
-BOO
-BOOK
-BOOTS
-BOSCH
-BOSTIK
-BOT
-BOUTIQUE
-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
-CAMERA
-CAMP
-CANCERRESEARCH
-CANON
-CAPETOWN
-CAPITAL
-CAR
-CARAVAN
-CARDS
-CARE
-CAREER
-CAREERS
-CARS
-CARTIER
-CASA
-CASH
-CASINO
-CAT
-CATERING
-CBA
-CBN
-CC
-CD
-CEB
-CENTER
-CEO
-CERN
-CF
-CFA
-CFD
-CG
-CH
-CHANEL
-CHANNEL
-CHASE
-CHAT
-CHEAP
-CHLOE
-CHRISTMAS
-CHROME
-CHURCH
-CI
-CIPRIANI
-CIRCLE
-CISCO
-CITIC
-CITY
-CITYEATS
-CK
-CL
-CLAIMS
-CLEANING
-CLICK
-CLINIC
-CLINIQUE
-CLOTHING
-CLOUD
-CLUB
-CLUBMED
-CM
-CN
-CO
-COACH
-CODES
-COFFEE
-COLLEGE
-COLOGNE
-COM
-COMMBANK
-COMMUNITY
-COMPANY
-COMPARE
-COMPUTER
-COMSEC
-CONDOS
-CONSTRUCTION
-CONSULTING
-CONTACT
-CONTRACTORS
-COOKING
-COOL
-COOP
-CORSICA
-COUNTRY
-COUPON
-COUPONS
-COURSES
-CR
-CREDIT
-CREDITCARD
-CREDITUNION
-CRICKET
-CROWN
-CRS
-CRUISES
-CSC
-CU
-CUISINELLA
-CV
-CW
-CX
-CY
-CYMRU
-CYOU
-CZ
-DABUR
-DAD
-DANCE
-DATE
-DATING
-DATSUN
-DAY
-DCLK
-#! DDS
-DE
-DEALER
-DEALS
-DEGREE
-DELIVERY
-DELL
-DELOITTE
-DELTA
-DEMOCRAT
-DENTAL
-DENTIST
-DESI
-DESIGN
-DEV
-#! DHL
-DIAMONDS
-DIET
-DIGITAL
-DIRECT
-DIRECTORY
-DISCOUNT
-DJ
-DK
-DM
-DNP
-DO
-DOCS
-DOG
-DOHA
-DOMAINS
-#! DOT
-DOWNLOAD
-DRIVE
-#! DTV
-DUBAI
-DURBAN
-DVAG
-DZ
-EARTH
-EAT
-EC
-EDEKA
-EDU
-EDUCATION
-EE
-EG
-EMAIL
-EMERCK
-ENERGY
-ENGINEER
-ENGINEERING
-ENTERPRISES
-EPSON
-EQUIPMENT
-ER
-ERNI
-ES
-ESQ
-ESTATE
-ET
-EU
-EUROVISION
-EUS
-EVENTS
-EVERBANK
-EXCHANGE
-EXPERT
-EXPOSED
-EXPRESS
-EXTRASPACE
-FAGE
-FAIL
-FAIRWINDS
-FAITH
-FAMILY
-FAN
-FANS
-FARM
-FASHION
-FAST
-FEEDBACK
-FERRERO
-FI
-FILM
-FINAL
-FINANCE
-FINANCIAL
-FIRESTONE
-FIRMDALE
-FISH
-FISHING
-FIT
-FITNESS
-FJ
-FK
-FLICKR
-FLIGHTS
-#! FLIR
-FLORIST
-FLOWERS
-FLSMIDTH
-FLY
-FM
-FO
-FOO
-FOOTBALL
-FORD
-FOREX
-FORSALE
-FORUM
-FOUNDATION
-FOX
-FR
-FRESENIUS
-FRL
-FROGANS
-FRONTIER
-FTR
-FUND
-FURNITURE
-FUTBOL
-FYI
-GA
-GAL
-GALLERY
-GALLO
-GALLUP
-GAME
-#! GAMES
-GARDEN
-GB
-GBIZ
-GD
-GDN
-GE
-GEA
-GENT
-GENTING
-GF
-GG
-GGEE
-GH
-GI
-GIFT
-GIFTS
-GIVES
-GIVING
-GL
-GLASS
-GLE
-GLOBAL
-GLOBO
-GM
-GMAIL
-GMBH
-GMO
-GMX
-GN
-GOLD
-GOLDPOINT
-GOLF
-GOO
-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
-HAMBURG
-HANGOUT
-HAUS
-HDFCBANK
-HEALTH
-HEALTHCARE
-HELP
-HELSINKI
-HERE
-HERMES
-HIPHOP
-#! HISAMITSU
-HITACHI
-HIV
-HK
-#! HKT
-HM
-HN
-HOCKEY
-HOLDINGS
-HOLIDAY
-HOMEDEPOT
-HOMES
-HONDA
-HORSE
-HOST
-HOSTING
-HOTELES
-HOTMAIL
-HOUSE
-HOW
-HR
-HSBC
-HT
-HTC
-HU
-HYUNDAI
-IBM
-ICBC
-ICE
-ICU
-ID
-IE
-IFM
-IINET
-IL
-IM
-IMAMAT
-IMMO
-IMMOBILIEN
-IN
-INDUSTRIES
-INFINITI
-INFO
-ING
-INK
-INSTITUTE
-INSURANCE
-INSURE
-INT
-INTERNATIONAL
-INVESTMENTS
-IO
-IPIRANGA
-IQ
-IR
-IRISH
-IS
-ISELECT
-ISMAILI
-IST
-ISTANBUL
-IT
-ITAU
-IWC
-JAGUAR
-JAVA
-JCB
-JCP
-JE
-JETZT
-JEWELRY
-JLC
-JLL
-JM
-JMP
-JNJ
-JO
-JOBS
-JOBURG
-JOT
-JOY
-JP
-JPMORGAN
-JPRS
-JUEGOS
-KAUFEN
-KDDI
-KE
-KERRYHOTELS
-KERRYLOGISTICS
-KERRYPROPERTIES
-KFH
-KG
-KH
-KI
-KIA
-KIM
-KINDER
-KITCHEN
-KIWI
-KM
-KN
-KOELN
-KOMATSU
-KP
-KPMG
-KPN
-KR
-KRD
-KRED
-KUOKGROUP
-KW
-KY
-KYOTO
-KZ
-LA
-LACAIXA
-LAMBORGHINI
-LAMER
-LANCASTER
-LAND
-LANDROVER
-LANXESS
-LASALLE
-LAT
-LATROBE
-LAW
-LAWYER
-LB
-LC
-LDS
-LEASE
-LECLERC
-LEGAL
-LEXUS
-LGBT
-LI
-LIAISON
-LIDL
-LIFE
-LIFEINSURANCE
-LIFESTYLE
-LIGHTING
-LIKE
-LIMITED
-LIMO
-LINCOLN
-LINDE
-LINK
-#! LIPSY
-LIVE
-LIVING
-LIXIL
-LK
-LOAN
-LOANS
-#! LOCKER
-LOCUS
-LOL
-LONDON
-LOTTE
-LOTTO
-LOVE
-LR
-LS
-LT
-LTD
-LTDA
-LU
-LUPIN
-LUXE
-LUXURY
-LV
-LY
-MA
-MADRID
-MAIF
-MAISON
-MAKEUP
-MAN
-MANAGEMENT
-MANGO
-MARKET
-MARKETING
-MARKETS
-MARRIOTT
-#! MATTEL
-MBA
-MC
-MD
-ME
-MED
-MEDIA
-MEET
-MELBOURNE
-MEME
-MEMORIAL
-MEN
-MENU
-MEO
-#! METLIFE
-MG
-MH
-MIAMI
-MICROSOFT
-MIL
-MINI
-MK
-ML
-#! MLB
-MLS
-MM
-MMA
-MN
-MO
-MOBI
-MOBILY
-MODA
-MOE
-MOI
-MOM
-MONASH
-MONEY
-MONTBLANC
-MORMON
-MORTGAGE
-MOSCOW
-MOTORCYCLES
-MOV
-MOVIE
-MOVISTAR
-MP
-MQ
-MR
-MS
-MT
-MTN
-MTPC
-MTR
-MU
-MUSEUM
-MUTUAL
-MUTUELLE
-MV
-MW
-MX
-MY
-MZ
-NA
-NADEX
-NAGOYA
-NAME
-NATURA
-NAVY
-NC
-NE
-NEC
-NET
-NETBANK
-#! NETFLIX
-NETWORK
-NEUSTAR
-NEW
-NEWS
-#! NEXT
-#! NEXTDIRECT
-NEXUS
-NF
-NG
-NGO
-NHK
-NI
-NICO
-NIKON
-NINJA
-NISSAN
-NISSAY
-NL
-NO
-NOKIA
-NORTHWESTERNMUTUAL
-NORTON
-NOWRUZ
-#! NOWTV
-NP
-NR
-NRA
-NRW
-NTT
-NU
-NYC
-NZ
-OBI
-OFFICE
-OKINAWA
-#! OLAYAN
-#! OLAYANGROUP
-#! OLLO
-OM
-OMEGA
-ONE
-ONG
-ONL
-ONLINE
-OOO
-ORACLE
-ORANGE
-ORG
-ORGANIC
-ORIGINS
-OSAKA
-OTSUKA
-#! OTT
-OVH
-PA
-PAGE
-PAMPEREDCHEF
-PANERAI
-PARIS
-PARS
-PARTNERS
-PARTS
-PARTY
-PASSAGENS
-#! PCCW
-PE
-PET
-PF
-PG
-PH
-PHARMACY
-PHILIPS
-PHOTO
-PHOTOGRAPHY
-PHOTOS
-PHYSIO
-PIAGET
-PICS
-PICTET
-PICTURES
-PID
-PIN
-PING
-PINK
-#! PIONEER
-PIZZA
-PK
-PL
-PLACE
-PLAY
-PLAYSTATION
-PLUMBING
-PLUS
-PM
-PN
-POHL
-POKER
-PORN
-POST
-PR
-PRAXI
-PRESS
-PRO
-PROD
-PRODUCTIONS
-PROF
-PROGRESSIVE
-PROMO
-PROPERTIES
-PROPERTY
-PROTECTION
-PS
-PT
-PUB
-PW
-PWC
-PY
-QA
-QPON
-QUEBEC
-QUEST
-RACING
-RE
-READ
-#! REALESTATE
-REALTOR
-REALTY
-RECIPES
-RED
-REDSTONE
-REDUMBRELLA
-REHAB
-REISE
-REISEN
-REIT
-REN
-RENT
-RENTALS
-REPAIR
-REPORT
-REPUBLICAN
-REST
-RESTAURANT
-REVIEW
-REVIEWS
-REXROTH
-RICH
-#! RICHARDLI
-RICOH
-RIO
-RIP
-RO
-ROCHER
-ROCKS
-RODEO
-ROOM
-RS
-RSVP
-RU
-RUHR
-RUN
-RW
-RWE
-RYUKYU
-SA
-SAARLAND
-SAFE
-SAFETY
-SAKURA
-SALE
-SALON
-SAMSUNG
-SANDVIK
-SANDVIKCOROMANT
-SANOFI
-SAP
-SAPO
-SARL
-SAS
-SAXO
-SB
-SBI
-SBS
-SC
-SCA
-SCB
-SCHAEFFLER
-SCHMIDT
-SCHOLARSHIPS
-SCHOOL
-SCHULE
-SCHWARZ
-SCIENCE
-SCOR
-SCOT
-SD
-SE
-SEAT
-SECURITY
-SEEK
-SELECT
-SENER
-SERVICES
-SEVEN
-SEW
-SEX
-SEXY
-SFR
-SG
-SH
-SHARP
-SHAW
-SHELL
-SHIA
-SHIKSHA
-SHOES
-#! SHOP
-SHOUJI
-SHOW
-SHRIRAM
-SI
-SINA
-SINGLES
-SITE
-SJ
-SK
-SKI
-SKIN
-SKY
-SKYPE
-SL
-SM
-SMILE
-SN
-SNCF
-SO
-SOCCER
-SOCIAL
-SOFTBANK
-SOFTWARE
-SOHU
-SOLAR
-SOLUTIONS
-SONG
-SONY
-SOY
-SPACE
-SPIEGEL
-SPOT
-SPREADBETTING
-SR
-SRL
-ST
-STADA
-STAR
-STARHUB
-STATEBANK
-STATEFARM
-STATOIL
-STC
-STCGROUP
-STOCKHOLM
-STORAGE
-STORE
-STREAM
-STUDIO
-STUDY
-STYLE
-SU
-SUCKS
-SUPPLIES
-SUPPLY
-SUPPORT
-SURF
-SURGERY
-SUZUKI
-SV
-SWATCH
-SWISS
-SX
-SY
-SYDNEY
-SYMANTEC
-SYSTEMS
-SZ
-TAB
-TAIPEI
-TALK
-TAOBAO
-TATAMOTORS
-TATAR
-TATTOO
-TAX
-TAXI
-TC
-TCI
-TD
-TEAM
-TECH
-TECHNOLOGY
-TEL
-TELECITY
-TELEFONICA
-TEMASEK
-TENNIS
-TEVA
-TF
-TG
-TH
-THD
-THEATER
-THEATRE
-TICKETS
-TIENDA
-TIFFANY
-TIPS
-TIRES
-TIROL
-TJ
-TK
-TL
-TM
-TMALL
-TN
-TO
-TODAY
-TOKYO
-TOOLS
-TOP
-TORAY
-TOSHIBA
-TOTAL
-TOURS
-TOWN
-TOYOTA
-TOYS
-TR
-TRADE
-TRADING
-TRAINING
-TRAVEL
-TRAVELERS
-TRAVELERSINSURANCE
-TRUST
-TRV
-TT
-TUBE
-TUI
-TUNES
-TUSHU
-TV
-TVS
-TW
-TZ
-UA
-UBS
-UG
-UK
-UNICOM
-UNIVERSITY
-UNO
-UOL
-#! UPS
-US
-UY
-UZ
-VA
-VACATIONS
-VANA
-VC
-VE
-VEGAS
-VENTURES
-VERISIGN
-VERSICHERUNG
-VET
-VG
-VI
-VIAJES
-VIDEO
-VIG
-VIKING
-VILLAS
-VIN
-VIP
-VIRGIN
-VISION
-VISTA
-VISTAPRINT
-VIVA
-VLAANDEREN
-VN
-VODKA
-VOLKSWAGEN
-VOTE
-VOTING
-VOTO
-VOYAGE
-VU
-VUELOS
-WALES
-WALTER
-WANG
-WANGGOU
-#! WARMAN
-WATCH
-WATCHES
-WEATHER
-WEATHERCHANNEL
-WEBCAM
-WEBER
-WEBSITE
-WED
-WEDDING
-WEIBO
-WEIR
-WF
-WHOSWHO
-WIEN
-WIKI
-WILLIAMHILL
-WIN
-WINDOWS
-WINE
-WME
-WOLTERSKLUWER
-WORK
-WORKS
-WORLD
-WS
-WTC
-WTF
-XBOX
-XEROX
-XIHUAN
-XIN
-XN--11B4C3D
-XN--1CK2E1B
-XN--1QQW23A
-XN--30RR7Y
-XN--3BST00M
-XN--3DS443G
-XN--3E0B707E
-XN--3PXU8K
-XN--42C2D9A
-XN--45BRJ9C
-XN--45Q11C
-XN--4GBRIM
-XN--55QW42G
-XN--55QX5D
-XN--5TZM5G
-XN--6FRZ82G
-XN--6QQ986B3XL
-XN--80ADXHKS
-XN--80AO21A
-XN--80ASEHDB
-XN--80ASWG
-XN--8Y0A063A
-XN--90A3AC
-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--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--MGBAYH7GPA
-XN--MGBB9FBPOB
-XN--MGBBH1A71E
-XN--MGBC0A9AZCG
-XN--MGBCA7DZDO
-XN--MGBERP4A5D4AR
-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--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
-ZM
-ZONE
-ZUERICH
-ZW
diff --git a/gerrit-sshd/BUILD b/gerrit-sshd/BUILD
index 189057e9..611df61 100644
--- a/gerrit-sshd/BUILD
+++ b/gerrit-sshd/BUILD
@@ -15,6 +15,8 @@
         "//gerrit-lucene:lucene",
         "//gerrit-patch-jgit:server",
         "//gerrit-reviewdb:server",
+        "//gerrit-server:metrics",
+        "//gerrit-server:receive",
         "//gerrit-server:server",
         "//gerrit-util-cli:cli",
         "//lib:args4j",
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
index cf76dcb..0b48cf5 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AbstractGitCommand.java
@@ -18,7 +18,9 @@
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -44,10 +46,12 @@
   @Inject private IdentifiedUser.GenericFactory userFactory;
 
   protected Repository repo;
+  protected ProjectState state;
+  protected Project.NameKey projectName;
   protected Project project;
 
   @Override
-  public void start(final Environment env) {
+  public void start(Environment env) {
     Context ctx = context.subContext(newSession(), context.getCommandLine());
     final Context old = sshScope.set(ctx);
     try {
@@ -84,11 +88,13 @@
     return n;
   }
 
-  private void service() throws IOException, Failure {
-    project = projectControl.getProjectState().getProject();
+  private void service() throws IOException, PermissionBackendException, Failure {
+    state = projectControl.getProjectState();
+    project = state.getProject();
+    projectName = project.getNameKey();
 
     try {
-      repo = repoManager.openRepository(project.getNameKey());
+      repo = repoManager.openRepository(projectName);
     } catch (RepositoryNotFoundException e) {
       throw new Failure(1, "fatal: '" + project.getName() + "': not a git archive", e);
     }
@@ -100,5 +106,5 @@
     }
   }
 
-  protected abstract void runImpl() throws IOException, Failure;
+  protected abstract void runImpl() throws IOException, PermissionBackendException, Failure;
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
index 45835d9..0ac7765 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommand.java
@@ -16,12 +16,16 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.util.concurrent.Atomics;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.api.access.GlobalOrPluginPermission;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityControl;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import java.io.IOException;
 import java.util.LinkedList;
 import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.sshd.server.Command;
 import org.apache.sshd.server.Environment;
@@ -30,14 +34,17 @@
 public class AliasCommand extends BaseCommand {
   private final DispatchCommandProvider root;
   private final CurrentUser currentUser;
+  private final PermissionBackend permissionBackend;
   private final CommandName command;
   private final AtomicReference<Command> atomicCmd;
 
   AliasCommand(
       @CommandName(Commands.ROOT) DispatchCommandProvider root,
+      PermissionBackend permissionBackend,
       CurrentUser currentUser,
       CommandName command) {
     this.root = root;
+    this.permissionBackend = permissionBackend;
     this.currentUser = currentUser;
     this.command = command;
     this.atomicCmd = Atomics.newReference();
@@ -47,7 +54,7 @@
   public void start(Environment env) throws IOException {
     try {
       begin(env);
-    } catch (UnloggedFailure e) {
+    } catch (Failure e) {
       String msg = e.getMessage();
       if (!msg.endsWith("\n")) {
         msg += "\n";
@@ -58,7 +65,7 @@
     }
   }
 
-  private void begin(Environment env) throws UnloggedFailure, IOException {
+  private void begin(Environment env) throws IOException, Failure {
     Map<String, CommandProvider> map = root.getMap();
     for (String name : chain(command)) {
       CommandProvider p = map.get(name);
@@ -103,17 +110,16 @@
     }
   }
 
-  private void checkRequiresCapability(Command cmd) throws UnloggedFailure {
-    RequiresCapability rc = cmd.getClass().getAnnotation(RequiresCapability.class);
-    if (rc != null) {
-      CapabilityControl ctl = currentUser.getCapabilities();
-      if (!ctl.canPerform(rc.value()) && !ctl.canAdministrateServer()) {
-        String msg =
-            String.format(
-                "fatal: %s does not have \"%s\" capability.",
-                currentUser.getUserName(), rc.value());
-        throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, msg);
+  private void checkRequiresCapability(Command cmd) throws Failure {
+    try {
+      Set<GlobalOrPluginPermission> check = GlobalPermission.fromAnnotation(cmd.getClass());
+      try {
+        permissionBackend.user(currentUser).checkAny(check);
+      } catch (AuthException err) {
+        throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, "fatal: " + err.getMessage());
       }
+    } catch (PermissionBackendException err) {
+      throw new Failure(1, "fatal: permissions unavailable", err);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
index 10beb40..0ef0473 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/AliasCommandProvider.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.permissions.PermissionBackend;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import org.apache.sshd.server.Command;
@@ -27,6 +28,7 @@
   @CommandName(Commands.ROOT)
   private DispatchCommandProvider root;
 
+  @Inject private PermissionBackend permissionBackend;
   @Inject private CurrentUser currentUser;
 
   public AliasCommandProvider(CommandName command) {
@@ -35,6 +37,6 @@
 
   @Override
   public Command get() {
-    return new AliasCommand(root, currentUser, command);
+    return new AliasCommand(root, permissionBackend, currentUser, command);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
index 7092fb0..220b0d3 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/BaseCommand.java
@@ -21,20 +21,26 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.annotations.PluginName;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.AccessPath;
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.RequestCleanup;
 import com.google.gerrit.server.git.ProjectRunnable;
-import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
+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.project.NoSuchChangeException;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.sshd.SshScope.Context;
 import com.google.gerrit.util.cli.CmdLineParser;
 import com.google.gerrit.util.cli.EndOfOptionsHandler;
 import com.google.inject.Inject;
+import com.google.inject.Injector;
 import java.io.BufferedWriter;
 import java.io.IOException;
 import java.io.InputStream;
@@ -45,6 +51,7 @@
 import java.io.StringWriter;
 import java.nio.charset.Charset;
 import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 import java.util.concurrent.atomic.AtomicReference;
 import org.apache.sshd.common.SshException;
 import org.apache.sshd.server.Command;
@@ -80,8 +87,9 @@
 
   @Inject private RequestCleanup cleanup;
 
-  @Inject @CommandExecutor private WorkQueue.Executor executor;
+  @Inject @CommandExecutor private ScheduledThreadPoolExecutor executor;
 
+  @Inject private PermissionBackend permissionBackend;
   @Inject private CurrentUser user;
 
   @Inject private SshScope.Context context;
@@ -91,6 +99,10 @@
   @PluginName
   private String pluginName;
 
+  @Inject private Injector injector;
+
+  @Inject private DynamicMap<DynamicOptions.DynamicBean> dynamicBeans = null;
+
   /** The task, as scheduled on a worker thread. */
   private final AtomicReference<Future<?>> task;
 
@@ -108,22 +120,22 @@
   }
 
   @Override
-  public void setInputStream(final InputStream in) {
+  public void setInputStream(InputStream in) {
     this.in = in;
   }
 
   @Override
-  public void setOutputStream(final OutputStream out) {
+  public void setOutputStream(OutputStream out) {
     this.out = out;
   }
 
   @Override
-  public void setErrorStream(final OutputStream err) {
+  public void setErrorStream(OutputStream err) {
     this.err = err;
   }
 
   @Override
-  public void setExitCallback(final ExitCallback callback) {
+  public void setExitCallback(ExitCallback callback) {
     this.exit = callback;
   }
 
@@ -136,7 +148,7 @@
     return commandName;
   }
 
-  void setName(final String prefix) {
+  void setName(String prefix) {
     this.commandName = prefix;
   }
 
@@ -144,7 +156,7 @@
     return argv;
   }
 
-  public void setArguments(final String[] argv) {
+  public void setArguments(String[] argv) {
     this.argv = argv;
   }
 
@@ -185,7 +197,7 @@
    *
    * @param cmd the command that will receive the current state.
    */
-  protected void provideStateTo(final Command cmd) {
+  protected void provideStateTo(Command cmd) {
     cmd.setInputStream(in);
     cmd.setOutputStream(out);
     cmd.setErrorStream(err);
@@ -218,6 +230,10 @@
    */
   protected void parseCommandLine(Object options) throws UnloggedFailure {
     final CmdLineParser clp = newCmdLineParser(options);
+    DynamicOptions pluginOptions = new DynamicOptions(options, injector, dynamicBeans);
+    pluginOptions.parseDynamicBeans(clp);
+    pluginOptions.setDynamicBeans();
+    pluginOptions.onBeanParseStart();
     try {
       clp.parseArgument(argv);
     } catch (IllegalArgumentException | CmdLineException err) {
@@ -232,6 +248,7 @@
       msg.write(usage());
       throw new UnloggedFailure(1, msg.toString());
     }
+    pluginOptions.onBeanParseEnd();
   }
 
   protected String usage() {
@@ -249,38 +266,12 @@
    * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
    *
    * <pre>
-   * startThread(new Runnable() {
-   *   public void run() {
-   *     runImp();
-   *   }
-   * });
-   * </pre>
-   *
-   * @param thunk the runnable to execute on the thread, performing the command's logic.
-   * @param accessPath the path used by the end user for running the SSH command
-   */
-  protected void startThread(final Runnable thunk, AccessPath accessPath) {
-    startThread(
-        new CommandRunnable() {
-          @Override
-          public void run() throws Exception {
-            thunk.run();
-          }
-        },
-        accessPath);
-  }
-
-  /**
-   * Spawn a function into its own thread.
-   *
-   * <p>Typically this should be invoked within {@link Command#start(Environment)}, such as:
-   *
-   * <pre>
    * startThread(new CommandRunnable() {
    *   public void run() throws Exception {
    *     runImp();
    *   }
-   * });
+   * },
+   * accessPath);
    * </pre>
    *
    * <p>If the function throws an exception, it is translated to a simple message for the client, a
@@ -292,7 +283,7 @@
   protected void startThread(final CommandRunnable thunk, AccessPath accessPath) {
     final TaskThunk tt = new TaskThunk(thunk, accessPath);
 
-    if (isAdminHighPriorityCommand() && user.getCapabilities().canAdministrateServer()) {
+    if (isAdminHighPriorityCommand()) {
       // Admin commands should not block the main work threads (there
       // might be an interactive shell there), nor should they wait
       // for the main work threads.
@@ -304,7 +295,15 @@
   }
 
   private boolean isAdminHighPriorityCommand() {
-    return getClass().getAnnotation(AdminHighPriorityCommand.class) != null;
+    if (getClass().getAnnotation(AdminHighPriorityCommand.class) != null) {
+      try {
+        permissionBackend.user(user).check(GlobalPermission.ADMINISTRATE_SERVER);
+        return true;
+      } catch (AuthException | PermissionBackendException e) {
+        return false;
+      }
+    }
+    return false;
   }
 
   /**
@@ -316,7 +315,7 @@
    *
    * @param rc exit code for the remote client.
    */
-  protected void onExit(final int rc) {
+  protected void onExit(int rc) {
     exit.onExit(rc);
     if (cleanup != null) {
       cleanup.run();
@@ -324,11 +323,11 @@
   }
 
   /** Wrap the supplied output stream in a UTF-8 encoded PrintWriter. */
-  protected static PrintWriter toPrintWriter(final OutputStream o) {
+  protected static PrintWriter toPrintWriter(OutputStream o) {
     return new PrintWriter(new BufferedWriter(new OutputStreamWriter(o, ENC)));
   }
 
-  private int handleError(final Throwable e) {
+  private int handleError(Throwable e) {
     if ((e.getClass() == IOException.class && "Pipe closed".equals(e.getMessage()))
         || //
         (e.getClass() == SshException.class && "Already closed".equals(e.getMessage()))
@@ -511,6 +510,7 @@
   }
 
   /** Runnable function which can throw an exception. */
+  @FunctionalInterface
   public interface CommandRunnable {
     void run() throws Exception;
   }
@@ -537,7 +537,7 @@
      *     command. Should be between 1 and 255, inclusive.
      * @param msg message to also send to the client's stderr.
      */
-    public Failure(final int exitCode, final String msg) {
+    public Failure(int exitCode, String msg) {
       this(exitCode, msg, null);
     }
 
@@ -550,7 +550,7 @@
      * @param why stack trace to include in the server's log, but is not sent to the client's
      *     stderr.
      */
-    public Failure(final int exitCode, final String msg, final Throwable why) {
+    public Failure(int exitCode, String msg, Throwable why) {
       super(msg, why);
       this.exitCode = exitCode;
     }
@@ -565,7 +565,7 @@
      *
      * @param msg message to also send to the client's stderr.
      */
-    public UnloggedFailure(final String msg) {
+    public UnloggedFailure(String msg) {
       this(1, msg);
     }
 
@@ -576,7 +576,7 @@
      *     command. Should be between 1 and 255, inclusive.
      * @param msg message to also send to the client's stderr.
      */
-    public UnloggedFailure(final int exitCode, final String msg) {
+    public UnloggedFailure(int exitCode, String msg) {
       this(exitCode, msg, null);
     }
 
@@ -589,7 +589,7 @@
      * @param why stack trace to include in the server's log, but is not sent to the client's
      *     stderr.
      */
-    public UnloggedFailure(final int exitCode, final String msg, final Throwable why) {
+    public UnloggedFailure(int exitCode, String msg, Throwable why) {
       super(exitCode, msg, why);
     }
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
index 4488c71..1c55f48 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/ChangeArgumentParser.java
@@ -14,8 +14,8 @@
 
 package com.google.gerrit.sshd;
 
-import static java.util.stream.Collectors.toList;
-
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -24,8 +24,10 @@
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.ChangesCollection;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
-import com.google.gerrit.server.project.NoSuchChangeException;
+import com.google.gerrit.server.permissions.ChangePermission;
+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.project.ProjectControl;
 import com.google.gerrit.sshd.BaseCommand.UnloggedFailure;
 import com.google.gwtorm.server.OrmException;
@@ -34,7 +36,6 @@
 import java.util.Arrays;
 import java.util.List;
 import java.util.Map;
-import java.util.Optional;
 
 public class ChangeArgumentParser {
   private final CurrentUser currentUser;
@@ -42,7 +43,7 @@
   private final ChangeFinder changeFinder;
   private final ReviewDb db;
   private final ChangeNotes.Factory changeNotesFactory;
-  private final ChangeControl.GenericFactory changeControlFactory;
+  private final PermissionBackend permissionBackend;
 
   @Inject
   ChangeArgumentParser(
@@ -51,23 +52,23 @@
       ChangeFinder changeFinder,
       ReviewDb db,
       ChangeNotes.Factory changeNotesFactory,
-      ChangeControl.GenericFactory changeControlFactory) {
+      PermissionBackend permissionBackend) {
     this.currentUser = currentUser;
     this.changesCollection = changesCollection;
     this.changeFinder = changeFinder;
     this.db = db;
     this.changeNotesFactory = changeNotesFactory;
-    this.changeControlFactory = changeControlFactory;
+    this.permissionBackend = permissionBackend;
   }
 
   public void addChange(String id, Map<Change.Id, ChangeResource> changes)
-      throws UnloggedFailure, OrmException {
+      throws UnloggedFailure, OrmException, PermissionBackendException {
     addChange(id, changes, null);
   }
 
   public void addChange(
       String id, Map<Change.Id, ChangeResource> changes, ProjectControl projectControl)
-      throws UnloggedFailure, OrmException {
+      throws UnloggedFailure, OrmException, PermissionBackendException {
     addChange(id, changes, projectControl, true);
   }
 
@@ -76,18 +77,26 @@
       Map<Change.Id, ChangeResource> changes,
       ProjectControl projectControl,
       boolean useIndex)
-      throws UnloggedFailure, OrmException {
-    List<ChangeControl> matched =
-        useIndex ? changeFinder.find(id, currentUser) : changeFromNotesFactory(id, currentUser);
-    List<ChangeControl> toAdd = new ArrayList<>(changes.size());
-    boolean canMaintainServer =
-        currentUser.isIdentifiedUser()
-            && currentUser.asIdentifiedUser().getCapabilities().canMaintainServer();
-    for (ChangeControl ctl : matched) {
-      if (!changes.containsKey(ctl.getId())
-          && inProject(projectControl, ctl.getProject())
-          && (canMaintainServer || ctl.isVisible(db))) {
-        toAdd.add(ctl);
+      throws UnloggedFailure, OrmException, PermissionBackendException {
+    List<ChangeNotes> matched = useIndex ? changeFinder.find(id) : changeFromNotesFactory(id);
+    List<ChangeNotes> toAdd = new ArrayList<>(changes.size());
+    boolean canMaintainServer;
+    try {
+      permissionBackend.user(currentUser).check(GlobalPermission.MAINTAIN_SERVER);
+      canMaintainServer = true;
+    } catch (AuthException | PermissionBackendException e) {
+      canMaintainServer = false;
+    }
+    for (ChangeNotes notes : matched) {
+      if (!changes.containsKey(notes.getChangeId())
+          && inProject(projectControl, notes.getProjectName())
+          && (canMaintainServer
+              || permissionBackend
+                  .user(currentUser)
+                  .change(notes)
+                  .database(db)
+                  .test(ChangePermission.READ))) {
+        toAdd.add(notes);
       }
     }
 
@@ -96,19 +105,18 @@
     } else if (toAdd.size() > 1) {
       throw new UnloggedFailure(1, "\"" + id + "\" matches multiple changes");
     }
-    ChangeControl ctl = toAdd.get(0);
-    changes.put(ctl.getId(), changesCollection.parse(ctl));
+    Change.Id cId = toAdd.get(0).getChangeId();
+    ChangeResource changeResource;
+    try {
+      changeResource = changesCollection.parse(cId);
+    } catch (ResourceNotFoundException e) {
+      throw new UnloggedFailure(1, "\"" + id + "\" no such change");
+    }
+    changes.put(cId, changeResource);
   }
 
-  private List<ChangeControl> changeFromNotesFactory(String id, CurrentUser currentUser)
-      throws OrmException, UnloggedFailure {
-    return changeNotesFactory
-        .create(db, parseId(id))
-        .stream()
-        .map(changeNote -> controlForChange(changeNote, currentUser))
-        .filter(changeControl -> changeControl.isPresent())
-        .map(changeControl -> changeControl.get())
-        .collect(toList());
+  private List<ChangeNotes> changeFromNotesFactory(String id) throws OrmException, UnloggedFailure {
+    return changeNotesFactory.create(db, parseId(id));
   }
 
   private List<Change.Id> parseId(String id) throws UnloggedFailure {
@@ -119,17 +127,9 @@
     }
   }
 
-  private Optional<ChangeControl> controlForChange(ChangeNotes change, CurrentUser user) {
-    try {
-      return Optional.of(changeControlFactory.controlFor(change, user));
-    } catch (NoSuchChangeException e) {
-      return Optional.empty();
-    }
-  }
-
-  private boolean inProject(ProjectControl projectControl, Project project) {
+  private boolean inProject(ProjectControl projectControl, Project.NameKey project) {
     if (projectControl != null) {
-      return projectControl.getProject().getNameKey().equals(project.getNameKey());
+      return projectControl.getProject().getNameKey().equals(project);
     }
 
     // No --project option, so they want every project.
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutor.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutor.java
index fa21c58..4fd55a1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutor.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutor.java
@@ -16,11 +16,11 @@
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-import com.google.gerrit.server.git.WorkQueue.Executor;
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 
-/** Marker on {@link Executor} used by SSH threads. */
+/** Marker on {@link ScheduledThreadPoolExecutor} used by SSH threads. */
 @Retention(RUNTIME)
 @BindingAnnotation
 public @interface CommandExecutor {}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorProvider.java
index 8c47144..993f280 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorProvider.java
@@ -15,24 +15,27 @@
 package com.google.gerrit.sshd;
 
 import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.account.AccountLimits;
 import com.google.gerrit.server.git.QueueProvider;
-import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 
-class CommandExecutorProvider implements Provider<WorkQueue.Executor> {
-
+class CommandExecutorProvider implements Provider<ScheduledThreadPoolExecutor> {
+  private final AccountLimits.Factory limitsFactory;
   private final QueueProvider queues;
   private final CurrentUser user;
 
   @Inject
-  CommandExecutorProvider(QueueProvider queues, CurrentUser user) {
+  CommandExecutorProvider(
+      AccountLimits.Factory limitsFactory, QueueProvider queues, CurrentUser user) {
+    this.limitsFactory = limitsFactory;
     this.queues = queues;
     this.user = user;
   }
 
   @Override
-  public WorkQueue.Executor get() {
-    return queues.getQueue(user.getCapabilities().getQueueType());
+  public ScheduledThreadPoolExecutor get() {
+    return queues.getQueue(limitsFactory.create(user).getQueueType());
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
index d5670f7..13ca52e 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandExecutorQueueProvider.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
-import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.eclipse.jgit.lib.Config;
 
 @Singleton
@@ -28,8 +28,8 @@
 
   private int poolSize;
   private final int batchThreads;
-  private final WorkQueue.Executor interactiveExecutor;
-  private final WorkQueue.Executor batchExecutor;
+  private final ScheduledThreadPoolExecutor interactiveExecutor;
+  private final ScheduledThreadPoolExecutor batchExecutor;
 
   @Inject
   public CommandExecutorQueueProvider(
@@ -43,31 +43,18 @@
       poolSize += batchThreads;
     }
     int interactiveThreads = Math.max(1, poolSize - batchThreads);
-    interactiveExecutor = queues.createQueue(interactiveThreads, "SSH-Interactive-Worker", true);
+    interactiveExecutor =
+        queues.createQueue(interactiveThreads, "SSH-Interactive-Worker", Thread.MIN_PRIORITY, true);
     if (batchThreads != 0) {
-      batchExecutor = queues.createQueue(batchThreads, "SSH-Batch-Worker", true);
-      setThreadFactory(batchExecutor);
+      batchExecutor =
+          queues.createQueue(batchThreads, "SSH-Batch-Worker", Thread.MIN_PRIORITY, true);
     } else {
       batchExecutor = interactiveExecutor;
     }
-    setThreadFactory(interactiveExecutor);
-  }
-
-  private void setThreadFactory(WorkQueue.Executor executor) {
-    final ThreadFactory parent = executor.getThreadFactory();
-    executor.setThreadFactory(
-        new ThreadFactory() {
-          @Override
-          public Thread newThread(final Runnable task) {
-            final Thread t = parent.newThread(task);
-            t.setPriority(Thread.MIN_PRIORITY);
-            return t;
-          }
-        });
   }
 
   @Override
-  public WorkQueue.Executor getQueue(QueueType type) {
+  public ScheduledThreadPoolExecutor getQueue(QueueType type) {
     switch (type) {
       case INTERACTIVE:
         return interactiveExecutor;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
index 501c0f6..e0f458b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandFactoryProvider.java
@@ -62,11 +62,11 @@
 
   @Inject
   CommandFactoryProvider(
-      @CommandName(Commands.ROOT) final DispatchCommandProvider d,
-      @GerritServerConfig final Config cfg,
-      final WorkQueue workQueue,
-      final SshLog l,
-      final SshScope s,
+      @CommandName(Commands.ROOT) DispatchCommandProvider d,
+      @GerritServerConfig Config cfg,
+      WorkQueue workQueue,
+      SshLog l,
+      SshScope s,
       SchemaFactory<ReviewDb> sf,
       DynamicItem<SshCreateCommandInterceptor> i) {
     dispatcher = d;
@@ -97,7 +97,7 @@
   public CommandFactory get() {
     return new CommandFactory() {
       @Override
-      public Command createCommand(final String requestCommand) {
+      public Command createCommand(String requestCommand) {
         String c = requestCommand;
         SshCreateCommandInterceptor interceptor = createCommandInterceptor.get();
         if (interceptor != null) {
@@ -121,7 +121,7 @@
     private final AtomicBoolean logged;
     private final AtomicReference<Future<?>> task;
 
-    Trampoline(final String cmdLine) {
+    Trampoline(String cmdLine) {
       commandLine = cmdLine;
       argv = split(cmdLine);
       logged = new AtomicBoolean();
@@ -129,33 +129,33 @@
     }
 
     @Override
-    public void setInputStream(final InputStream in) {
+    public void setInputStream(InputStream in) {
       this.in = in;
     }
 
     @Override
-    public void setOutputStream(final OutputStream out) {
+    public void setOutputStream(OutputStream out) {
       this.out = out;
     }
 
     @Override
-    public void setErrorStream(final OutputStream err) {
+    public void setErrorStream(OutputStream err) {
       this.err = err;
     }
 
     @Override
-    public void setExitCallback(final ExitCallback callback) {
+    public void setExitCallback(ExitCallback callback) {
       this.exit = callback;
     }
 
     @Override
-    public void setSession(final ServerSession session) {
+    public void setSession(ServerSession session) {
       final SshSession s = session.getAttribute(SshSession.KEY);
       this.ctx = sshScope.newContext(schemaFactory, s, commandLine);
     }
 
     @Override
-    public void start(final Environment env) throws IOException {
+    public void start(Environment env) throws IOException {
       this.env = env;
       final Context ctx = this.ctx;
       task.set(
@@ -212,7 +212,7 @@
       }
     }
 
-    private int translateExit(final int rc) {
+    private int translateExit(int rc) {
       switch (rc) {
         case BaseCommand.STATUS_NOT_ADMIN:
           return 1;
@@ -228,7 +228,7 @@
       }
     }
 
-    private void log(final int rc) {
+    private void log(int rc) {
       if (logged.compareAndSet(false, true)) {
         log.onExecute(cmd, rc, ctx.getSession());
       }
@@ -239,13 +239,7 @@
       Future<?> future = task.getAndSet(null);
       if (future != null) {
         future.cancel(true);
-        destroyExecutor.execute(
-            new Runnable() {
-              @Override
-              public void run() {
-                onDestroy();
-              }
-            });
+        destroyExecutor.execute(this::onDestroy);
       }
     }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandModule.java
index 54ffba6..93aab0b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandModule.java
@@ -28,7 +28,7 @@
    * @param name the name of the command the client will provide in order to call the command.
    * @return a binding that must be bound to a non-singleton provider for a {@link Command} object.
    */
-  protected LinkedBindingBuilder<Command> command(final String name) {
+  protected LinkedBindingBuilder<Command> command(String name) {
     return bind(Commands.key(name));
   }
 
@@ -38,7 +38,7 @@
    * @param name the name of the command the client will provide in order to call the command.
    * @return a binding that must be bound to a non-singleton provider for a {@link Command} object.
    */
-  protected LinkedBindingBuilder<Command> command(final CommandName name) {
+  protected LinkedBindingBuilder<Command> command(CommandName name) {
     return bind(Commands.key(name));
   }
 
@@ -49,7 +49,7 @@
    * @param name the name of the command the client will provide in order to call the command.
    * @return a binding that must be bound to a non-singleton provider for a {@link Command} object.
    */
-  protected LinkedBindingBuilder<Command> command(final CommandName parent, final String name) {
+  protected LinkedBindingBuilder<Command> command(CommandName parent, String name) {
     return bind(Commands.key(parent, name));
   }
 
@@ -60,7 +60,7 @@
    * @param clazz class of the command with {@link CommandMetaData} annotation to retrieve the name
    *     and the description from
    */
-  protected void command(final CommandName parent, final Class<? extends BaseCommand> clazz) {
+  protected void command(CommandName parent, Class<? extends BaseCommand> clazz) {
     CommandMetaData meta = clazz.getAnnotation(CommandMetaData.class);
     if (meta == null) {
       throw new IllegalStateException("no CommandMetaData annotation found");
@@ -78,8 +78,7 @@
    * @param clazz class of the command with {@link CommandMetaData} annotation to retrieve the
    *     description from
    */
-  protected void alias(
-      final CommandName parent, final String name, final Class<? extends BaseCommand> clazz) {
+  protected void alias(final CommandName parent, String name, Class<? extends BaseCommand> clazz) {
     CommandMetaData meta = clazz.getAnnotation(CommandMetaData.class);
     if (meta == null) {
       throw new IllegalStateException("no CommandMetaData annotation found");
@@ -95,7 +94,7 @@
    * @param to name of an already registered command that will perform the action when {@code from}
    *     is invoked by a client.
    */
-  protected void alias(final String from, final String to) {
+  protected void alias(String from, String to) {
     bind(Commands.key(from)).to(Commands.key(to));
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandProvider.java
index 200d3a0..61c36cb 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/CommandProvider.java
@@ -22,7 +22,7 @@
   private final Provider<Command> provider;
   private final String description;
 
-  CommandProvider(final Provider<Command> p, final String d) {
+  CommandProvider(Provider<Command> p, String d) {
     this.provider = p;
     this.description = d;
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/Commands.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/Commands.java
index 620ffbe..43d2c50 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/Commands.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/Commands.java
@@ -27,40 +27,40 @@
   /** Magic value signaling the top level. */
   public static final CommandName CMD_ROOT = named(ROOT);
 
-  public static Key<Command> key(final String name) {
+  public static Key<Command> key(String name) {
     return key(named(name));
   }
 
-  public static Key<Command> key(final CommandName name) {
+  public static Key<Command> key(CommandName name) {
     return Key.get(Command.class, name);
   }
 
-  public static Key<Command> key(final CommandName parent, final String name) {
+  public static Key<Command> key(CommandName parent, String name) {
     return Key.get(Command.class, named(parent, name));
   }
 
-  public static Key<Command> key(final CommandName parent, final String name, final String descr) {
+  public static Key<Command> key(CommandName parent, String name, String descr) {
     return Key.get(Command.class, named(parent, name, descr));
   }
 
   /** Create a CommandName annotation for the supplied name. */
   @AutoAnnotation
-  public static CommandName named(final String value) {
+  public static CommandName named(String value) {
     return new AutoAnnotation_Commands_named(value);
   }
 
   /** Create a CommandName annotation for the supplied name. */
-  public static CommandName named(final CommandName parent, final String name) {
+  public static CommandName named(CommandName parent, String name) {
     return new NestedCommandNameImpl(parent, name);
   }
 
   /** Create a CommandName annotation for the supplied name and description. */
-  public static CommandName named(final CommandName parent, final String name, final String descr) {
+  public static CommandName named(CommandName parent, String name, String descr) {
     return new NestedCommandNameImpl(parent, name, descr);
   }
 
   /** Return the name of this command, possibly including any parents. */
-  public static String nameOf(final CommandName name) {
+  public static String nameOf(CommandName name) {
     if (name instanceof NestedCommandNameImpl) {
       return nameOf(((NestedCommandNameImpl) name).parent) + " " + name.value();
     }
@@ -68,7 +68,7 @@
   }
 
   /** Is the second command a direct child of the first command? */
-  public static boolean isChild(final CommandName parent, final CommandName name) {
+  public static boolean isChild(CommandName parent, CommandName name) {
     if (name instanceof NestedCommandNameImpl) {
       return parent.equals(((NestedCommandNameImpl) name).parent);
     }
@@ -90,13 +90,13 @@
     private final String name;
     private final String descr;
 
-    NestedCommandNameImpl(final CommandName parent, final String name) {
+    NestedCommandNameImpl(CommandName parent, String name) {
       this.parent = parent;
       this.name = name;
       this.descr = "";
     }
 
-    NestedCommandNameImpl(final CommandName parent, final String name, final String descr) {
+    NestedCommandNameImpl(CommandName parent, String name, String descr) {
       this.parent = parent;
       this.name = name;
       this.descr = descr;
@@ -122,7 +122,7 @@
     }
 
     @Override
-    public boolean equals(final Object obj) {
+    public boolean equals(Object obj) {
       return obj instanceof NestedCommandNameImpl
           && parent.equals(((NestedCommandNameImpl) obj).parent)
           && value().equals(((NestedCommandNameImpl) obj).value());
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
index d655500..7e0406a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DatabasePubKeyAuth.java
@@ -62,14 +62,14 @@
 
   @Inject
   DatabasePubKeyAuth(
-      final SshKeyCacheImpl skc,
-      final SshLog l,
-      final IdentifiedUser.GenericFactory uf,
-      final PeerDaemonUser.Factory pf,
-      final SitePaths site,
-      final KeyPairProvider hostKeyProvider,
-      @GerritServerConfig final Config cfg,
-      final SshScope s) {
+      SshKeyCacheImpl skc,
+      SshLog l,
+      IdentifiedUser.GenericFactory uf,
+      PeerDaemonUser.Factory pf,
+      SitePaths site,
+      KeyPairProvider hostKeyProvider,
+      @GerritServerConfig Config cfg,
+      SshScope s) {
     sshKeyCache = skc;
     sshLog = l;
     userFactory = uf;
@@ -92,7 +92,7 @@
   }
 
   private static void addPublicKey(
-      final Collection<PublicKey> out, final KeyPairProvider p, final String type) {
+      final Collection<PublicKey> out, KeyPairProvider p, String type) {
     final KeyPair pair = p.loadKey(type);
     if (pair != null && pair.getPublic() != null) {
       out.add(pair.getPublic());
@@ -162,9 +162,8 @@
     return p.keys;
   }
 
-  private SshKeyCacheEntry find(
-      final Iterable<SshKeyCacheEntry> keyList, final PublicKey suppliedKey) {
-    for (final SshKeyCacheEntry k : keyList) {
+  private SshKeyCacheEntry find(Iterable<SshKeyCacheEntry> keyList, PublicKey suppliedKey) {
+    for (SshKeyCacheEntry k : keyList) {
       if (k.match(suppliedKey)) {
         return k;
       }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
index 2f3d10f6..3f2e258 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommand.java
@@ -20,8 +20,10 @@
 import com.google.common.util.concurrent.Atomics;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.account.CapabilityUtils;
 import com.google.gerrit.server.args4j.SubcommandHandler;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.inject.Inject;
 import com.google.inject.assistedinject.Assisted;
 import java.io.IOException;
@@ -41,6 +43,7 @@
   }
 
   private final CurrentUser currentUser;
+  private final PermissionBackend permissionBackend;
   private final Map<String, CommandProvider> commands;
   private final AtomicReference<Command> atomicCmd;
 
@@ -51,8 +54,12 @@
   private List<String> args = new ArrayList<>();
 
   @Inject
-  DispatchCommand(CurrentUser cu, @Assisted final Map<String, CommandProvider> all) {
-    currentUser = cu;
+  DispatchCommand(
+      CurrentUser user,
+      PermissionBackend permissionBackend,
+      @Assisted Map<String, CommandProvider> all) {
+    this.currentUser = user;
+    this.permissionBackend = permissionBackend;
     commands = all;
     atomicCmd = Atomics.newReference();
   }
@@ -62,7 +69,7 @@
   }
 
   @Override
-  public void start(final Environment env) throws IOException {
+  public void start(Environment env) throws IOException {
     try {
       parseCommandLine();
       if (Strings.isNullOrEmpty(commandName)) {
@@ -117,9 +124,13 @@
       pluginName = ((BaseCommand) cmd).getPluginName();
     }
     try {
-      CapabilityUtils.checkRequiresCapability(currentUser, pluginName, cmd.getClass());
+      permissionBackend
+          .user(currentUser)
+          .checkAny(GlobalPermission.fromAnnotation(pluginName, cmd.getClass()));
     } catch (AuthException e) {
       throw new UnloggedFailure(BaseCommand.STATUS_NOT_ADMIN, e.getMessage());
+    } catch (PermissionBackendException e) {
+      throw new UnloggedFailure(1, "fatal: permission check unavailable", e);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommandProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommandProvider.java
index 2a88f63..c782d2f 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommandProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/DispatchCommandProvider.java
@@ -35,7 +35,7 @@
   private final CommandName parent;
   private volatile ConcurrentMap<String, CommandProvider> map;
 
-  public DispatchCommandProvider(final CommandName cn) {
+  public DispatchCommandProvider(CommandName cn) {
     this.parent = cn;
   }
 
@@ -44,7 +44,7 @@
     return factory.create(getMap());
   }
 
-  public RegistrationHandle register(final CommandName name, final Provider<Command> cmd) {
+  public RegistrationHandle register(CommandName name, Provider<Command> cmd) {
     final ConcurrentMap<String, CommandProvider> m = getMap();
     final CommandProvider commandProvider = new CommandProvider(cmd, null);
     if (m.putIfAbsent(name.value(), commandProvider) != null) {
@@ -58,7 +58,7 @@
     };
   }
 
-  public RegistrationHandle replace(final CommandName name, final Provider<Command> cmd) {
+  public RegistrationHandle replace(CommandName name, Provider<Command> cmd) {
     final ConcurrentMap<String, CommandProvider> m = getMap();
     final CommandProvider commandProvider = new CommandProvider(cmd, null);
     m.put(name.value(), commandProvider);
@@ -84,7 +84,7 @@
   @SuppressWarnings("unchecked")
   private ConcurrentMap<String, CommandProvider> createMap() {
     ConcurrentMap<String, CommandProvider> m = Maps.newConcurrentMap();
-    for (final Binding<?> b : allCommands()) {
+    for (Binding<?> b : allCommands()) {
       final Annotation annotation = b.getKey().getAnnotation();
       if (annotation instanceof CommandName) {
         final CommandName n = (CommandName) annotation;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
index 0b1f3ae..8e4be78 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/GerritGSSAuthenticator.java
@@ -51,7 +51,7 @@
   }
 
   @Override
-  public boolean validateIdentity(final ServerSession session, final String identity) {
+  public boolean validateIdentity(ServerSession session, String identity) {
     final SshSession sd = session.getAttribute(SshSession.KEY);
     int at = identity.indexOf('@');
     String username;
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
index 20694b2..c0b6d5a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/HostKeyProvider.java
@@ -31,7 +31,7 @@
   private final SitePaths site;
 
   @Inject
-  HostKeyProvider(final SitePaths site) {
+  HostKeyProvider(SitePaths site) {
     this.site = site;
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/NoShell.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/NoShell.java
index eafdcd6..aec85d4 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/NoShell.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/NoShell.java
@@ -76,33 +76,33 @@
     }
 
     @Override
-    public void setInputStream(final InputStream in) {
+    public void setInputStream(InputStream in) {
       this.in = in;
     }
 
     @Override
-    public void setOutputStream(final OutputStream out) {
+    public void setOutputStream(OutputStream out) {
       this.out = out;
     }
 
     @Override
-    public void setErrorStream(final OutputStream err) {
+    public void setErrorStream(OutputStream err) {
       this.err = err;
     }
 
     @Override
-    public void setExitCallback(final ExitCallback callback) {
+    public void setExitCallback(ExitCallback callback) {
       this.exit = callback;
     }
 
     @Override
-    public void setSession(final ServerSession session) {
+    public void setSession(ServerSession session) {
       SshSession s = session.getAttribute(SshSession.KEY);
       this.context = sshScope.newContext(schemaFactory, s, "");
     }
 
     @Override
-    public void start(final Environment env) throws IOException {
+    public void start(Environment env) throws IOException {
       Context old = sshScope.set(context);
       String message;
       try {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/PluginCommandModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/PluginCommandModule.java
index fe8197d..b0116e4 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/PluginCommandModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/PluginCommandModule.java
@@ -46,7 +46,7 @@
     command(command, clazz);
   }
 
-  protected void alias(final String name, Class<? extends BaseCommand> clazz) {
+  protected void alias(String name, Class<? extends BaseCommand> clazz) {
     alias(command, name, clazz);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
index 5964bd4..31b01c9 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshDaemon.java
@@ -150,16 +150,16 @@
 
   @Inject
   SshDaemon(
-      final CommandFactory commandFactory,
-      final NoShell noShell,
-      final PublickeyAuthenticator userAuth,
-      final GerritGSSAuthenticator kerberosAuth,
-      final KeyPairProvider hostKeyProvider,
-      final IdGenerator idGenerator,
-      @GerritServerConfig final Config cfg,
-      final SshLog sshLog,
-      @SshListenAddresses final List<SocketAddress> listen,
-      @SshAdvertisedAddresses final List<String> advertised,
+      CommandFactory commandFactory,
+      NoShell noShell,
+      PublickeyAuthenticator userAuth,
+      GerritGSSAuthenticator kerberosAuth,
+      KeyPairProvider hostKeyProvider,
+      IdGenerator idGenerator,
+      @GerritServerConfig Config cfg,
+      SshLog sshLog,
+      @SshListenAddresses List<SocketAddress> listen,
+      @SshAdvertisedAddresses List<String> advertised,
       MetricMaker metricMaker) {
     setPort(IANA_SSH_PORT /* never used */);
 
@@ -262,7 +262,7 @@
     setSessionFactory(
         new SessionFactory(this) {
           @Override
-          protected ServerSessionImpl createSession(final IoSession io) throws Exception {
+          protected ServerSessionImpl createSession(IoSession io) throws Exception {
             connected.incrementAndGet();
             sessionsCreated.increment();
             if (io instanceof MinaSession) {
@@ -397,12 +397,12 @@
 
     final List<PublicKey> keys = myHostKeys();
     final List<HostKey> r = new ArrayList<>();
-    for (final PublicKey pub : keys) {
+    for (PublicKey pub : keys) {
       final Buffer buf = new ByteArrayBuffer();
       buf.putRawPublicKey(pub);
       final byte[] keyBin = buf.getCompactData();
 
-      for (final String addr : advertised) {
+      for (String addr : advertised) {
         try {
           r.add(new HostKey(addr, keyBin));
         } catch (JSchException e) {
@@ -428,7 +428,7 @@
   }
 
   private static void addPublicKey(
-      final Collection<PublicKey> out, final KeyPairProvider p, final String type) {
+      final Collection<PublicKey> out, KeyPairProvider p, String type) {
     final KeyPair pair = p.loadKey(type);
     if (pair != null && pair.getPublic() != null) {
       out.add(pair.getPublic());
@@ -528,7 +528,7 @@
   }
 
   @SuppressWarnings("unchecked")
-  private void initCiphers(final Config cfg) {
+  private void initCiphers(Config cfg) {
     final List<NamedFactory<Cipher>> a = BaseBuilder.setUpDefaultCiphers(true);
 
     for (Iterator<NamedFactory<Cipher>> i = a.iterator(); i.hasNext(); ) {
@@ -566,9 +566,9 @@
 
   @SafeVarargs
   private static <T> List<NamedFactory<T>> filter(
-      final Config cfg, final String key, final NamedFactory<T>... avail) {
+      final Config cfg, String key, NamedFactory<T>... avail) {
     final ArrayList<NamedFactory<T>> def = new ArrayList<>();
-    for (final NamedFactory<T> n : avail) {
+    for (NamedFactory<T> n : avail) {
       if (n == null) {
         break;
       }
@@ -581,7 +581,7 @@
     }
 
     boolean didClear = false;
-    for (final String setting : want) {
+    for (String setting : want) {
       String name = setting.trim();
       boolean add = true;
       if (name.startsWith("-")) {
@@ -622,8 +622,8 @@
   }
 
   @SafeVarargs
-  private static <T> NamedFactory<T> find(final String name, final NamedFactory<T>... avail) {
-    for (final NamedFactory<T> n : avail) {
+  private static <T> NamedFactory<T> find(String name, NamedFactory<T>... avail) {
+    for (NamedFactory<T> n : avail) {
       if (n != null && name.equals(n.getName())) {
         return n;
       }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheEntry.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
index 2cab00b..1a5e137 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheEntry.java
@@ -22,7 +22,7 @@
   private final AccountSshKey.Id id;
   private final PublicKey publicKey;
 
-  SshKeyCacheEntry(final AccountSshKey.Id i, final PublicKey k) {
+  SshKeyCacheEntry(AccountSshKey.Id i, PublicKey k) {
     id = i;
     publicKey = k;
   }
@@ -31,7 +31,7 @@
     return id.getParentKey();
   }
 
-  boolean match(final PublicKey inkey) {
+  boolean match(PublicKey inkey) {
     return publicKey.equals(inkey);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
index 837865e..6a68211 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshKeyCacheImpl.java
@@ -14,18 +14,17 @@
 
 package com.google.gerrit.sshd;
 
-import static com.google.gerrit.server.account.ExternalId.SCHEME_USERNAME;
+import static com.google.gerrit.server.account.externalids.ExternalId.SCHEME_USERNAME;
 
 import com.google.common.cache.CacheLoader;
 import com.google.common.cache.LoadingCache;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.account.ExternalId;
 import com.google.gerrit.server.account.VersionedAuthorizedKeys;
+import com.google.gerrit.server.account.externalids.ExternalId;
+import com.google.gerrit.server.account.externalids.ExternalIds;
 import com.google.gerrit.server.cache.CacheModule;
 import com.google.gerrit.server.ssh.SshKeyCache;
 import com.google.gerrit.server.ssh.SshKeyCreator;
-import com.google.gwtorm.server.SchemaFactory;
 import com.google.inject.Inject;
 import com.google.inject.Module;
 import com.google.inject.Singleton;
@@ -91,39 +90,33 @@
   }
 
   static class Loader extends CacheLoader<String, Iterable<SshKeyCacheEntry>> {
-    private final SchemaFactory<ReviewDb> schema;
+    private final ExternalIds externalIds;
     private final VersionedAuthorizedKeys.Accessor authorizedKeys;
 
     @Inject
-    Loader(SchemaFactory<ReviewDb> schema, VersionedAuthorizedKeys.Accessor authorizedKeys) {
-      this.schema = schema;
+    Loader(ExternalIds externalIds, VersionedAuthorizedKeys.Accessor authorizedKeys) {
+      this.externalIds = externalIds;
       this.authorizedKeys = authorizedKeys;
     }
 
     @Override
     public Iterable<SshKeyCacheEntry> load(String username) throws Exception {
-      try (ReviewDb db = schema.open()) {
-        ExternalId user =
-            ExternalId.from(
-                db.accountExternalIds()
-                    .get(
-                        ExternalId.Key.create(SCHEME_USERNAME, username).asAccountExternalIdKey()));
-        if (user == null) {
-          return NO_SUCH_USER;
-        }
-
-        List<SshKeyCacheEntry> kl = new ArrayList<>(4);
-        for (AccountSshKey k : authorizedKeys.getKeys(user.accountId())) {
-          if (k.isValid()) {
-            add(kl, k);
-          }
-        }
-
-        if (kl.isEmpty()) {
-          return NO_KEYS;
-        }
-        return Collections.unmodifiableList(kl);
+      ExternalId user = externalIds.get(ExternalId.Key.create(SCHEME_USERNAME, username));
+      if (user == null) {
+        return NO_SUCH_USER;
       }
+
+      List<SshKeyCacheEntry> kl = new ArrayList<>(4);
+      for (AccountSshKey k : authorizedKeys.getKeys(user.accountId())) {
+        if (k.isValid()) {
+          add(kl, k);
+        }
+      }
+
+      if (kl.isEmpty()) {
+        return NO_KEYS;
+      }
+      return Collections.unmodifiableList(kl);
     }
 
     private void add(List<SshKeyCacheEntry> kl, AccountSshKey k) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
index dfd56f1..12064c8 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLog.java
@@ -89,7 +89,7 @@
     audit(context.get(), "0", "LOGIN");
   }
 
-  void onAuthFail(final SshSession sd) {
+  void onAuthFail(SshSession sd) {
     final LoggingEvent event =
         new LoggingEvent( //
             Logger.class.getName(), // fqnOfCategoryClass
@@ -210,7 +210,7 @@
     audit(context.get(), "0", "LOGOUT");
   }
 
-  private LoggingEvent log(final String msg) {
+  private LoggingEvent log(String msg) {
     final SshSession sd = session.get();
     final CurrentUser user = sd.getUser();
 
@@ -248,7 +248,7 @@
     return event;
   }
 
-  private static String id(final int id) {
+  private static String id(int id) {
     return IdGenerator.format(id);
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
index 627bf71..442ec52 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshLogLayout.java
@@ -69,7 +69,7 @@
     return buf.toString();
   }
 
-  private void formatDate(final long now, final StringBuffer sbuf) {
+  private void formatDate(long now, StringBuffer sbuf) {
     final int millis = (int) (now % 1000);
     final long rounded = now - millis;
     if (rounded != lastTimeMillis) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
index da62782..4134496 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshModule.java
@@ -18,14 +18,15 @@
 import static com.google.inject.Scopes.SINGLETON;
 
 import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.PeerDaemonUser;
 import com.google.gerrit.server.RemotePeer;
 import com.google.gerrit.server.config.GerritRequestModule;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.git.AsyncReceiveCommits;
 import com.google.gerrit.server.git.QueueProvider;
-import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
 import com.google.gerrit.server.plugins.ModuleGenerator;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
@@ -38,6 +39,7 @@
 import java.net.SocketAddress;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.apache.sshd.server.CommandFactory;
 import org.apache.sshd.server.auth.gss.GSSAuthenticator;
 import org.apache.sshd.server.auth.pubkey.PublickeyAuthenticator;
@@ -76,7 +78,7 @@
         .toInstance(new DispatchCommandProvider(Commands.CMD_ROOT));
     bind(CommandFactoryProvider.class);
     bind(CommandFactory.class).toProvider(CommandFactoryProvider.class);
-    bind(WorkQueue.Executor.class)
+    bind(ScheduledThreadPoolExecutor.class)
         .annotatedWith(StreamCommandExecutor.class)
         .toProvider(StreamCommandExecutorProvider.class)
         .in(SINGLETON);
@@ -95,7 +97,9 @@
         .annotatedWith(UniqueAnnotations.create())
         .to(SshPluginStarterCallback.class);
 
+    DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
     DynamicItem.itemOf(binder(), SshCreateCommandInterceptor.class);
+
     listener().toInstance(registerInParentInjectors());
     listener().to(SshLog.class);
     listener().to(SshDaemon.class);
@@ -124,7 +128,7 @@
         .toProvider(SshRemotePeerProvider.class)
         .in(SshScope.REQUEST);
 
-    bind(WorkQueue.Executor.class)
+    bind(ScheduledThreadPoolExecutor.class)
         .annotatedWith(CommandExecutor.class)
         .toProvider(CommandExecutorProvider.class)
         .in(SshScope.REQUEST);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
index 5f7af9a..1a54f1d 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshPluginStarterCallback.java
@@ -14,6 +14,8 @@
 
 package com.google.gerrit.sshd;
 
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.server.DynamicOptions;
 import com.google.gerrit.server.plugins.Plugin;
 import com.google.gerrit.server.plugins.ReloadPluginListener;
 import com.google.gerrit.server.plugins.StartPluginListener;
@@ -30,10 +32,14 @@
   private static final Logger log = LoggerFactory.getLogger(SshPluginStarterCallback.class);
 
   private final DispatchCommandProvider root;
+  private final DynamicMap<DynamicOptions.DynamicBean> dynamicBeans;
 
   @Inject
-  SshPluginStarterCallback(@CommandName(Commands.ROOT) DispatchCommandProvider root) {
+  SshPluginStarterCallback(
+      @CommandName(Commands.ROOT) DispatchCommandProvider root,
+      DynamicMap<DynamicOptions.DynamicBean> dynamicBeans) {
     this.root = root;
+    this.dynamicBeans = dynamicBeans;
   }
 
   @Override
@@ -58,9 +64,18 @@
       try {
         return plugin.getSshInjector().getProvider(key);
       } catch (RuntimeException err) {
-        log.warn("Plugin {} did not define its top-level command", plugin.getName(), err);
+        if (!providesDynamicOptions(plugin)) {
+          log.warn(
+              "Plugin {} did not define its top-level command nor any DynamicOptions",
+              plugin.getName(),
+              err);
+        }
       }
     }
     return null;
   }
+
+  private boolean providesDynamicOptions(Plugin plugin) {
+    return dynamicBeans.plugins().contains(plugin.getName());
+  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshRemotePeerProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshRemotePeerProvider.java
index 44554ca..5e2626e 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshRemotePeerProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshRemotePeerProvider.java
@@ -24,7 +24,7 @@
   private final Provider<SshSession> session;
 
   @Inject
-  SshRemotePeerProvider(final Provider<SshSession> s) {
+  SshRemotePeerProvider(Provider<SshSession> s) {
     session = s;
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java
index 66e5101..2659831 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshScope.java
@@ -51,7 +51,7 @@
     volatile long started;
     volatile long finished;
 
-    private Context(SchemaFactory<ReviewDb> sf, final SshSession s, final String c, final long at) {
+    private Context(SchemaFactory<ReviewDb> sf, SshSession s, String c, long at) {
       schemaFactory = sf;
       session = s;
       commandLine = c;
@@ -179,7 +179,7 @@
   public 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-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
index f08fb43..1a60a20 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshSession.java
@@ -35,7 +35,7 @@
   private volatile String authError;
   private volatile String peerAgent;
 
-  SshSession(final int sessionId, SocketAddress peer) {
+  SshSession(int sessionId, SocketAddress peer) {
     this.sessionId = sessionId;
     this.remoteAddress = peer;
     this.remoteAsString = format(remoteAddress);
@@ -109,7 +109,7 @@
     return authError != null;
   }
 
-  private static String format(final SocketAddress remote) {
+  private static String format(SocketAddress remote) {
     if (remote instanceof InetSocketAddress) {
       final InetSocketAddress sa = (InetSocketAddress) remote;
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java
index 33d253a..ab0ffcf 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SshUtil.java
@@ -48,7 +48,7 @@
    * @throws NoSuchAlgorithmException the JVM is missing the key algorithm.
    * @throws NoSuchProviderException the JVM is missing the provider.
    */
-  public static PublicKey parse(final AccountSshKey key)
+  public static PublicKey parse(AccountSshKey key)
       throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
     try {
       final String s = key.getEncodedKey();
@@ -69,7 +69,7 @@
    * @return {@code keyStr} if conversion failed; otherwise the converted key, in OpenSSH key
    *     format.
    */
-  public static String toOpenSshPublicKey(final String keyStr) {
+  public static String toOpenSshPublicKey(String keyStr) {
     try {
       final StringBuilder strBuf = new StringBuilder();
       final BufferedReader br = new BufferedReader(new StringReader(keyStr));
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutor.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutor.java
index 794ff76..9a8e029 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutor.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutor.java
@@ -16,11 +16,11 @@
 
 import static java.lang.annotation.RetentionPolicy.RUNTIME;
 
-import com.google.gerrit.server.git.WorkQueue.Executor;
 import com.google.inject.BindingAnnotation;
 import java.lang.annotation.Retention;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 
-/** Marker on {@link Executor} used by delayed event streaming. */
+/** Marker on {@link ScheduledThreadPoolExecutor} used by delayed event streaming. */
 @Retention(RUNTIME)
 @BindingAnnotation
 public @interface StreamCommandExecutor {}
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java
index 96f1750..235da5d 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/StreamCommandExecutorProvider.java
@@ -18,36 +18,22 @@
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
-import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.eclipse.jgit.lib.Config;
 
-class StreamCommandExecutorProvider implements Provider<WorkQueue.Executor> {
+class StreamCommandExecutorProvider implements Provider<ScheduledThreadPoolExecutor> {
   private final int poolSize;
   private final WorkQueue queues;
 
   @Inject
-  StreamCommandExecutorProvider(@GerritServerConfig final Config config, final WorkQueue wq) {
+  StreamCommandExecutorProvider(@GerritServerConfig Config config, WorkQueue wq) {
     final int cores = Runtime.getRuntime().availableProcessors();
     poolSize = config.getInt("sshd", "streamThreads", cores + 1);
     queues = wq;
   }
 
   @Override
-  public WorkQueue.Executor get() {
-    final WorkQueue.Executor executor;
-
-    executor = queues.createQueue(poolSize, "SSH-Stream-Worker", true);
-
-    final ThreadFactory parent = executor.getThreadFactory();
-    executor.setThreadFactory(
-        new ThreadFactory() {
-          @Override
-          public Thread newThread(final Runnable task) {
-            final Thread t = parent.newThread(task);
-            t.setPriority(Thread.MIN_PRIORITY);
-            return t;
-          }
-        });
-    return executor;
+  public ScheduledThreadPoolExecutor get() {
+    return queues.createQueue(poolSize, "SSH-Stream-Worker", Thread.MIN_PRIORITY, true);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
index 53a98eb..54371c1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/SuExec.java
@@ -18,11 +18,15 @@
 
 import com.google.common.base.Throwables;
 import com.google.common.util.concurrent.Atomics;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PeerDaemonUser;
 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.gerrit.sshd.SshScope.Context;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -45,6 +49,7 @@
 public final class SuExec extends BaseCommand {
   private final SshScope sshScope;
   private final DispatchCommandProvider dispatcher;
+  private final PermissionBackend permissionBackend;
 
   private boolean enableRunAs;
   private CurrentUser caller;
@@ -67,6 +72,7 @@
   SuExec(
       final SshScope sshScope,
       @CommandName(Commands.ROOT) final DispatchCommandProvider dispatcher,
+      PermissionBackend permissionBackend,
       final CurrentUser caller,
       final SshSession session,
       final IdentifiedUser.GenericFactory userFactory,
@@ -74,6 +80,7 @@
       AuthConfig config) {
     this.sshScope = sshScope;
     this.dispatcher = dispatcher;
+    this.permissionBackend = permissionBackend;
     this.caller = caller;
     this.session = session;
     this.userFactory = userFactory;
@@ -115,8 +122,14 @@
       // OK.
     } else if (!enableRunAs) {
       throw die("suexec disabled by auth.enableRunAs = false");
-    } else if (!caller.getCapabilities().canRunAs()) {
-      throw die("suexec not permitted");
+    } else {
+      try {
+        permissionBackend.user(caller).check(GlobalPermission.RUN_AS);
+      } catch (AuthException e) {
+        throw die("suexec not permitted");
+      } catch (PermissionBackendException e) {
+        throw die("suexec not available: " + e);
+      }
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
index 42c7578..ef1cd81 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminQueryShell.java
@@ -15,9 +15,12 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.gerrit.common.data.GlobalCapability;
-import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.IdentifiedUser;
+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.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -29,8 +32,8 @@
 @RequiresCapability(GlobalCapability.ACCESS_DATABASE)
 @CommandMetaData(name = "gsql", description = "Administrative interface to active database")
 final class AdminQueryShell extends SshCommand {
+  @Inject private PermissionBackend permissionBackend;
   @Inject private QueryShell.Factory factory;
-
   @Inject private IdentifiedUser currentUser;
 
   @Option(name = "--format", usage = "Set output format")
@@ -42,9 +45,11 @@
   @Override
   protected void run() throws Failure {
     try {
-      checkPermission();
-    } catch (PermissionDeniedException err) {
+      permissionBackend.user(currentUser).check(GlobalPermission.ACCESS_DATABASE);
+    } catch (AuthException err) {
       throw die(err.getMessage());
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "unavailable", e);
     }
 
     QueryShell shell = factory.create(in, out);
@@ -55,22 +60,4 @@
       shell.run();
     }
   }
-
-  /**
-   * Assert that the current user is permitted to perform raw queries.
-   *
-   * <p>As the @RequireCapability guards at various entry points of internal commands implicitly add
-   * administrators (which we want to avoid), we also check permissions within QueryShell and grant
-   * access only to those who can access the database, regardless of whether they are administrators
-   * or not.
-   *
-   * @throws PermissionDeniedException
-   */
-  private void checkPermission() throws PermissionDeniedException {
-    if (!currentUser.getCapabilities().canAccessDatabase()) {
-      throw new PermissionDeniedException(
-          String.format(
-              "%s does not have \"Access Database\" capability.", currentUser.getUserName()));
-    }
-  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
index b7d8507..22eafd6 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/AdminSetParent.java
@@ -21,6 +21,7 @@
 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.ListChildProjects;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
@@ -120,14 +121,18 @@
     }
 
     final List<Project.NameKey> childProjects = new ArrayList<>();
-    for (final ProjectControl pc : children) {
+    for (ProjectControl pc : children) {
       childProjects.add(pc.getProject().getNameKey());
     }
     if (oldParent != null) {
-      childProjects.addAll(getChildrenForReparenting(oldParent));
+      try {
+        childProjects.addAll(getChildrenForReparenting(oldParent));
+      } catch (PermissionBackendException e) {
+        throw new Failure(1, "permissions unavailable", e);
+      }
     }
 
-    for (final Project.NameKey nameKey : childProjects) {
+    for (Project.NameKey nameKey : childProjects) {
       final String name = nameKey.get();
 
       if (allProjectsName.equals(nameKey)) {
@@ -180,17 +185,18 @@
    * list of child projects does not contain projects that were specified to be excluded from
    * reparenting.
    */
-  private List<Project.NameKey> getChildrenForReparenting(final ProjectControl parent) {
+  private List<Project.NameKey> getChildrenForReparenting(ProjectControl parent)
+      throws PermissionBackendException {
     final List<Project.NameKey> childProjects = new ArrayList<>();
     final List<Project.NameKey> excluded = new ArrayList<>(excludedChildren.size());
-    for (final ProjectControl excludedChild : excludedChildren) {
+    for (ProjectControl excludedChild : excludedChildren) {
       excluded.add(excludedChild.getProject().getNameKey());
     }
     final List<Project.NameKey> automaticallyExcluded = new ArrayList<>(excludedChildren.size());
     if (newParentKey != null) {
       automaticallyExcluded.addAll(getAllParents(newParentKey));
     }
-    for (final ProjectInfo child : listChildProjects.apply(new ProjectResource(parent))) {
+    for (ProjectInfo child : listChildProjects.apply(new ProjectResource(parent))) {
       final Project.NameKey childName = new Project.NameKey(child.name);
       if (!excluded.contains(childName)) {
         if (!automaticallyExcluded.contains(childName)) {
@@ -215,6 +221,6 @@
     if (ps == null) {
       return Collections.emptySet();
     }
-    return ps.parents().transform(s -> s.getProject().getNameKey()).toSet();
+    return ps.parents().transform(s -> s.getNameKey()).toSet();
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java
index 92098cc..633eaa0 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ApproveOption.java
@@ -34,7 +34,7 @@
 
   private Short value;
 
-  ApproveOption(final String name, final String usage, final LabelType type) {
+  ApproveOption(String name, String usage, LabelType type) {
     this.name = name;
     this.usage = usage;
     this.type = type;
@@ -100,7 +100,7 @@
   }
 
   @Override
-  public void addValue(final Short val) {
+  public void addValue(Short val) {
     this.value = val;
   }
 
@@ -122,13 +122,13 @@
     private final ApproveOption cmdOption;
 
     // CS IGNORE RedundantModifier FOR NEXT 1 LINES. REASON: needed by org.kohsuke.args4j.Option
-    public Handler(final CmdLineParser parser, final OptionDef option, final Setter<Short> setter) {
+    public Handler(CmdLineParser parser, OptionDef option, Setter<Short> setter) {
       super(parser, option, setter);
       this.cmdOption = (ApproveOption) setter;
     }
 
     @Override
-    protected Short parse(final String token) throws NumberFormatException, CmdLineException {
+    protected Short parse(String token) throws NumberFormatException, CmdLineException {
       String argument = token;
       if (argument.startsWith("+")) {
         argument = argument.substring(1);
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
index 51c65c6..3699073 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/BanCommitCommand.java
@@ -18,7 +18,6 @@
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.Lists;
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.BanCommit;
 import com.google.gerrit.server.project.BanCommit.BanResultInfo;
 import com.google.gerrit.server.project.ProjectControl;
@@ -26,7 +25,6 @@
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import org.eclipse.jgit.lib.ObjectId;
@@ -73,7 +71,7 @@
       printCommits(r.newlyBanned, "The following commits were banned");
       printCommits(r.alreadyBanned, "The following commits were already banned");
       printCommits(r.ignored, "The following ids do not represent commits and were ignored");
-    } catch (RestApiException | IOException e) {
+    } catch (Exception e) {
       throw die(e);
     }
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
index 1446f84..1855f41 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateGroupCommand.java
@@ -25,8 +25,8 @@
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.group.AddIncludedGroups;
 import com.google.gerrit.server.group.AddMembers;
+import com.google.gerrit.server.group.AddSubgroups;
 import com.google.gerrit.server.group.CreateGroup;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.GroupsCollection;
@@ -37,6 +37,7 @@
 import java.io.IOException;
 import java.util.HashSet;
 import java.util.Set;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
 
@@ -72,7 +73,7 @@
       aliases = {"-m"},
       metaVar = "USERNAME",
       usage = "initial set of users to become members of the group")
-  void addMember(final Account.Id id) {
+  void addMember(Account.Id id) {
     initialMembers.add(id);
   }
 
@@ -86,7 +87,7 @@
       aliases = "-g",
       metaVar = "GROUP",
       usage = "initial set of groups to be included in the group")
-  void addGroup(final AccountGroup.UUID id) {
+  void addGroup(AccountGroup.UUID id) {
     initialGroups.add(id);
   }
 
@@ -96,10 +97,10 @@
 
   @Inject private AddMembers addMembers;
 
-  @Inject private AddIncludedGroups addIncludedGroups;
+  @Inject private AddSubgroups addSubgroups;
 
   @Override
-  protected void run() throws Failure, OrmException, IOException {
+  protected void run() throws Failure, OrmException, IOException, ConfigInvalidException {
     try {
       GroupResource rsrc = createGroup();
 
@@ -108,14 +109,15 @@
       }
 
       if (!initialGroups.isEmpty()) {
-        addIncludedGroups(rsrc);
+        addSubgroups(rsrc);
       }
     } catch (RestApiException e) {
       throw die(e);
     }
   }
 
-  private GroupResource createGroup() throws RestApiException, OrmException, IOException {
+  private GroupResource createGroup()
+      throws RestApiException, OrmException, IOException, ConfigInvalidException {
     GroupInput input = new GroupInput();
     input.description = groupDescription;
     input.visibleToAll = visibleToAll;
@@ -128,17 +130,18 @@
     return groups.parse(TopLevelResource.INSTANCE, IdString.fromUrl(group.id));
   }
 
-  private void addMembers(GroupResource rsrc) throws RestApiException, OrmException, IOException {
+  private void addMembers(GroupResource rsrc)
+      throws RestApiException, OrmException, IOException, ConfigInvalidException {
     AddMembers.Input input =
         AddMembers.Input.fromMembers(
             initialMembers.stream().map(Object::toString).collect(toList()));
     addMembers.apply(rsrc, input);
   }
 
-  private void addIncludedGroups(GroupResource rsrc) throws RestApiException, OrmException {
-    AddIncludedGroups.Input input =
-        AddIncludedGroups.Input.fromGroups(
+  private void addSubgroups(GroupResource rsrc) throws RestApiException, OrmException, IOException {
+    AddSubgroups.Input input =
+        AddSubgroups.Input.fromGroups(
             initialGroups.stream().map(AccountGroup.UUID::get).collect(toList()));
-    addIncludedGroups.apply(rsrc, input);
+    addSubgroups.apply(rsrc, input);
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
index cfefb7b..8ccf864 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/CreateProjectCommand.java
@@ -27,7 +27,7 @@
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.project.SuggestParentCandidates;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -162,7 +162,7 @@
   @Inject private SuggestParentCandidates suggestParentCandidates;
 
   @Override
-  protected void run() throws UnloggedFailure {
+  protected void run() throws Failure {
     try {
       if (!suggestParent) {
         if (projectName == null) {
@@ -194,14 +194,14 @@
 
         gApi.projects().create(input);
       } else {
-        List<Project.NameKey> parentCandidates = suggestParentCandidates.getNameKeys();
-
-        for (Project.NameKey parent : parentCandidates) {
-          stdout.print(parent + "\n");
+        for (Project.NameKey parent : suggestParentCandidates.getNameKeys()) {
+          stdout.print(parent.get() + '\n');
         }
       }
-    } catch (RestApiException | NoSuchProjectException err) {
+    } catch (RestApiException err) {
       throw die(err);
+    } catch (PermissionBackendException err) {
+      throw new Failure(1, "permissions unavailable", err);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
index f2a466d..d9c892d 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/FlushCaches.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.server.config.ListCaches;
 import com.google.gerrit.server.config.ListCaches.OutputFormat;
 import com.google.gerrit.server.config.PostCaches;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -80,6 +81,8 @@
       }
     } catch (RestApiException e) {
       throw die(e.getMessage());
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "unavailable", e);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
index d932114..0804d08 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexActivateCommand.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.index.AbstractVersionManager;
 import com.google.gerrit.server.index.ReindexerAlreadyRunningException;
+import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -30,7 +30,7 @@
   @Argument(index = 0, required = true, metaVar = "INDEX", usage = "index name to activate")
   private String name;
 
-  @Inject private AbstractVersionManager versionManager;
+  @Inject private VersionManager versionManager;
 
   @Override
   protected void run() throws UnloggedFailure {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
index ce01211..b624ee1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexChangesCommand.java
@@ -14,16 +14,15 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.Index;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.ChangeArgumentParser;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.LinkedHashMap;
 import java.util.Map;
 import org.kohsuke.args4j.Argument;
@@ -43,7 +42,7 @@
   void addChange(String token) {
     try {
       changeArgumentParser.addChange(token, changes, null, false);
-    } catch (UnloggedFailure | OrmException e) {
+    } catch (UnloggedFailure | OrmException | PermissionBackendException e) {
       writeError("warning", e.getMessage());
     }
   }
@@ -56,7 +55,7 @@
     for (ChangeResource rsrc : changes.values()) {
       try {
         index.apply(rsrc, new Index.Input());
-      } catch (IOException | RestApiException | OrmException e) {
+      } catch (Exception e) {
         ok = false;
         writeError(
             "error", String.format("failed to index change %s: %s", rsrc.getId(), e.getMessage()));
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
index 5d1f955..599c9dc 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexCommandsModule.java
@@ -14,7 +14,7 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.gerrit.server.index.AbstractVersionManager;
+import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.sshd.CommandModule;
 import com.google.gerrit.sshd.CommandName;
 import com.google.gerrit.sshd.Commands;
@@ -35,7 +35,7 @@
     CommandName gerrit = Commands.named("gerrit");
     CommandName index = Commands.named(gerrit, "index");
     command(index).toProvider(new DispatchCommandProvider(index));
-    if (injector.getExistingBinding(Key.get(AbstractVersionManager.class)) != null) {
+    if (injector.getExistingBinding(Key.get(VersionManager.class)) != null) {
       command(index, IndexActivateCommand.class);
       command(index, IndexStartCommand.class);
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
index 1f75c9a..f3d349c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/IndexStartCommand.java
@@ -16,8 +16,8 @@
 
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
-import com.google.gerrit.server.index.AbstractVersionManager;
 import com.google.gerrit.server.index.ReindexerAlreadyRunningException;
+import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -34,7 +34,7 @@
   @Argument(index = 0, required = true, metaVar = "INDEX", usage = "index name to start")
   private String name;
 
-  @Inject private AbstractVersionManager versionManager;
+  @Inject private VersionManager versionManager;
 
   @Override
   protected void run() throws UnloggedFailure {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
index 4ebc568..3465a9c 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/KillCommand.java
@@ -25,6 +25,7 @@
 import com.google.gerrit.server.config.DeleteTask;
 import com.google.gerrit.server.config.TaskResource;
 import com.google.gerrit.server.config.TasksCollection;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -50,7 +51,7 @@
       try {
         TaskResource taskRsrc = tasksCollection.parse(cfgRsrc, IdString.fromDecoded(id));
         deleteTask.apply(taskRsrc, null);
-      } catch (AuthException | ResourceNotFoundException e) {
+      } catch (AuthException | ResourceNotFoundException | PermissionBackendException e) {
         stderr.print("kill: " + id + ": No such task\n");
       }
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
index 9bb4bd9..59bfa06 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListGroupsCommand.java
@@ -19,23 +19,17 @@
 import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.gerrit.extensions.common.GroupInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.AccountGroup;
-import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.account.GetGroups;
-import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupControl;
-import com.google.gerrit.server.group.GroupJson;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.ListGroups;
 import com.google.gerrit.server.ioutil.ColumnFormatter;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
-import com.google.gwtorm.server.OrmException;
+import com.google.gerrit.util.cli.Options;
 import com.google.inject.Inject;
-import com.google.inject.Provider;
-import java.io.PrintWriter;
+import java.util.Optional;
 import org.kohsuke.args4j.Option;
 
 @CommandMetaData(
@@ -43,72 +37,43 @@
     description = "List groups visible to the caller",
     runsAt = MASTER_OR_SLAVE)
 public class ListGroupsCommand extends SshCommand {
-  @Inject private MyListGroups impl;
+  @Inject private GroupCache groupCache;
+
+  @Inject @Options public ListGroups listGroups;
+
+  @Option(
+      name = "--verbose",
+      aliases = {"-v"},
+      usage =
+          "verbose output format with tab-separated columns for the "
+              + "group name, UUID, description, owner group name, "
+              + "owner group UUID, and whether the group is visible to all")
+  private boolean verboseOutput;
 
   @Override
   public void run() throws Exception {
-    if (impl.getUser() != null && !impl.getProjects().isEmpty()) {
+    if (listGroups.getUser() != null && !listGroups.getProjects().isEmpty()) {
       throw die("--user and --project options are not compatible.");
     }
-    impl.display(stdout);
-  }
 
-  @Override
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(impl);
-  }
+    ColumnFormatter formatter = new ColumnFormatter(stdout, '\t');
+    for (GroupInfo info : listGroups.get()) {
+      formatter.addColumn(MoreObjects.firstNonNull(info.name, "n/a"));
+      if (verboseOutput) {
+        Optional<InternalGroup> group =
+            info.ownerId != null
+                ? groupCache.get(new AccountGroup.UUID(Url.decode(info.ownerId)))
+                : Optional.empty();
 
-  private static class MyListGroups extends ListGroups {
-    @Option(
-        name = "--verbose",
-        aliases = {"-v"},
-        usage =
-            "verbose output format with tab-separated columns for the "
-                + "group name, UUID, description, owner group name, "
-                + "owner group UUID, and whether the group is visible to all")
-    private boolean verboseOutput;
-
-    @Inject
-    MyListGroups(
-        final GroupCache groupCache,
-        final GroupControl.Factory groupControlFactory,
-        final GroupControl.GenericFactory genericGroupControlFactory,
-        final Provider<IdentifiedUser> identifiedUser,
-        final IdentifiedUser.GenericFactory userFactory,
-        final GetGroups accountGetGroups,
-        final GroupJson json,
-        GroupBackend groupBackend) {
-      super(
-          groupCache,
-          groupControlFactory,
-          genericGroupControlFactory,
-          identifiedUser,
-          userFactory,
-          accountGetGroups,
-          json,
-          groupBackend);
-    }
-
-    void display(final PrintWriter out) throws OrmException, BadRequestException {
-      final ColumnFormatter formatter = new ColumnFormatter(out, '\t');
-      for (final GroupInfo info : get()) {
-        formatter.addColumn(MoreObjects.firstNonNull(info.name, "n/a"));
-        if (verboseOutput) {
-          AccountGroup o =
-              info.ownerId != null
-                  ? groupCache.get(new AccountGroup.UUID(Url.decode(info.ownerId)))
-                  : null;
-
-          formatter.addColumn(Url.decode(info.id));
-          formatter.addColumn(Strings.nullToEmpty(info.description));
-          formatter.addColumn(o != null ? o.getName() : "n/a");
-          formatter.addColumn(o != null ? o.getGroupUUID().get() : "");
-          formatter.addColumn(
-              Boolean.toString(MoreObjects.firstNonNull(info.options.visibleToAll, Boolean.FALSE)));
-        }
-        formatter.nextLine();
+        formatter.addColumn(Url.decode(info.id));
+        formatter.addColumn(Strings.nullToEmpty(info.description));
+        formatter.addColumn(group.map(InternalGroup::getName).orElse("n/a"));
+        formatter.addColumn(group.map(g -> g.getGroupUUID().get()).orElse(""));
+        formatter.addColumn(
+            Boolean.toString(MoreObjects.firstNonNull(info.options.visibleToAll, Boolean.FALSE)));
       }
-      formatter.finish();
+      formatter.nextLine();
     }
+    formatter.finish();
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
index f3f9577..568c431b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListMembersCommand.java
@@ -22,7 +22,8 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.account.GroupDetailFactory.Factory;
+import com.google.gerrit.server.account.GroupControl;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.server.group.ListMembers;
 import com.google.gerrit.server.ioutil.ColumnFormatter;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -31,6 +32,7 @@
 import com.google.inject.Inject;
 import java.io.PrintWriter;
 import java.util.List;
+import java.util.Optional;
 import org.kohsuke.args4j.Argument;
 
 /** Implements a command that allows the user to see the members of a group. */
@@ -60,23 +62,23 @@
     @Inject
     protected ListMembersCommandImpl(
         GroupCache groupCache,
-        Factory groupDetailFactory,
+        GroupControl.Factory groupControlFactory,
         AccountLoader.Factory accountLoaderFactory) {
-      super(groupCache, groupDetailFactory, accountLoaderFactory);
+      super(groupCache, groupControlFactory, accountLoaderFactory);
       this.groupCache = groupCache;
     }
 
     void display(PrintWriter writer) throws OrmException {
-      AccountGroup group = groupCache.get(new AccountGroup.NameKey(name));
+      Optional<InternalGroup> group = groupCache.get(new AccountGroup.NameKey(name));
       String errorText = "Group not found or not visible\n";
 
-      if (group == null) {
+      if (!group.isPresent()) {
         writer.write(errorText);
         writer.flush();
         return;
       }
 
-      List<AccountInfo> members = apply(group.getGroupUUID());
+      List<AccountInfo> members = apply(group.get().getGroupUUID());
       ColumnFormatter formatter = new ColumnFormatter(writer, '\t');
       formatter.addColumn("id");
       formatter.addColumn("username");
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
index 1face7501..db0929e 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ListProjectsCommand.java
@@ -19,6 +19,7 @@
 import com.google.gerrit.server.project.ListProjects;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
+import com.google.gerrit.util.cli.Options;
 import com.google.inject.Inject;
 import java.util.List;
 
@@ -26,8 +27,8 @@
     name = "ls-projects",
     description = "List projects visible to the caller",
     runsAt = MASTER_OR_SLAVE)
-final class ListProjectsCommand extends SshCommand {
-  @Inject private ListProjects impl;
+public class ListProjectsCommand extends SshCommand {
+  @Inject @Options public ListProjects impl;
 
   @Override
   public void run() throws Exception {
@@ -42,9 +43,4 @@
     }
     impl.display(out);
   }
-
-  @Override
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(impl);
-  }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
index f5bb682..8927850 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/LsUserRefs.java
@@ -17,26 +17,24 @@
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 import static org.eclipse.jgit.lib.RefDatabase.ALL;
 
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.TagCache;
 import com.google.gerrit.server.git.VisibleRefFilter;
-import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.util.ManualRequestContext;
+import com.google.gerrit.server.util.OneOffRequestContext;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.Map;
+import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
@@ -49,16 +47,9 @@
     runsAt = MASTER_OR_SLAVE)
 public class LsUserRefs extends SshCommand {
   @Inject private AccountResolver accountResolver;
-
-  @Inject private IdentifiedUser.GenericFactory userFactory;
-
-  @Inject private ReviewDb db;
-
-  @Inject private TagCache tagCache;
-
-  @Inject private ChangeNotes.Factory changeNotesFactory;
-
-  @Inject @Nullable private SearchingChangeCacheImpl changeCache;
+  @Inject private OneOffRequestContext requestContext;
+  @Inject private VisibleRefFilter.Factory refFilterFactory;
+  @Inject private GitRepositoryManager repoManager;
 
   @Option(
       name = "--project",
@@ -79,46 +70,41 @@
   @Option(name = "--only-refs-heads", usage = "list only refs under refs/heads")
   private boolean onlyRefsHeads;
 
-  @Inject private GitRepositoryManager repoManager;
-
   @Override
   protected void run() throws Failure {
     Account userAccount;
     try {
-      userAccount = accountResolver.find(db, userName);
-    } catch (OrmException e) {
+      userAccount = accountResolver.find(userName);
+    } catch (OrmException | IOException | ConfigInvalidException e) {
       throw die(e);
     }
-
     if (userAccount == null) {
       stdout.print("No single user could be found when searching for: " + userName + '\n');
       stdout.flush();
       return;
     }
 
-    IdentifiedUser user = userFactory.create(userAccount.getId());
-    ProjectControl userProjectControl = projectControl.forUser(user);
-    try (Repository repo =
-        repoManager.openRepository(userProjectControl.getProject().getNameKey())) {
+    Project.NameKey projectName = projectControl.getProject().getNameKey();
+    try (Repository repo = repoManager.openRepository(projectName);
+        ManualRequestContext ctx = requestContext.openAs(userAccount.getId())) {
       try {
         Map<String, Ref> refsMap =
-            new VisibleRefFilter(
-                    tagCache, changeNotesFactory, changeCache, repo, userProjectControl, db, true)
+            refFilterFactory
+                .create(projectControl.getProjectState(), repo)
                 .filter(repo.getRefDatabase().getRefs(ALL), false);
 
-        for (final String ref : refsMap.keySet()) {
+        for (String ref : refsMap.keySet()) {
           if (!onlyRefsHeads || ref.startsWith(RefNames.REFS_HEADS)) {
             stdout.println(ref);
           }
         }
       } catch (IOException e) {
-        throw new Failure(
-            1, "fatal: Error reading refs: '" + projectControl.getProject().getNameKey(), e);
+        throw new Failure(1, "fatal: Error reading refs: '" + projectName, e);
       }
     } catch (RepositoryNotFoundException e) {
-      throw die("'" + projectControl.getProject().getNameKey() + "': not a git archive");
-    } catch (IOException e) {
-      throw die("Error opening: '" + projectControl.getProject().getNameKey());
+      throw die("'" + projectName + "': not a git archive");
+    } catch (IOException | OrmException e) {
+      throw die("Error opening: '" + projectName);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java
index 9b02f38..c3613b1 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PatchSetParser.java
@@ -21,10 +21,8 @@
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeFinder;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.notedb.ChangeNotes;
-import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -44,7 +42,6 @@
   private final ChangeNotes.Factory notesFactory;
   private final PatchSetUtil psUtil;
   private final ChangeFinder changeFinder;
-  private final Provider<CurrentUser> self;
 
   @Inject
   PatchSetParser(
@@ -52,14 +49,12 @@
       Provider<InternalChangeQuery> queryProvider,
       ChangeNotes.Factory notesFactory,
       PatchSetUtil psUtil,
-      ChangeFinder changeFinder,
-      Provider<CurrentUser> self) {
+      ChangeFinder changeFinder) {
     this.db = db;
     this.queryProvider = queryProvider;
     this.notesFactory = notesFactory;
     this.psUtil = psUtil;
     this.changeFinder = changeFinder;
-    this.self = self;
   }
 
   public PatchSet parsePatchSet(String token, ProjectControl projectControl, String branch)
@@ -141,8 +136,8 @@
       return notesFactory.create(db.get(), projectControl.getProject().getNameKey(), changeId);
     }
     try {
-      ChangeControl ctl = changeFinder.findOne(changeId, self.get());
-      return notesFactory.create(db.get(), ctl.getProject().getNameKey(), changeId);
+      ChangeNotes notes = changeFinder.findOne(changeId);
+      return notesFactory.create(db.get(), notes.getProjectName(), changeId);
     } catch (NoSuchChangeException e) {
       throw error("\"" + changeId + "\" no such change");
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
index 78c9526..ceb5f07 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/PluginLsCommand.java
@@ -16,25 +16,56 @@
 
 import static com.google.gerrit.sshd.CommandMetaData.Mode.MASTER_OR_SLAVE;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.annotations.RequiresCapability;
+import com.google.gerrit.extensions.common.PluginInfo;
+import com.google.gerrit.extensions.restapi.TopLevelResource;
+import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.plugins.ListPlugins;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
+import com.google.gerrit.util.cli.Options;
+import com.google.gson.reflect.TypeToken;
 import com.google.inject.Inject;
+import java.util.Map;
+import org.kohsuke.args4j.Option;
 
 @RequiresCapability(GlobalCapability.VIEW_PLUGINS)
 @CommandMetaData(name = "ls", description = "List the installed plugins", runsAt = MASTER_OR_SLAVE)
-final class PluginLsCommand extends SshCommand {
-  @Inject private ListPlugins impl;
+public class PluginLsCommand extends SshCommand {
+  @Inject @Options public ListPlugins list;
+
+  @Option(name = "--format", usage = "output format")
+  private OutputFormat format = OutputFormat.TEXT;
 
   @Override
   public void run() throws Exception {
-    impl.display(stdout);
+    Map<String, PluginInfo> output = list.apply(TopLevelResource.INSTANCE);
+
+    if (format.isJson()) {
+      format
+          .newGson()
+          .toJson(output, new TypeToken<Map<String, PluginInfo>>() {}.getType(), stdout);
+      stdout.print('\n');
+    } else {
+      stdout.format("%-30s %-10s %-8s %s\n", "Name", "Version", "Status", "File");
+      stdout.print(
+          "-------------------------------------------------------------------------------\n");
+      for (Map.Entry<String, PluginInfo> p : output.entrySet()) {
+        PluginInfo info = p.getValue();
+        stdout.format(
+            "%-30s %-10s %-8s %s\n",
+            p.getKey(),
+            Strings.nullToEmpty(info.version),
+            status(info.disabled),
+            Strings.nullToEmpty(info.filename));
+      }
+    }
+    stdout.flush();
   }
 
-  @Override
-  protected void parseCommandLine() throws UnloggedFailure {
-    parseCommandLine(impl);
+  private String status(Boolean disabled) {
+    return disabled != null && disabled.booleanValue() ? "DISABLED" : "ENABLED";
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
index bf60776..3fe0396 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Query.java
@@ -24,7 +24,7 @@
 import org.kohsuke.args4j.Option;
 
 @CommandMetaData(name = "query", description = "Query the change database")
-class Query extends SshCommand {
+public class Query extends SshCommand {
   @Inject private OutputStreamQuery processor;
 
   @Option(name = "--format", metaVar = "FMT", usage = "Output display format")
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java
index 4201c2c..9651f39 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/QueryShell.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.common.Version;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.schema.ReviewDbFactory;
 import com.google.gson.JsonArray;
 import com.google.gson.JsonObject;
 import com.google.gwtorm.jdbc.JdbcSchema;
@@ -68,7 +69,7 @@
 
   @Inject
   QueryShell(
-      final SchemaFactory<ReviewDb> dbFactory,
+      @ReviewDbFactory final SchemaFactory<ReviewDb> dbFactory,
       @Assisted final InputStream in,
       @Assisted final OutputStream out) {
     this.dbFactory = dbFactory;
@@ -272,7 +273,7 @@
           Identity.create(rs, "COLUMN_NAME"),
           new Function("TYPE") {
             @Override
-            String apply(final ResultSet rs) throws SQLException {
+            String apply(ResultSet rs) throws SQLException {
               String type = rs.getString("TYPE_NAME");
               switch (rs.getInt("DATA_TYPE")) {
                 case java.sql.Types.CHAR:
@@ -344,7 +345,7 @@
     println("");
   }
 
-  private void executeStatement(final String sql) {
+  private void executeStatement(String sql) {
     final long start = TimeUtil.nowMs();
     final boolean hasResultSet;
     try {
@@ -397,7 +398,7 @@
    * @param show Functions to map columns
    * @throws SQLException
    */
-  private void showResultSet(final ResultSet rs, boolean alreadyOnRow, long start, Function... show)
+  private void showResultSet(ResultSet rs, boolean alreadyOnRow, long start, Function... show)
       throws SQLException {
     switch (outputFormat) {
       case JSON_SINGLE:
@@ -620,7 +621,7 @@
     }
   }
 
-  private void warning(final String msg) {
+  private void warning(String msg) {
     switch (outputFormat) {
       case JSON_SINGLE:
       case JSON:
@@ -639,7 +640,7 @@
     }
   }
 
-  private void error(final SQLException err) {
+  private void error(SQLException err) {
     switch (outputFormat) {
       case JSON_SINGLE:
       case JSON:
@@ -718,7 +719,7 @@
   private abstract static class Function {
     final String name;
 
-    Function(final String name) {
+    Function(String name) {
       this.name = name;
     }
 
@@ -726,19 +727,19 @@
   }
 
   private static class Identity extends Function {
-    static Identity create(final ResultSet rs, final String name) throws SQLException {
+    static Identity create(ResultSet rs, String name) throws SQLException {
       return new Identity(rs.findColumn(name), name);
     }
 
     final int colId;
 
-    Identity(final int colId, final String name) {
+    Identity(int colId, String name) {
       super(name);
       this.colId = colId;
     }
 
     @Override
-    String apply(final ResultSet rs) throws SQLException {
+    String apply(ResultSet rs) throws SQLException {
       return rs.getString(colId);
     }
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
index 8852f0e..262e57a 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Receive.java
@@ -14,22 +14,26 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.common.collect.MultimapBuilder;
+import com.google.common.collect.SetMultimap;
 import com.google.gerrit.common.data.Capable;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.server.IdentifiedUser;
-import com.google.gerrit.server.git.AsyncReceiveCommits;
-import com.google.gerrit.server.git.ReceiveCommits;
 import com.google.gerrit.server.git.VisibleRefFilter;
+import com.google.gerrit.server.git.receive.AsyncReceiveCommits;
+import com.google.gerrit.server.notedb.ReviewerStateInternal;
+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.sshd.AbstractGitCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshSession;
 import com.google.inject.Inject;
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
-import java.util.Set;
 import org.eclipse.jgit.errors.TooLargeObjectInPackException;
 import org.eclipse.jgit.errors.UnpackException;
 import org.eclipse.jgit.lib.Ref;
@@ -50,17 +54,18 @@
   @Inject private AsyncReceiveCommits.Factory factory;
   @Inject private IdentifiedUser currentUser;
   @Inject private SshSession session;
+  @Inject private PermissionBackend permissionBackend;
 
-  private final Set<Account.Id> reviewerId = new HashSet<>();
-  private final Set<Account.Id> ccId = new HashSet<>();
+  private final SetMultimap<ReviewerStateInternal, Account.Id> reviewers =
+      MultimapBuilder.hashKeys(2).hashSetValues().build();
 
   @Option(
       name = "--reviewer",
       aliases = {"--re"},
       metaVar = "EMAIL",
       usage = "request reviewer for change(s)")
-  void addReviewer(final Account.Id id) {
-    reviewerId.add(id);
+  void addReviewer(Account.Id id) {
+    reviewers.put(ReviewerStateInternal.REVIEWER, id);
   }
 
   @Option(
@@ -68,27 +73,31 @@
       aliases = {},
       metaVar = "EMAIL",
       usage = "CC user on change(s)")
-  void addCC(final Account.Id id) {
-    ccId.add(id);
+  void addCC(Account.Id id) {
+    reviewers.put(ReviewerStateInternal.CC, id);
   }
 
   @Override
   protected void runImpl() throws IOException, Failure {
-    if (!projectControl.canRunReceivePack()) {
+    try {
+      permissionBackend
+          .user(currentUser)
+          .project(project.getNameKey())
+          .check(ProjectPermission.RUN_RECEIVE_PACK);
+    } catch (AuthException e) {
       throw new Failure(1, "fatal: receive-pack not permitted on this server");
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "fatal: unable to check permissions " + e);
     }
 
-    final ReceiveCommits receive = factory.create(projectControl, repo).getReceiveCommits();
+    AsyncReceiveCommits arc = factory.create(projectControl, repo, null, reviewers);
 
-    Capable r = receive.canUpload();
+    Capable r = arc.canUpload();
     if (r != Capable.OK) {
       throw die(r.getMessage());
     }
 
-    receive.init();
-    receive.addReviewers(reviewerId);
-    receive.addExtraCC(ccId);
-    ReceivePack rp = receive.getReceivePack();
+    ReceivePack rp = arc.getReceivePack();
     try {
       rp.receive(in, out, err);
       session.setPeerAgent(rp.getPeerUserAgent());
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
index 331405a..53b6b32 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/RenameGroupCommand.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.sshd.commands;
 
-import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -51,7 +50,7 @@
       PutName.Input input = new PutName.Input();
       input.name = newGroupName;
       putName.apply(rsrc, input);
-    } catch (RestApiException | OrmException | IOException | NoSuchGroupException e) {
+    } catch (RestApiException | OrmException | IOException e) {
       throw die(e);
     }
   }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
index d038824..a9899b5 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ReviewCommand.java
@@ -33,8 +33,9 @@
 import com.google.gerrit.server.OutputFormat;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.NoSuchProjectException;
+import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.util.LabelVote;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -77,7 +78,7 @@
       multiValued = true,
       metaVar = "{COMMIT | CHANGE,PATCHSET}",
       usage = "list of commits or patch sets to review")
-  void addPatchSetId(final String token) {
+  void addPatchSetId(String token) {
     try {
       PatchSet ps = psParser.parsePatchSet(token, projectControl, branch);
       patchSets.add(ps);
@@ -126,21 +127,10 @@
   @Option(name = "--submit", aliases = "-s", usage = "submit the specified patch set(s)")
   private boolean submitChange;
 
-  @Option(name = "--publish", usage = "publish the specified draft patch set(s)")
-  private boolean publishPatchSet;
-
-  @Option(name = "--delete", usage = "delete the specified draft patch set(s)")
-  private boolean deleteDraftPatchSet;
-
   @Option(name = "--json", aliases = "-j", usage = "read review input json from stdin")
   private boolean json;
 
   @Option(
-      name = "--strict-labels",
-      usage = "Strictly check if the labels specified can be applied to the given patch set(s)")
-  private boolean strictLabels;
-
-  @Option(
       name = "--tag",
       aliases = "-t",
       usage = "applies a tag to the given review",
@@ -152,13 +142,13 @@
       aliases = "-l",
       usage = "custom label(s) to assign",
       metaVar = "LABEL=VALUE")
-  void addLabel(final String token) {
+  void addLabel(String token) {
     LabelVote v = LabelVote.parseWithEquals(token);
     LabelType.checkName(v.label()); // Disallow SUBM.
     customLabels.put(v.label(), v.value());
   }
 
-  @Inject private ProjectControl.Factory projectControlFactory;
+  @Inject private ProjectCache projectCache;
 
   @Inject private AllProjectsName allProjects;
 
@@ -178,12 +168,6 @@
       if (submitChange) {
         throw die("abandon and submit actions are mutually exclusive");
       }
-      if (publishPatchSet) {
-        throw die("abandon and publish actions are mutually exclusive");
-      }
-      if (deleteDraftPatchSet) {
-        throw die("abandon and delete actions are mutually exclusive");
-      }
       if (rebaseChange) {
         throw die("abandon and rebase actions are mutually exclusive");
       }
@@ -191,17 +175,6 @@
         throw die("abandon and move actions are mutually exclusive");
       }
     }
-    if (publishPatchSet) {
-      if (restoreChange) {
-        throw die("publish and restore actions are mutually exclusive");
-      }
-      if (submitChange) {
-        throw die("publish and submit actions are mutually exclusive");
-      }
-      if (deleteDraftPatchSet) {
-        throw die("publish and delete actions are mutually exclusive");
-      }
-    }
     if (json) {
       if (restoreChange) {
         throw die("json and restore actions are mutually exclusive");
@@ -209,12 +182,6 @@
       if (submitChange) {
         throw die("json and submit actions are mutually exclusive");
       }
-      if (deleteDraftPatchSet) {
-        throw die("json and delete actions are mutually exclusive");
-      }
-      if (publishPatchSet) {
-        throw die("json and publish actions are mutually exclusive");
-      }
       if (abandonChange) {
         throw die("json and abandon actions are mutually exclusive");
       }
@@ -232,16 +199,10 @@
       }
     }
     if (rebaseChange) {
-      if (deleteDraftPatchSet) {
-        throw die("rebase and delete actions are mutually exclusive");
-      }
       if (submitChange) {
         throw die("rebase and submit actions are mutually exclusive");
       }
     }
-    if (deleteDraftPatchSet && submitChange) {
-      throw die("delete and submit actions are mutually exclusive");
-    }
 
     boolean ok = true;
     ReviewInput input = null;
@@ -249,7 +210,7 @@
       input = reviewFromJson();
     }
 
-    for (final PatchSet patchSet : patchSets) {
+    for (PatchSet patchSet : patchSets) {
       try {
         if (input != null) {
           applyReview(patchSet, input);
@@ -274,7 +235,7 @@
     }
   }
 
-  private void applyReview(PatchSet patchSet, final ReviewInput review) throws RestApiException {
+  private void applyReview(PatchSet patchSet, ReviewInput review) throws RestApiException {
     gApi.changes()
         .id(patchSet.getId().getParentKey().get())
         .revision(patchSet.getRevision().get())
@@ -290,10 +251,7 @@
     }
   }
 
-  private void reviewPatchSet(final PatchSet patchSet) throws Exception {
-    if (notify == null) {
-      notify = NotifyHandling.ALL;
-    }
+  private void reviewPatchSet(PatchSet patchSet) throws Exception {
 
     ReviewInput review = new ReviewInput();
     review.message = Strings.emptyToNull(changeComment);
@@ -301,7 +259,6 @@
     review.notify = notify;
     review.labels = new TreeMap<>();
     review.drafts = ReviewInput.DraftHandling.PUBLISH;
-    review.strictLabels = strictLabels;
     for (ApproveOption ao : optionList) {
       Short v = ao.value();
       if (v != null) {
@@ -345,11 +302,6 @@
         revisionApi(patchSet).submit();
       }
 
-      if (publishPatchSet) {
-        revisionApi(patchSet).publish();
-      } else if (deleteDraftPatchSet) {
-        revisionApi(patchSet).delete();
-      }
     } catch (IllegalStateException | RestApiException e) {
       throw die(e);
     }
@@ -368,14 +320,14 @@
     optionList = new ArrayList<>();
     customLabels = new HashMap<>();
 
-    ProjectControl allProjectsControl;
+    ProjectState allProjectsState;
     try {
-      allProjectsControl = projectControlFactory.controlFor(allProjects);
-    } catch (NoSuchProjectException e) {
+      allProjectsState = projectCache.checkedGet(allProjects);
+    } catch (IOException e) {
       throw die("missing " + allProjects.get());
     }
 
-    for (LabelType type : allProjectsControl.getLabelTypes().getLabelTypes()) {
+    for (LabelType type : allProjectsState.getLabelTypes().getLabelTypes()) {
       StringBuilder usage = new StringBuilder("score for ").append(type.getName()).append("\n");
 
       for (LabelValue v : type.getValues()) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
index ff45d75..0d20305 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ScpCommand.java
@@ -51,7 +51,7 @@
   private IOException error;
 
   @Override
-  public void setArguments(final String[] args) {
+  public void setArguments(String[] args) {
     root = "";
     for (int i = 0; i < args.length; i++) {
       if (args[i].charAt(0) == '-') {
@@ -82,15 +82,8 @@
   }
 
   @Override
-  public void start(final Environment env) {
-    startThread(
-        new Runnable() {
-          @Override
-          public void run() {
-            runImp();
-          }
-        },
-        AccessPath.SSH_COMMAND);
+  public void start(Environment env) {
+    startThread(this::runImp, AccessPath.SSH_COMMAND);
   }
 
   private void runImp() {
@@ -163,7 +156,7 @@
     }
   }
 
-  private void readFile(final Entry ent) throws IOException {
+  private void readFile(Entry ent) throws IOException {
     byte[] data = ent.getBytes();
     if (data == null) {
       throw new FileNotFoundException(ent.getPath());
@@ -177,7 +170,7 @@
     readAck();
   }
 
-  private void readDir(final Entry dir) throws IOException {
+  private void readDir(Entry dir) throws IOException {
     header(dir, 0);
     readAck();
 
@@ -194,8 +187,7 @@
     readAck();
   }
 
-  private void header(final Entry dir, final int len)
-      throws IOException, UnsupportedEncodingException {
+  private void header(Entry dir, int len) throws IOException, UnsupportedEncodingException {
     final StringBuilder buf = new StringBuilder();
     switch (dir.getType()) {
       case DIR:
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
index d8ca77b..3fbf81d 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetAccountCommand.java
@@ -18,17 +18,17 @@
 
 import com.google.common.base.Strings;
 import com.google.gerrit.common.RawInputUtil;
-import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.errors.EmailException;
-import com.google.gerrit.extensions.annotations.RequiresCapability;
 import com.google.gerrit.extensions.api.accounts.EmailInput;
 import com.google.gerrit.extensions.common.EmailInfo;
 import com.google.gerrit.extensions.common.SshKeyInfo;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountSshKey;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountResource;
 import com.google.gerrit.server.account.AddSshKey;
@@ -42,10 +42,14 @@
 import com.google.gerrit.server.account.PutHttpPassword;
 import com.google.gerrit.server.account.PutName;
 import com.google.gerrit.server.account.PutPreferred;
+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.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
+import com.google.inject.Provider;
 import java.io.BufferedReader;
 import java.io.IOException;
 import java.io.InputStreamReader;
@@ -60,7 +64,6 @@
 
 /** Set a user's account settings. * */
 @CommandMetaData(name = "set-account", description = "Change an account's settings")
-@RequiresCapability(GlobalCapability.MODIFY_ACCOUNT)
 final class SetAccountCommand extends SshCommand {
 
   @Argument(
@@ -112,6 +115,9 @@
   @Option(name = "--clear-http-password", usage = "clear HTTP password for the account")
   private boolean clearHttpPassword;
 
+  @Option(name = "--generate-http-password", usage = "generate a new HTTP password for the account")
+  private boolean generateHttpPassword;
+
   @Inject private IdentifiedUser.GenericFactory genericUserFactory;
 
   @Inject private CreateEmail.Factory createEmailFactory;
@@ -136,21 +142,55 @@
 
   @Inject private DeleteSshKey deleteSshKey;
 
+  @Inject private PermissionBackend permissionBackend;
+
+  @Inject private Provider<CurrentUser> userProvider;
+
   private IdentifiedUser user;
   private AccountResource rsrc;
 
   @Override
   public void run() throws Exception {
+    user = genericUserFactory.create(id);
+
     validate();
     setAccount();
   }
 
   private void validate() throws UnloggedFailure {
-    if (active && inactive) {
-      throw die("--active and --inactive options are mutually exclusive.");
+    PermissionBackend.WithUser userPermission = permissionBackend.user(userProvider);
+
+    boolean isAdmin = userPermission.testOrFalse(GlobalPermission.ADMINISTRATE_SERVER);
+    boolean canModifyAccount =
+        isAdmin || userPermission.testOrFalse(GlobalPermission.MODIFY_ACCOUNT);
+
+    if (!user.hasSameAccountId(userProvider.get()) && !canModifyAccount) {
+      throw die(
+          "Setting another user's account information requries 'modify account' or 'administrate server' capabilities.");
     }
-    if (clearHttpPassword && !Strings.isNullOrEmpty(httpPassword)) {
-      throw die("--http-password and --clear-http-password options are mutually exclusive.");
+    if (active || inactive) {
+      if (!canModifyAccount) {
+        throw die(
+            "--active and --inactive require 'modify account' or 'administrate server' capabilities.");
+      }
+      if (active && inactive) {
+        throw die("--active and --inactive options are mutually exclusive.");
+      }
+    }
+
+    if (generateHttpPassword && clearHttpPassword) {
+      throw die("--generate-http-password and --clear-http-password are mutually exclusive.");
+    }
+    if (!Strings.isNullOrEmpty(httpPassword)) { // gave --http-password
+      if (!isAdmin) {
+        throw die("--http-password requires 'administrate server' capabilities.");
+      }
+      if (generateHttpPassword) {
+        throw die("--http-password and --generate-http-password options are mutually exclusive.");
+      }
+      if (clearHttpPassword) {
+        throw die("--http-password and --clear-http-password options are mutually exclusive.");
+      }
     }
     if (addSshKeys.contains("-") && deleteSshKeys.contains("-")) {
       throw die("Only one option may use the stdin");
@@ -169,8 +209,8 @@
   }
 
   private void setAccount()
-      throws OrmException, IOException, UnloggedFailure, ConfigInvalidException {
-    user = genericUserFactory.create(id);
+      throws OrmException, IOException, UnloggedFailure, ConfigInvalidException,
+          PermissionBackendException {
     rsrc = new AccountResource(user);
     try {
       for (String email : addEmails) {
@@ -191,10 +231,16 @@
         putName.apply(rsrc, in);
       }
 
-      if (httpPassword != null || clearHttpPassword) {
+      if (httpPassword != null || clearHttpPassword || generateHttpPassword) {
         PutHttpPassword.Input in = new PutHttpPassword.Input();
         in.httpPassword = httpPassword;
-        putHttpPassword.apply(rsrc, in);
+        if (generateHttpPassword) {
+          in.generate = true;
+        }
+        Response<String> resp = putHttpPassword.apply(rsrc, in);
+        if (generateHttpPassword) {
+          stdout.print("New password: " + resp.value() + "\n");
+        }
       }
 
       if (active) {
@@ -222,8 +268,9 @@
   }
 
   private void addSshKeys(List<String> sshKeys)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
-    for (final String sshKey : sshKeys) {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
+    for (String sshKey : sshKeys) {
       AddSshKey.Input in = new AddSshKey.Input();
       in.raw = RawInputUtil.create(sshKey.getBytes(UTF_8), "plain/text");
       addSshKey.apply(rsrc, in);
@@ -232,7 +279,7 @@
 
   private void deleteSshKeys(List<String> sshKeys)
       throws RestApiException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
+          ConfigInvalidException, PermissionBackendException {
     List<SshKeyInfo> infos = getSshKeys.apply(rsrc);
     if (sshKeys.contains("ALL")) {
       for (SshKeyInfo i : infos) {
@@ -251,14 +298,15 @@
 
   private void deleteSshKey(SshKeyInfo i)
       throws AuthException, OrmException, RepositoryNotFoundException, IOException,
-          ConfigInvalidException {
+          ConfigInvalidException, PermissionBackendException {
     AccountSshKey sshKey =
         new AccountSshKey(new AccountSshKey.Id(user.getAccountId(), i.seq), i.sshPublicKey);
     deleteSshKey.apply(new AccountResource.SshKey(user, sshKey), null);
   }
 
   private void addEmail(String email)
-      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws UnloggedFailure, RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     EmailInput in = new EmailInput();
     in.email = email;
     in.noConfirmation = true;
@@ -270,7 +318,8 @@
   }
 
   private void deleteEmail(String email)
-      throws RestApiException, OrmException, IOException, ConfigInvalidException {
+      throws RestApiException, OrmException, IOException, ConfigInvalidException,
+          PermissionBackendException {
     if (email.equals("ALL")) {
       List<EmailInfo> emails = getEmails.apply(rsrc);
       for (EmailInfo e : emails) {
@@ -281,7 +330,9 @@
     }
   }
 
-  private void putPreferred(String email) throws RestApiException, OrmException, IOException {
+  private void putPreferred(String email)
+      throws RestApiException, OrmException, IOException, PermissionBackendException,
+          ConfigInvalidException {
     for (EmailInfo e : getEmails.apply(rsrc)) {
       if (e.email.equals(email)) {
         putPreferred.apply(new AccountResource.Email(user, email), null);
@@ -291,7 +342,7 @@
     stderr.println("preferred email not found: " + email);
   }
 
-  private List<String> readSshKey(final List<String> sshKeys)
+  private List<String> readSshKey(List<String> sshKeys)
       throws UnsupportedEncodingException, IOException {
     if (!sshKeys.isEmpty()) {
       int idx = sshKeys.indexOf("-");
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
index 7d9cf31..84a9f84 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetMembersCommand.java
@@ -18,6 +18,7 @@
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.base.MoreObjects;
+import com.google.common.collect.Streams;
 import com.google.gerrit.extensions.restapi.IdString;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
@@ -25,12 +26,13 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.GroupCache;
-import com.google.gerrit.server.group.AddIncludedGroups;
 import com.google.gerrit.server.group.AddMembers;
-import com.google.gerrit.server.group.DeleteIncludedGroups;
+import com.google.gerrit.server.group.AddSubgroups;
 import com.google.gerrit.server.group.DeleteMembers;
+import com.google.gerrit.server.group.DeleteSubgroups;
 import com.google.gerrit.server.group.GroupResource;
 import com.google.gerrit.server.group.GroupsCollection;
+import com.google.gerrit.server.group.InternalGroup;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
@@ -86,9 +88,9 @@
 
   @Inject private DeleteMembers deleteMembers;
 
-  @Inject private AddIncludedGroups addIncludedGroups;
+  @Inject private AddSubgroups addSubgroups;
 
-  @Inject private DeleteIncludedGroups deleteIncludedGroups;
+  @Inject private DeleteSubgroups deleteSubgroups;
 
   @Inject private GroupsCollection groupsCollection;
 
@@ -107,7 +109,7 @@
           reportMembersAction("removed from", resource, accountsToRemove);
         }
         if (!groupsToRemove.isEmpty()) {
-          deleteIncludedGroups.apply(resource, fromGroups(groupsToRemove));
+          deleteSubgroups.apply(resource, fromGroups(groupsToRemove));
           reportGroupsAction("excluded from", resource, groupsToRemove);
         }
         if (!accountsToAdd.isEmpty()) {
@@ -115,7 +117,7 @@
           reportMembersAction("added to", resource, accountsToAdd);
         }
         if (!groupsToInclude.isEmpty()) {
-          addIncludedGroups.apply(resource, fromGroups(groupsToInclude));
+          addSubgroups.apply(resource, fromGroups(groupsToInclude));
           reportGroupsAction("included to", resource, groupsToInclude);
         }
       }
@@ -143,14 +145,17 @@
       String action, GroupResource group, List<AccountGroup.UUID> groupUuidList)
       throws UnsupportedEncodingException, IOException {
     String names =
-        groupUuidList.stream().map(uuid -> groupCache.get(uuid).getName()).collect(joining(", "));
+        groupUuidList
+            .stream()
+            .map(uuid -> groupCache.get(uuid).map(InternalGroup::getName))
+            .flatMap(Streams::stream)
+            .collect(joining(", "));
     out.write(
         String.format("Groups %s group %s: %s\n", action, group.getName(), names).getBytes(ENC));
   }
 
-  private AddIncludedGroups.Input fromGroups(List<AccountGroup.UUID> accounts) {
-    return AddIncludedGroups.Input.fromGroups(
-        accounts.stream().map(Object::toString).collect(toList()));
+  private AddSubgroups.Input fromGroups(List<AccountGroup.UUID> accounts) {
+    return AddSubgroups.Input.fromGroups(accounts.stream().map(Object::toString).collect(toList()));
   }
 
   private AddMembers.Input fromMembers(List<Account.Id> accounts) {
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
index d126b16..6b3fcf2 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetProjectCommand.java
@@ -14,29 +14,23 @@
 
 package com.google.gerrit.sshd.commands;
 
+import com.google.common.base.Strings;
+import com.google.gerrit.extensions.api.projects.ConfigInput;
 import com.google.gerrit.extensions.client.InheritableBoolean;
 import com.google.gerrit.extensions.client.ProjectState;
 import com.google.gerrit.extensions.client.SubmitType;
-import com.google.gerrit.reviewdb.client.Project;
-import com.google.gerrit.server.git.MetaDataUpdate;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.project.ProjectCache;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.project.ProjectResource;
+import com.google.gerrit.server.project.PutConfig;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.inject.Inject;
-import java.io.IOException;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.errors.RepositoryNotFoundException;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.Option;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 @CommandMetaData(name = "set-project", description = "Change a project's settings")
 final class SetProjectCommand extends SshCommand {
-  private static final Logger log = LoggerFactory.getLogger(SetProjectCommand.class);
-
   @Argument(index = 0, required = true, metaVar = "NAME", usage = "name of the project")
   private ProjectControl projectControl;
 
@@ -134,64 +128,30 @@
   @Option(name = "--max-object-size-limit", usage = "max Git object size for this project")
   private String maxObjectSizeLimit;
 
-  @Inject private MetaDataUpdate.User metaDataUpdateFactory;
-
-  @Inject private ProjectCache projectCache;
+  @Inject private PutConfig putConfig;
 
   @Override
   protected void run() throws Failure {
-    if (!projectControl.isOwner()) {
-      throw new UnloggedFailure(1, "restricted to project owner");
+    ConfigInput configInput = new ConfigInput();
+    configInput.requireChangeId = requireChangeID;
+    configInput.submitType = submitType;
+    configInput.useContentMerge = contentMerge;
+    configInput.useContributorAgreements = contributorAgreements;
+    configInput.useSignedOffBy = signedOffBy;
+    configInput.state = state;
+    configInput.maxObjectSizeLimit = maxObjectSizeLimit;
+    // Description is different to other parameters, null won't result in
+    // keeping the existing description, it would delete it.
+    if (Strings.emptyToNull(projectDescription) != null) {
+      configInput.description = projectDescription;
+    } else {
+      configInput.description = projectControl.getProject().getDescription();
     }
-    Project ctlProject = projectControl.getProject();
-    Project.NameKey nameKey = ctlProject.getNameKey();
-    String name = ctlProject.getName();
-    final StringBuilder err = new StringBuilder();
 
-    try (MetaDataUpdate md = metaDataUpdateFactory.create(nameKey)) {
-      ProjectConfig config = ProjectConfig.read(md);
-      Project project = config.getProject();
-
-      if (requireChangeID != null) {
-        project.setRequireChangeID(requireChangeID);
-      }
-      if (submitType != null) {
-        project.setSubmitType(submitType);
-      }
-      if (contentMerge != null) {
-        project.setUseContentMerge(contentMerge);
-      }
-      if (contributorAgreements != null) {
-        project.setUseContributorAgreements(contributorAgreements);
-      }
-      if (signedOffBy != null) {
-        project.setUseSignedOffBy(signedOffBy);
-      }
-      if (projectDescription != null) {
-        project.setDescription(projectDescription);
-      }
-      if (state != null) {
-        project.setState(state);
-      }
-      if (maxObjectSizeLimit != null) {
-        project.setMaxObjectSizeLimit(maxObjectSizeLimit);
-      }
-      md.setMessage("Project settings updated");
-      config.commit(md);
-    } catch (RepositoryNotFoundException notFound) {
-      err.append("Project ").append(name).append(" not found\n");
-    } catch (IOException | ConfigInvalidException e) {
-      final String msg = "Cannot update project " + name;
-      log.error(msg, e);
-      err.append("error: ").append(msg).append("\n");
-    }
-    projectCache.evict(ctlProject);
-
-    if (err.length() > 0) {
-      while (err.charAt(err.length() - 1) == '\n') {
-        err.setLength(err.length() - 1);
-      }
-      throw die(err.toString());
+    try {
+      putConfig.apply(new ProjectResource(projectControl), configInput);
+    } catch (RestApiException e) {
+      throw die(e);
     }
   }
 }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
index aa060c5..52a790b 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/SetReviewersCommand.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.server.change.DeleteReviewer;
 import com.google.gerrit.server.change.PostReviewers;
 import com.google.gerrit.server.change.ReviewerResource;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.sshd.ChangeArgumentParser;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -76,6 +77,8 @@
       throw new IllegalArgumentException(e.getMessage(), e);
     } catch (OrmException e) {
       throw new IllegalArgumentException("database is down", e);
+    } catch (PermissionBackendException e) {
+      throw new IllegalArgumentException("can't check permissions", e);
     }
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
index abca0b8..20e77c7 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowCaches.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.common.Version;
 import com.google.gerrit.extensions.annotations.RequiresAnyCapability;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.config.ConfigResource;
 import com.google.gerrit.server.config.GetSummary;
@@ -34,6 +35,9 @@
 import com.google.gerrit.server.config.ListCaches;
 import com.google.gerrit.server.config.ListCaches.CacheInfo;
 import com.google.gerrit.server.config.ListCaches.CacheType;
+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.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
 import com.google.gerrit.sshd.SshDaemon;
@@ -79,12 +83,10 @@
   private boolean showThreads;
 
   @Inject private SshDaemon daemon;
-
   @Inject private ListCaches listCaches;
-
   @Inject private GetSummary getSummary;
-
   @Inject private CurrentUser self;
+  @Inject private PermissionBackend permissionBackend;
 
   @Option(
       name = "--width",
@@ -166,7 +168,15 @@
     printDiskCaches(caches);
     stdout.print('\n');
 
-    if (self.getCapabilities().canMaintainServer()) {
+    boolean showJvm;
+    try {
+      permissionBackend.user(self).check(GlobalPermission.MAINTAIN_SERVER);
+      showJvm = true;
+    } catch (AuthException | PermissionBackendException e) {
+      // Silently ignore and do not display detailed JVM information.
+      showJvm = false;
+    }
+    if (showJvm) {
       sshSummary();
 
       SummaryInfo summary = getSummary.setGc(gc).setJvm(showJVM).apply(new ConfigResource());
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java
index d13a9a5..d3bb8fe 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowConnections.java
@@ -72,7 +72,7 @@
   private int columns = 80;
 
   @Override
-  public void start(final Environment env) throws IOException {
+  public void start(Environment env) throws IOException {
     String s = env.getEnv().get(Environment.ENV_COLUMNS);
     if (s != null && !s.isEmpty()) {
       try {
@@ -119,7 +119,7 @@
           String.format(
               "%-8s %8s %8s   %-15s %s\n", "Session", "Start", "Idle", "User", "Remote Host"));
       stdout.print("--------------------------------------------------------------\n");
-      for (final IoSession io : list) {
+      for (IoSession io : list) {
         checkState(io instanceof MinaSession, "expected MinaSession");
         MinaSession minaSession = (MinaSession) io;
         long start = minaSession.getSession().getCreationTime();
@@ -139,7 +139,7 @@
     } else {
       stdout.print(String.format("%-8s   %-15s %s\n", "Session", "User", "Remote Host"));
       stdout.print("--------------------------------------------------------------\n");
-      for (final IoSession io : list) {
+      for (IoSession io : list) {
         AbstractSession s = AbstractSession.getSession(io, true);
         SshSession sd = s != null ? s.getAttribute(SshSession.KEY) : null;
 
@@ -166,11 +166,11 @@
     }
   }
 
-  private static String id(final SshSession sd) {
+  private static String id(SshSession sd) {
     return sd != null ? IdGenerator.format(sd.getSessionId()) : "";
   }
 
-  private static String time(final long now, final long time) {
+  private static String time(long now, long time) {
     if (now - time < 24 * 60 * 60 * 1000L) {
       return new SimpleDateFormat("HH:mm:ss").format(new Date(time));
     }
@@ -190,7 +190,7 @@
     return String.format("%02d:%02d:%02d", hr, min, sec);
   }
 
-  private String username(final SshSession sd) {
+  private String username(SshSession sd) {
     if (sd == null) {
       return "";
     }
@@ -211,7 +211,7 @@
     return "";
   }
 
-  private String hostname(final SocketAddress remoteAddress) {
+  private String hostname(SocketAddress remoteAddress) {
     if (remoteAddress == null) {
       return "?";
     }
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
index 4d1398d..84c460f 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/ShowQueue.java
@@ -27,6 +27,9 @@
 import com.google.gerrit.server.config.ListTasks.TaskInfo;
 import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.Task;
+import com.google.gerrit.server.permissions.GlobalPermission;
+import com.google.gerrit.server.permissions.PermissionBackend;
+import com.google.gerrit.server.permissions.PermissionBackendException;
 import com.google.gerrit.sshd.AdminHighPriorityCommand;
 import com.google.gerrit.sshd.CommandMetaData;
 import com.google.gerrit.sshd.SshCommand;
@@ -35,6 +38,7 @@
 import java.text.SimpleDateFormat;
 import java.util.Date;
 import java.util.List;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.apache.sshd.server.Environment;
 import org.kohsuke.args4j.Option;
 
@@ -57,10 +61,9 @@
       usage = "group tasks by queue and print queue info")
   private boolean groupByQueue;
 
+  @Inject private PermissionBackend permissionBackend;
   @Inject private ListTasks listTasks;
-
   @Inject private IdentifiedUser currentUser;
-
   @Inject private WorkQueue workQueue;
 
   private int columns = 80;
@@ -80,7 +83,7 @@
   }
 
   @Override
-  protected void run() throws UnloggedFailure {
+  protected void run() throws Failure {
     maxCommandWidth = wide ? Integer.MAX_VALUE : columns - 8 - 12 - 12 - 4 - 4;
     stdout.print(
         String.format(
@@ -94,14 +97,16 @@
       tasks = listTasks.apply(new ConfigResource());
     } catch (AuthException e) {
       throw die(e);
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "permission backend unavailable", e);
     }
-    boolean viewAll = currentUser.getCapabilities().canViewQueue();
-    long now = TimeUtil.nowMs();
 
+    boolean viewAll = permissionBackend.user(currentUser).testOrFalse(GlobalPermission.VIEW_QUEUE);
+    long now = TimeUtil.nowMs();
     if (groupByQueue) {
       ListMultimap<String, TaskInfo> byQueue = byQueue(tasks);
       for (String queueName : byQueue.keySet()) {
-        WorkQueue.Executor e = workQueue.getExecutor(queueName);
+        ScheduledThreadPoolExecutor e = workQueue.getExecutor(queueName);
         stdout.print(String.format("Queue: %s\n", queueName));
         print(byQueue.get(queueName), now, viewAll, e.getCorePoolSize());
       }
@@ -173,7 +178,7 @@
     return format(when, delay);
   }
 
-  private static String startTime(final Date when) {
+  private static String startTime(Date when) {
     return format(when, TimeUtil.nowMs() - when.getTime());
   }
 
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
index 75157b0..e842210 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/StreamEvents.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.server.events.EventTypes;
 import com.google.gerrit.server.events.ProjectNameKeySerializer;
 import com.google.gerrit.server.events.SupplierSerializer;
-import com.google.gerrit.server.git.WorkQueue;
 import com.google.gerrit.server.git.WorkQueue.CancelableRunnable;
 import com.google.gerrit.sshd.BaseCommand;
 import com.google.gerrit.sshd.CommandMetaData;
@@ -43,6 +42,7 @@
 import java.util.List;
 import java.util.concurrent.Future;
 import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
 import org.apache.sshd.server.Environment;
 import org.kohsuke.args4j.Option;
 import org.slf4j.Logger;
@@ -70,7 +70,7 @@
 
   @Inject private DynamicSet<UserScopedEventListener> eventListeners;
 
-  @Inject @StreamCommandExecutor private WorkQueue.Executor pool;
+  @Inject @StreamCommandExecutor private ScheduledThreadPoolExecutor pool;
 
   /** Queue of events to stream to the connected user. */
   private final LinkedBlockingQueue<Event> queue = new LinkedBlockingQueue<>(MAX_EVENTS);
@@ -130,7 +130,7 @@
   private PrintWriter stdout;
 
   @Override
-  public void start(final Environment env) throws IOException {
+  public void start(Environment env) throws IOException {
     try {
       parseCommandLine();
     } catch (UnloggedFailure e) {
@@ -149,7 +149,7 @@
         eventListeners.add(
             new UserScopedEventListener() {
               @Override
-              public void onEvent(final Event event) {
+              public void onEvent(Event event) {
                 if (subscribedToEvents.isEmpty() || subscribedToEvents.contains(event.getType())) {
                   offer(event);
                 }
@@ -175,7 +175,7 @@
   }
 
   @Override
-  protected void onExit(final int rc) {
+  protected void onExit(int rc) {
     removeEventListenerRegistration();
 
     synchronized (taskLock) {
@@ -204,7 +204,7 @@
     }
   }
 
-  private void offer(final Event event) {
+  private void offer(Event event) {
     synchronized (taskLock) {
       if (!queue.offer(event)) {
         dropped = true;
@@ -268,7 +268,7 @@
     }
   }
 
-  private void write(final Object message) {
+  private void write(Object message) {
     String msg = null;
     try {
       msg = gson.toJson(message) + "\n";
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
index 67dfe96..7049c7f 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/Upload.java
@@ -15,16 +15,16 @@
 package com.google.gerrit.sshd.commands;
 
 import com.google.common.collect.Lists;
-import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.git.SearchingChangeCacheImpl;
-import com.google.gerrit.server.git.TagCache;
+import com.google.gerrit.extensions.restapi.AuthException;
 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.validators.UploadValidationException;
 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.sshd.AbstractGitCommand;
 import com.google.gerrit.sshd.SshSession;
 import com.google.inject.Inject;
@@ -38,34 +38,30 @@
 
 /** Publishes Git repositories over SSH using the Git upload-pack protocol. */
 final class Upload extends AbstractGitCommand {
-  @Inject private ReviewDb db;
-
   @Inject private TransferConfig config;
-
-  @Inject private TagCache tagCache;
-
-  @Inject private ChangeNotes.Factory changeNotesFactory;
-
-  @Inject @Nullable private SearchingChangeCacheImpl changeCache;
-
+  @Inject private VisibleRefFilter.Factory refFilterFactory;
   @Inject private DynamicSet<PreUploadHook> preUploadHooks;
-
   @Inject private DynamicSet<PostUploadHook> postUploadHooks;
-
+  @Inject private DynamicSet<UploadPackInitializer> uploadPackInitializers;
   @Inject private UploadValidators.Factory uploadValidatorsFactory;
-
   @Inject private SshSession session;
+  @Inject private PermissionBackend permissionBackend;
 
   @Override
   protected void runImpl() throws IOException, Failure {
-    if (!projectControl.canRunUploadPack()) {
+    try {
+      permissionBackend
+          .user(projectControl.getUser())
+          .project(projectControl.getProject().getNameKey())
+          .check(ProjectPermission.RUN_UPLOAD_PACK);
+    } catch (AuthException e) {
       throw new Failure(1, "fatal: upload-pack not permitted on this server");
+    } catch (PermissionBackendException e) {
+      throw new Failure(1, "fatal: unable to check permissions " + e);
     }
 
     final UploadPack up = new UploadPack(repo);
-    up.setAdvertiseRefsHook(
-        new VisibleRefFilter(
-            tagCache, changeNotesFactory, changeCache, repo, projectControl, db, true));
+    up.setAdvertiseRefsHook(refFilterFactory.create(projectControl.getProjectState(), repo));
     up.setPackConfig(config.getPackConfig());
     up.setTimeout(config.getTimeout());
     up.setPostUploadHook(PostUploadHookChain.newChain(Lists.newArrayList(postUploadHooks)));
@@ -74,6 +70,9 @@
     allPreUploadHooks.add(
         uploadValidatorsFactory.create(project, repo, session.getRemoteAddressAsString()));
     up.setPreUploadHook(PreUploadHookChain.newChain(allPreUploadHooks));
+    for (UploadPackInitializer initializer : uploadPackInitializers) {
+      initializer.init(projectControl.getProject().getNameKey(), up);
+    }
     try {
       up.upload(in, out, err);
       session.setPeerAgent(up.getPeerUserAgent());
diff --git a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
index cafde99..f90650f 100644
--- a/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
+++ b/gerrit-sshd/src/main/java/com/google/gerrit/sshd/commands/UploadArchive.java
@@ -17,9 +17,14 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.change.AllowedFormats;
 import com.google.gerrit.server.change.ArchiveFormat;
+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.CommitsCollection;
 import com.google.gerrit.sshd.AbstractGitCommand;
 import com.google.inject.Inject;
 import java.io.IOException;
@@ -112,8 +117,10 @@
     private List<String> path;
   }
 
+  @Inject private PermissionBackend permissionBackend;
+  @Inject private CommitsCollection commits;
+  @Inject private IdentifiedUser user;
   @Inject private AllowedFormats allowedFormats;
-  @Inject private ReviewDb db;
   private Options options = new Options();
 
   /**
@@ -153,7 +160,7 @@
   }
 
   @Override
-  protected void runImpl() throws IOException, Failure {
+  protected void runImpl() throws IOException, PermissionBackendException, Failure {
     PacketLineOut packetOut = new PacketLineOut(out);
     packetOut.setFlushOnEnd(true);
     packetOut.writeString("ACK");
@@ -174,8 +181,8 @@
         throw new Failure(4, "fatal: reference not found");
       }
 
-      // Verify the user has permissions to read the specified reference
-      if (!projectControl.allRefsAreVisible() && !canRead(treeId)) {
+      // Verify the user has permissions to read the specified tree.
+      if (!canRead(treeId)) {
         throw new Failure(5, "fatal: cannot perform upload-archive operation");
       }
 
@@ -232,10 +239,16 @@
     return Collections.emptyMap();
   }
 
-  private boolean canRead(ObjectId revId) throws IOException {
-    try (RevWalk rw = new RevWalk(repo)) {
-      RevCommit commit = rw.parseCommit(revId);
-      return projectControl.canReadCommit(db, repo, commit);
+  private boolean canRead(ObjectId revId) throws IOException, PermissionBackendException {
+    try {
+      permissionBackend.user(user).project(projectName).check(ProjectPermission.READ);
+      return true;
+    } catch (AuthException e) {
+      // Check reachability of the specific revision.
+      try (RevWalk rw = new RevWalk(repo)) {
+        RevCommit commit = rw.parseCommit(revId);
+        return commits.canRead(state, repo, commit);
+      }
     }
   }
 }
diff --git a/gerrit-test-util/BUCK b/gerrit-test-util/BUCK
deleted file mode 100644
index b2f20a5..0000000
--- a/gerrit-test-util/BUCK
+++ /dev/null
@@ -1,9 +0,0 @@
-java_library(
-  name = 'test_util',
-  srcs = glob(['src/main/java/**/*.java']),
-  visibility = ['PUBLIC'],
-  deps = [
-    '//gerrit-extension-api:api',
-    '//lib:truth',
-  ],
-)
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/ContentEntrySubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/ContentEntrySubject.java
new file mode 100644
index 0000000..be5b6a9
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/ContentEntrySubject.java
@@ -0,0 +1,94 @@
+// 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 static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.IterableSubject;
+import com.google.common.truth.StringSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+import com.google.gerrit.extensions.common.DiffInfo.ContentEntry;
+import com.google.gerrit.truth.ListSubject;
+
+public class ContentEntrySubject extends Subject<ContentEntrySubject, ContentEntry> {
+
+  private static final SubjectFactory<ContentEntrySubject, ContentEntry> DIFF_INFO_SUBJECT_FACTORY =
+      new SubjectFactory<ContentEntrySubject, ContentEntry>() {
+        @Override
+        public ContentEntrySubject getSubject(
+            FailureStrategy failureStrategy, ContentEntry contentEntry) {
+          return new ContentEntrySubject(failureStrategy, contentEntry);
+        }
+      };
+
+  public static ContentEntrySubject assertThat(ContentEntry contentEntry) {
+    return assertAbout(DIFF_INFO_SUBJECT_FACTORY).that(contentEntry);
+  }
+
+  private ContentEntrySubject(FailureStrategy failureStrategy, ContentEntry contentEntry) {
+    super(failureStrategy, contentEntry);
+  }
+
+  public void isDueToRebase() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    Truth.assertWithMessage("Entry should be marked 'dueToRebase'")
+        .that(contentEntry.dueToRebase)
+        .named("dueToRebase")
+        .isTrue();
+  }
+
+  public void isNotDueToRebase() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    Truth.assertWithMessage("Entry should not be marked 'dueToRebase'")
+        .that(contentEntry.dueToRebase)
+        .named("dueToRebase")
+        .isNull();
+  }
+
+  public ListSubject<StringSubject, String> commonLines() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return ListSubject.assertThat(contentEntry.ab, Truth::assertThat).named("common lines");
+  }
+
+  public ListSubject<StringSubject, String> linesOfA() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return ListSubject.assertThat(contentEntry.a, Truth::assertThat).named("lines of 'a'");
+  }
+
+  public ListSubject<StringSubject, String> linesOfB() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return ListSubject.assertThat(contentEntry.b, Truth::assertThat).named("lines of 'b'");
+  }
+
+  public IterableSubject intralineEditsOfA() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return Truth.assertThat(contentEntry.editA).named("intraline edits of 'a'");
+  }
+
+  public IterableSubject intralineEditsOfB() {
+    isNotNull();
+    ContentEntry contentEntry = actual();
+    return Truth.assertThat(contentEntry.editB).named("intraline edits of 'b'");
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/DiffInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/DiffInfoSubject.java
new file mode 100644
index 0000000..1b1b847
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/DiffInfoSubject.java
@@ -0,0 +1,57 @@
+// 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 static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.ComparableSubject;
+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.extensions.common.DiffInfo.ContentEntry;
+import com.google.gerrit.truth.ListSubject;
+
+public class DiffInfoSubject extends Subject<DiffInfoSubject, DiffInfo> {
+
+  private static final SubjectFactory<DiffInfoSubject, DiffInfo> DIFF_INFO_SUBJECT_FACTORY =
+      new SubjectFactory<DiffInfoSubject, DiffInfo>() {
+        @Override
+        public DiffInfoSubject getSubject(FailureStrategy failureStrategy, DiffInfo diffInfo) {
+          return new DiffInfoSubject(failureStrategy, diffInfo);
+        }
+      };
+
+  public static DiffInfoSubject assertThat(DiffInfo diffInfo) {
+    return assertAbout(DIFF_INFO_SUBJECT_FACTORY).that(diffInfo);
+  }
+
+  private DiffInfoSubject(FailureStrategy failureStrategy, DiffInfo diffInfo) {
+    super(failureStrategy, diffInfo);
+  }
+
+  public ListSubject<ContentEntrySubject, ContentEntry> content() {
+    isNotNull();
+    DiffInfo diffInfo = actual();
+    return ListSubject.assertThat(diffInfo.content, ContentEntrySubject::assertThat)
+        .named("content");
+  }
+
+  public ComparableSubject<?, ChangeType> changeType() {
+    isNotNull();
+    DiffInfo diffInfo = actual();
+    return Truth.assertThat(diffInfo.changeType).named("changeType");
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FileInfoSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FileInfoSubject.java
new file mode 100644
index 0000000..f8cdb34
--- /dev/null
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/common/FileInfoSubject.java
@@ -0,0 +1,61 @@
+// 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 static com.google.common.truth.Truth.assertAbout;
+
+import com.google.common.truth.ComparableSubject;
+import com.google.common.truth.FailureStrategy;
+import com.google.common.truth.IntegerSubject;
+import com.google.common.truth.Subject;
+import com.google.common.truth.SubjectFactory;
+import com.google.common.truth.Truth;
+
+public class FileInfoSubject extends Subject<FileInfoSubject, FileInfo> {
+
+  private static final SubjectFactory<FileInfoSubject, FileInfo> FILE_INFO_SUBJECT_FACTORY =
+      new SubjectFactory<FileInfoSubject, FileInfo>() {
+        @Override
+        public FileInfoSubject getSubject(FailureStrategy failureStrategy, FileInfo fileInfo) {
+          return new FileInfoSubject(failureStrategy, fileInfo);
+        }
+      };
+
+  public static FileInfoSubject assertThat(FileInfo fileInfo) {
+    return assertAbout(FILE_INFO_SUBJECT_FACTORY).that(fileInfo);
+  }
+
+  private FileInfoSubject(FailureStrategy failureStrategy, FileInfo fileInfo) {
+    super(failureStrategy, fileInfo);
+  }
+
+  public IntegerSubject linesInserted() {
+    isNotNull();
+    FileInfo fileInfo = actual();
+    return Truth.assertThat(fileInfo.linesInserted).named("linesInserted");
+  }
+
+  public IntegerSubject linesDeleted() {
+    isNotNull();
+    FileInfo fileInfo = actual();
+    return Truth.assertThat(fileInfo.linesDeleted).named("linesDeleted");
+  }
+
+  public ComparableSubject<?, Character> status() {
+    isNotNull();
+    FileInfo fileInfo = actual();
+    return Truth.assertThat(fileInfo.status).named("status");
+  }
+}
diff --git a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
index 989ab0f..30ac496 100644
--- a/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
+++ b/gerrit-test-util/src/main/java/com/google/gerrit/extensions/restapi/BinaryResultSubject.java
@@ -18,6 +18,7 @@
 
 import com.google.common.truth.FailureStrategy;
 import com.google.common.truth.PrimitiveByteArraySubject;
+import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 import com.google.common.truth.SubjectFactory;
 import com.google.common.truth.Truth;
@@ -51,6 +52,15 @@
     super(failureStrategy, binaryResult);
   }
 
+  public StringSubject asString() throws IOException {
+    isNotNull();
+    // We shouldn't close the BinaryResult within this method as it might still
+    // be used afterwards. Besides, closing it doesn't have an effect for most
+    // implementations of a BinaryResult.
+    BinaryResult binaryResult = actual();
+    return Truth.assertThat(binaryResult.asString());
+  }
+
   public PrimitiveByteArraySubject bytes() throws IOException {
     isNotNull();
     // We shouldn't close the BinaryResult within this method as it might still
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
index 11f380d..f2d07ed 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/CmdLineParser.java
@@ -44,11 +44,15 @@
 import java.io.Writer;
 import java.lang.annotation.Annotation;
 import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.ResourceBundle;
+import java.util.Set;
 import org.kohsuke.args4j.Argument;
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.IllegalAnnotationError;
@@ -58,8 +62,10 @@
 import org.kohsuke.args4j.spi.BooleanOptionHandler;
 import org.kohsuke.args4j.spi.EnumOptionHandler;
 import org.kohsuke.args4j.spi.FieldSetter;
+import org.kohsuke.args4j.spi.MethodSetter;
 import org.kohsuke.args4j.spi.OptionHandler;
 import org.kohsuke.args4j.spi.Setter;
+import org.kohsuke.args4j.spi.Setters;
 
 /**
  * Extended command line parser which handles --foo=value arguments.
@@ -191,7 +197,7 @@
     return parser.help.value;
   }
 
-  public void parseArgument(final String... args) throws CmdLineException {
+  public void parseArgument(String... args) throws CmdLineException {
     List<String> tmp = Lists.newArrayListWithCapacity(args.length);
     for (int argi = 0; argi < args.length; argi++) {
       final String str = args[argi];
@@ -228,7 +234,7 @@
 
   public void parseOptionMap(ListMultimap<String, String> params) throws CmdLineException {
     List<String> tmp = Lists.newArrayListWithCapacity(2 * params.size());
-    for (final String key : params.keySet()) {
+    for (String key : params.keySet()) {
       String name = makeOption(key);
 
       if (isBoolean(name)) {
@@ -253,6 +259,10 @@
     return findHandler(makeOption(name)) instanceof BooleanOptionHandler;
   }
 
+  public void parseWithPrefix(String prefix, Object bean) {
+    parser.parseWithPrefix(prefix, bean);
+  }
+
   private String makeOption(String name) {
     if (!name.startsWith("-")) {
       if (name.length() == 1) {
@@ -313,20 +323,135 @@
     throw new CmdLineException(parser, String.format("invalid boolean \"%s=%s\"", name, value));
   }
 
+  private static class PrefixedOption implements Option {
+    String prefix;
+    Option o;
+
+    PrefixedOption(String prefix, Option o) {
+      this.prefix = prefix;
+      this.o = o;
+    }
+
+    @Override
+    public String name() {
+      return getPrefixedName(prefix, o.name());
+    }
+
+    @Override
+    public String[] aliases() {
+      String[] prefixedAliases = new String[o.aliases().length];
+      for (int i = 0; i < prefixedAliases.length; i++) {
+        prefixedAliases[i] = getPrefixedName(prefix, o.aliases()[i]);
+      }
+      return prefixedAliases;
+    }
+
+    @Override
+    public String usage() {
+      return o.usage();
+    }
+
+    @Override
+    public String metaVar() {
+      return o.metaVar();
+    }
+
+    @Override
+    public boolean required() {
+      return o.required();
+    }
+
+    @Override
+    public boolean hidden() {
+      return o.hidden();
+    }
+
+    @SuppressWarnings("rawtypes")
+    @Override
+    public Class<? extends OptionHandler> handler() {
+      return o.handler();
+    }
+
+    @Override
+    public String[] depends() {
+      return o.depends();
+    }
+
+    @Override
+    public Class<? extends Annotation> annotationType() {
+      return o.annotationType();
+    }
+
+    private static String getPrefixedName(String prefix, String name) {
+      return prefix + name;
+    }
+  }
+
   private class MyParser extends org.kohsuke.args4j.CmdLineParser {
     @SuppressWarnings("rawtypes")
     private List<OptionHandler> optionsList;
 
     private HelpOption help;
 
-    MyParser(final Object bean) {
+    MyParser(Object bean) {
       super(bean);
+      parseAdditionalOptions(bean, new HashSet<>());
       ensureOptionsInitialized();
     }
 
+    // NOTE: Argument annotations on bean are ignored.
+    public void parseWithPrefix(String prefix, Object bean) {
+      parseWithPrefix(prefix, bean, new HashSet<>());
+    }
+
+    private void parseWithPrefix(String prefix, Object bean, Set<Object> parsedBeans) {
+      if (!parsedBeans.add(bean)) {
+        return;
+      }
+      // recursively process all the methods/fields.
+      for (Class<?> c = bean.getClass(); c != null; c = c.getSuperclass()) {
+        for (Method m : c.getDeclaredMethods()) {
+          Option o = m.getAnnotation(Option.class);
+          if (o != null) {
+            addOption(new MethodSetter(this, bean, m), new PrefixedOption(prefix, o));
+          }
+        }
+        for (Field f : c.getDeclaredFields()) {
+          Option o = f.getAnnotation(Option.class);
+          if (o != null) {
+            addOption(Setters.create(f, bean), new PrefixedOption(prefix, o));
+          }
+          if (f.isAnnotationPresent(Options.class)) {
+            try {
+              parseWithPrefix(
+                  prefix + f.getAnnotation(Options.class).prefix(), f.get(bean), parsedBeans);
+            } catch (IllegalAccessException e) {
+              throw new IllegalAnnotationError(e);
+            }
+          }
+        }
+      }
+    }
+
+    private void parseAdditionalOptions(Object bean, Set<Object> parsedBeans) {
+      for (Class<?> c = bean.getClass(); c != null; c = c.getSuperclass()) {
+        for (Field f : c.getDeclaredFields()) {
+          if (f.isAnnotationPresent(Options.class)) {
+            Object additionalBean = null;
+            try {
+              additionalBean = f.get(bean);
+            } catch (IllegalAccessException e) {
+              throw new IllegalAnnotationError(e);
+            }
+            parseWithPrefix(f.getAnnotation(Options.class).prefix(), additionalBean, parsedBeans);
+          }
+        }
+      }
+    }
+
     @SuppressWarnings({"unchecked", "rawtypes"})
     @Override
-    protected OptionHandler createOptionHandler(final OptionDef option, final Setter setter) {
+    protected OptionHandler createOptionHandler(OptionDef option, Setter setter) {
       if (isHandlerSpecified(option) || isEnum(setter) || isPrimitive(setter)) {
         return add(super.createOptionHandler(option, setter));
       }
@@ -353,7 +478,7 @@
       }
     }
 
-    private boolean isHandlerSpecified(final OptionDef option) {
+    private boolean isHandlerSpecified(OptionDef option) {
       return option.handler() != OptionHandler.class;
     }
 
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerUtil.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerUtil.java
index 582bee2..95d11a5 100644
--- a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerUtil.java
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/OptionHandlerUtil.java
@@ -25,7 +25,7 @@
 public class OptionHandlerUtil {
   /** Generate a key for an {@link OptionHandlerFactory} in Guice. */
   @SuppressWarnings("unchecked")
-  public static <T> Key<OptionHandlerFactory<T>> keyFor(final Class<T> valueType) {
+  public static <T> Key<OptionHandlerFactory<T>> keyFor(Class<T> valueType) {
     final Type factoryType = Types.newParameterizedType(OptionHandlerFactory.class, valueType);
     return (Key<OptionHandlerFactory<T>>) Key.get(factoryType);
   }
@@ -36,7 +36,7 @@
     return (Key<OptionHandler<T>>) Key.get(handlerType);
   }
 
-  public static <T> Module moduleFor(final Class<T> type, Class<? extends OptionHandler<T>> impl) {
+  public static <T> Module moduleFor(Class<T> type, Class<? extends OptionHandler<T>> impl) {
     return new FactoryModuleBuilder().implement(handlerOf(type), impl).build(keyFor(type));
   }
 
diff --git a/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/Options.java b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/Options.java
new file mode 100644
index 0000000..96613df
--- /dev/null
+++ b/gerrit-util-cli/src/main/java/com/google/gerrit/util/cli/Options.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.util.cli;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Marks a field that refers to a class with @Option annotations
+ *
+ * <p>Any @Option annotations found on the referred class will be handled as if they were found on
+ * the referring class.
+ */
+@Retention(RUNTIME)
+@Target({FIELD})
+public @interface Options {
+  String prefix() default "";
+}
diff --git a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
index cb8ed39..56734ff 100644
--- a/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
+++ b/gerrit-util-http/src/test/java/com/google/gerrit/util/http/testutil/FakeHttpServletRequest.java
@@ -19,11 +19,11 @@
 import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.base.Splitter;
+import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.LinkedListMultimap;
 import com.google.common.collect.ListMultimap;
 import com.google.common.collect.Maps;
-import com.google.gerrit.extensions.restapi.Url;
 import java.io.BufferedReader;
 import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
@@ -60,6 +60,7 @@
   private final ListMultimap<String, String> headers;
 
   private ListMultimap<String, String> parameters;
+  private String queryString;
   private String hostName;
   private int port;
   private String contextPath;
@@ -158,6 +159,7 @@
   }
 
   public void setQueryString(String qs) {
+    this.queryString = qs;
     ListMultimap<String, String> params = LinkedListMultimap.create();
     for (String entry : Splitter.on('&').split(qs)) {
       List<String> kv = Splitter.on('=').limit(2).splitToList(entry);
@@ -306,7 +308,7 @@
 
   @Override
   public String getQueryString() {
-    return paramsToString(parameters);
+    return queryString;
   }
 
   @Override
@@ -317,8 +319,8 @@
   @Override
   public String getRequestURI() {
     String uri = contextPath + servletPath + path;
-    if (!parameters.isEmpty()) {
-      uri += "?" + paramsToString(parameters);
+    if (!Strings.isNullOrEmpty(queryString)) {
+      uri += '?' + queryString;
     }
     return uri;
   }
@@ -379,23 +381,6 @@
     throw new UnsupportedOperationException();
   }
 
-  private static String paramsToString(ListMultimap<String, String> params) {
-    StringBuilder sb = new StringBuilder();
-    boolean first = true;
-    for (Map.Entry<String, String> e : params.entries()) {
-      if (!first) {
-        sb.append('&');
-      } else {
-        first = false;
-      }
-      sb.append(Url.encode(e.getKey()));
-      if (!"".equals(e.getValue())) {
-        sb.append('=').append(Url.encode(e.getValue()));
-      }
-    }
-    return sb.toString();
-  }
-
   @Override
   public AsyncContext getAsyncContext() {
     throw new UnsupportedOperationException();
diff --git a/gerrit-util-ssl/src/main/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java b/gerrit-util-ssl/src/main/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
index 171e059..6dc1006 100644
--- a/gerrit-util-ssl/src/main/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
+++ b/gerrit-util-ssl/src/main/java/com/google/gerrit/util/ssl/BlindSSLSocketFactory.java
@@ -63,7 +63,7 @@
 
   private final SSLSocketFactory sslFactory;
 
-  private BlindSSLSocketFactory(final SSLSocketFactory sslFactory) {
+  private BlindSSLSocketFactory(SSLSocketFactory sslFactory) {
     this.sslFactory = sslFactory;
   }
 
diff --git a/gerrit-war/BUILD b/gerrit-war/BUILD
index 512adf2..82a2e21 100644
--- a/gerrit-war/BUILD
+++ b/gerrit-war/BUILD
@@ -19,8 +19,10 @@
         "//gerrit-pgm:init-api",
         "//gerrit-pgm:util",
         "//gerrit-reviewdb:server",
+        "//gerrit-server:module",
+        "//gerrit-server:prolog-common",
+        "//gerrit-server:receive",
         "//gerrit-server:server",
-        "//gerrit-server/src/main/prolog:common",
         "//gerrit-sshd:sshd",
         "//lib:guava",
         "//lib:gwtorm",
diff --git a/gerrit-war/pom.xml b/gerrit-war/pom.xml
index f438621..20aeebc 100644
--- a/gerrit-war/pom.xml
+++ b/gerrit-war/pom.xml
@@ -2,7 +2,7 @@
   <modelVersion>4.0.0</modelVersion>
   <groupId>com.google.gerrit</groupId>
   <artifactId>gerrit-war</artifactId>
-  <version>2.14.18</version>
+  <version>2.15.8-SNAPSHOT</version>
   <packaging>war</packaging>
   <name>Gerrit Code Review - WAR</name>
   <description>Gerrit WAR</description>
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java
index 2693340..616030e 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/ReviewDbDataSourceProvider.java
@@ -54,7 +54,7 @@
     }
   }
 
-  private void closeDataSource(final DataSource ds) {
+  private void closeDataSource(DataSource ds) {
     try {
       Class<?> type = Class.forName("org.apache.commons.dbcp.BasicDataSource");
       if (type.isInstance(ds)) {
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index 4815366..28c9137 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -34,6 +34,7 @@
 import com.google.gerrit.server.LibModuleLoader;
 import com.google.gerrit.server.ModuleOverloader;
 import com.google.gerrit.server.StartupChecks;
+import com.google.gerrit.server.account.AccountDeactivator;
 import com.google.gerrit.server.account.InternalAccountDirectory;
 import com.google.gerrit.server.cache.h2.H2CacheModule;
 import com.google.gerrit.server.cache.mem.DefaultMemoryCacheModule;
@@ -51,20 +52,23 @@
 import com.google.gerrit.server.events.StreamEventsApiListener;
 import com.google.gerrit.server.git.GarbageCollectionModule;
 import com.google.gerrit.server.git.GitRepositoryManagerModule;
-import com.google.gerrit.server.git.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.git.SearchingChangeCacheImpl;
 import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.git.receive.ReceiveCommitsExecutorModule;
 import com.google.gerrit.server.index.IndexModule;
 import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.OnlineUpgrader;
+import com.google.gerrit.server.index.VersionManager;
 import com.google.gerrit.server.mail.SignedTokenEmailTokenVerifier;
 import com.google.gerrit.server.mail.receive.MailReceiver;
 import com.google.gerrit.server.mail.send.SmtpEmailSender;
 import com.google.gerrit.server.mime.MimeUtil2Module;
-import com.google.gerrit.server.notedb.ConfigNotesMigration;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.DiffExecutorModule;
 import com.google.gerrit.server.plugins.PluginGuiceEnvironment;
 import com.google.gerrit.server.plugins.PluginModule;
 import com.google.gerrit.server.plugins.PluginRestApiModule;
+import com.google.gerrit.server.project.DefaultPermissionBackendModule;
 import com.google.gerrit.server.schema.DataSourceModule;
 import com.google.gerrit.server.schema.DataSourceProvider;
 import com.google.gerrit.server.schema.DataSourceType;
@@ -293,7 +297,7 @@
       modules.add(new GerritServerConfigModule());
     }
     modules.add(new DatabaseModule());
-    modules.add(new ConfigNotesMigration.Module());
+    modules.add(new NotesMigration.Module());
     modules.add(new DropWizardMetricMaker.ApiModule());
     return Guice.createInjector(PRODUCTION, modules);
   }
@@ -320,26 +324,12 @@
     modules.add(cfgInjector.getInstance(GerritGlobalModule.class));
     modules.add(new SearchingChangeCacheImpl.Module());
     modules.add(new InternalAccountDirectory.Module());
+    modules.add(new DefaultPermissionBackendModule());
     modules.add(new DefaultMemoryCacheModule());
     modules.add(new H2CacheModule());
     modules.add(cfgInjector.getInstance(MailReceiver.Module.class));
     modules.add(new SmtpEmailSender.Module());
     modules.add(new SignedTokenEmailTokenVerifier.Module());
-
-    // Plugin module needs to be inserted *before* the index module.
-    // There is the concept of LifecycleModule, in Gerrit's own extension
-    // to Guice, which has these:
-    //  listener().to(SomeClassImplementingLifecycleListener.class);
-    // and the start() methods of each such listener are executed in the
-    // order they are declared.
-    // Makes sure that PluginLoader.start() is executed before the
-    // LuceneIndexModule.start() so that plugins get loaded and the respective
-    // Guice modules installed so that the on-line reindexing will happen
-    // with the proper classes (e.g. group backends, custom Prolog
-    // predicates) and the associated rules ready to be evaluated.
-    modules.add(new PluginModule());
-    modules.add(new PluginRestApiModule());
-
     modules.add(new RestCacheAdminModule());
     modules.add(new GpgModule(config));
     modules.add(new StartupChecks.Module());
@@ -348,6 +338,12 @@
     // work queue can get stuck waiting on index futures that will never return.
     modules.add(createIndexModule());
 
+    modules.add(new PluginModule());
+    if (VersionManager.getOnlineUpgrade(config)) {
+      modules.add(new OnlineUpgrader.Module());
+    }
+
+    modules.add(new PluginRestApiModule());
     modules.add(new WorkQueue.Module());
     modules.add(
         new CanonicalWebUrlModule() {
@@ -366,6 +362,7 @@
         });
     modules.add(new GarbageCollectionModule());
     modules.add(new ChangeCleanupRunner.Module());
+    modules.add(new AccountDeactivator.Module());
     return cfgInjector.createChildInjector(
         ModuleOverloader.override(modules, LibModuleLoader.loadModules(cfgInjector)));
   }
@@ -373,9 +370,9 @@
   private Module createIndexModule() {
     switch (indexType) {
       case LUCENE:
-        return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+        return LuceneIndexModule.latestVersion();
       case ELASTICSEARCH:
-        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
+        return ElasticIndexModule.latestVersion();
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
diff --git a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit-jetty.sh b/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit-jetty.sh
index 1342d804..ee346ab 100644
--- a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit-jetty.sh
+++ b/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit-jetty.sh
@@ -2,7 +2,7 @@
 
 export JETTY_HOST=127.0.0.1
 export JETTY_PORT=8081
-export JETTY_USER=gerrit2
+export JETTY_USER=gerrit
 export JETTY_PID=/var/run/jetty$JETTY_PORT.pid
 export JETTY_HOME=/home/$JETTY_USER/jetty
 export JAVA_HOME=/usr/lib/jvm/java-6-sun-1.6.0.07/jre
diff --git a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml b/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml
index 02aa1b9..cb0a256 100644
--- a/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml
+++ b/gerrit-war/src/main/webapp/WEB-INF/extra/jetty7/gerrit.xml
@@ -42,16 +42,16 @@
 <!--  PostgreSQL
         <Set name="driverClassName">org.postgresql.Driver</Set>
         <Set name="url">jdbc:postgresql:reviewdb</Set>
-        <Set name="username">gerrit2</Set>
+        <Set name="username">gerrit</Set>
         <Set name="password">secretkey</Set>
 -->
 <!--  MySQL
         <Set name="driverClassName">com.mysql.jdbc.Driver</Set>
-        <Set name="url">jdbc:mysql://localhost/reviewdb?user=gerrit2&amp;password=secretkey</Set>
+        <Set name="url">jdbc:mysql://localhost/reviewdb?user=gerrit&amp;password=secretkey</Set>
 -->
 <!--  MariaDB
         <Set name="driverClassName">org.mariadb.jdbc.Driver</Set>
-        <Set name="url">jdbc:mariadb://localhost/reviewdb?user=gerrit2&amp;password=secretkey</Set>
+        <Set name="url">jdbc:mariadb://localhost/reviewdb?user=gerrit&amp;password=secretkey</Set>
 -->
 <!--  H2
         <Set name="driverClassName">org.h2.Driver</Set>
diff --git a/lib/antlr/BUILD b/lib/antlr/BUILD
index 435d6d3..99eb9e6 100644
--- a/lib/antlr/BUILD
+++ b/lib/antlr/BUILD
@@ -1,3 +1,5 @@
+package(default_visibility = ["//gerrit-index:__pkg__"])
+
 [java_library(
     name = n,
     data = ["//lib:LICENSE-antlr"],
@@ -14,10 +16,13 @@
     exports = ["@java-runtime//jar"],
 )
 
+# See https://github.com/bazelbuild/bazel/issues/3542
+# for why we need to tweak jvm flags in this rule.
 java_binary(
     name = "antlr-tool",
+    jvm_flags = ["-XX:-UsePerfData"],
     main_class = "org.antlr.Tool",
-    visibility = ["//gerrit-antlr:__pkg__"],
+    visibility = ["//gerrit-index:__pkg__"],
     runtime_deps = [":tool"],
 )
 
diff --git a/lib/fonts/BUILD b/lib/fonts/BUILD
index d97b238..c4719d5 100644
--- a/lib/fonts/BUILD
+++ b/lib/fonts/BUILD
@@ -1,11 +1,16 @@
-# Source Code Pro. Version 2.010 Roman / 1.030 Italics
-# https://github.com/adobe-fonts/source-code-pro/releases/tag/2.010R-ro%2F1.030R-it
+# Roboto Mono. Version 2.136
+# https://github.com/google/roboto/releases/tag/v2.136
+
 filegroup(
-    name = "sourcecodepro",
+    name = "robotofonts",
     srcs = [
-        "SourceCodePro-Regular.woff",
-        "SourceCodePro-Regular.woff2",
+        "Roboto-Medium.woff",
+        "Roboto-Medium.woff2",
+        "Roboto-Regular.woff",
+        "Roboto-Regular.woff2",
+        "RobotoMono-Regular.woff",
+        "RobotoMono-Regular.woff2",
     ],
-    data = ["//lib:LICENSE-OFL1.1"],
+    data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
 )
diff --git a/lib/fonts/Roboto-Medium.woff b/lib/fonts/Roboto-Medium.woff
new file mode 100644
index 0000000..720bd3e
--- /dev/null
+++ b/lib/fonts/Roboto-Medium.woff
Binary files differ
diff --git a/lib/fonts/Roboto-Medium.woff2 b/lib/fonts/Roboto-Medium.woff2
new file mode 100644
index 0000000..c003fba
--- /dev/null
+++ b/lib/fonts/Roboto-Medium.woff2
Binary files differ
diff --git a/lib/fonts/Roboto-Regular.woff b/lib/fonts/Roboto-Regular.woff
new file mode 100644
index 0000000..03e84eb
--- /dev/null
+++ b/lib/fonts/Roboto-Regular.woff
Binary files differ
diff --git a/lib/fonts/Roboto-Regular.woff2 b/lib/fonts/Roboto-Regular.woff2
new file mode 100644
index 0000000..6fa4939
--- /dev/null
+++ b/lib/fonts/Roboto-Regular.woff2
Binary files differ
diff --git a/lib/fonts/RobotoMono-Regular.woff b/lib/fonts/RobotoMono-Regular.woff
new file mode 100644
index 0000000..1ed8af5
--- /dev/null
+++ b/lib/fonts/RobotoMono-Regular.woff
Binary files differ
diff --git a/lib/fonts/RobotoMono-Regular.woff2 b/lib/fonts/RobotoMono-Regular.woff2
new file mode 100644
index 0000000..1142739
--- /dev/null
+++ b/lib/fonts/RobotoMono-Regular.woff2
Binary files differ
diff --git a/lib/fonts/SourceCodePro-Regular.woff b/lib/fonts/SourceCodePro-Regular.woff
deleted file mode 100644
index 395436e..0000000
--- a/lib/fonts/SourceCodePro-Regular.woff
+++ /dev/null
Binary files differ
diff --git a/lib/fonts/SourceCodePro-Regular.woff2 b/lib/fonts/SourceCodePro-Regular.woff2
deleted file mode 100644
index 65cd591..0000000
--- a/lib/fonts/SourceCodePro-Regular.woff2
+++ /dev/null
Binary files differ
diff --git a/lib/jgit/jgit.bzl b/lib/jgit/jgit.bzl
index e010710..0bef327 100644
--- a/lib/jgit/jgit.bzl
+++ b/lib/jgit/jgit.bzl
@@ -1,12 +1,12 @@
-load("//tools/bzl:maven_jar.bzl", "ECLIPSE", "maven_jar")
+load("//tools/bzl:maven_jar.bzl", "MAVEN_CENTRAL", "maven_jar")
 
-_JGIT_VERS = "4.7.7.201812240805-r"
+_JGIT_VERS = "4.9.7.201810191756-r"
 
 _DOC_VERS = _JGIT_VERS  # Set to _JGIT_VERS unless using a snapshot
 
 JGIT_DOC_URL = "http://download.eclipse.org/jgit/site/" + _DOC_VERS + "/apidocs"
 
-_JGIT_REPO = ECLIPSE  # Leave here even if set to MAVEN_CENTRAL.
+_JGIT_REPO = MAVEN_CENTRAL  # Leave here even if set to MAVEN_CENTRAL.
 
 # set this to use a local version.
 # "/home/<user>/projects/jgit"
@@ -35,28 +35,28 @@
         name = "jgit-lib",
         artifact = "org.eclipse.jgit:org.eclipse.jgit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "d2358e838084d2482d51b51b70fd34a356495925",
-        src_sha1 = "29471dec1313292c0b97a9b89bc3009802145497",
+        sha1 = "fdb6c03608e701970338c0a659cffc6772642708",
+        src_sha1 = "00923a3e9302d659fa7887cc8a019e1fa11b5dd2",
         unsign = True,
     )
     maven_jar(
         name = "jgit-servlet",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.http.server:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "ed85179e4fd6aabadf00db035578dd0888c68228",
+        sha1 = "8385c02bee53a8e189817bae2ea2529631ccc7a8",
         unsign = True,
     )
     maven_jar(
         name = "jgit-archive",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.archive:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "83820dc9253d6e1450742cbea9f7de265caf2a89",
+        sha1 = "b8224b08c5c403ee635b8fa8378c27fbe6329620",
     )
     maven_jar(
         name = "jgit-junit",
         artifact = "org.eclipse.jgit:org.eclipse.jgit.junit:" + _JGIT_VERS,
         repository = _JGIT_REPO,
-        sha1 = "3275d89afdad003b03a055a8a2b838cc512cd2fc",
+        sha1 = "4084467ad58438bc819daf99de7244cfaa5a6fa1",
         unsign = True,
     )
 
diff --git a/lib/js/bower_archives.bzl b/lib/js/bower_archives.bzl
index 1dcc76d..5f09d7a 100644
--- a/lib/js/bower_archives.bzl
+++ b/lib/js/bower_archives.bzl
@@ -10,8 +10,8 @@
     bower_archive(
         name = "accessibility-developer-tools",
         package = "accessibility-developer-tools",
-        version = "2.11.0",
-        sha1 = "792cb24b649dafb316e7e536f8ae65d0d7b52bab",
+        version = "2.12.0",
+        sha1 = "88ae82dcdeb6c658f76eff509d0ee425cae14d49",
     )
     bower_archive(
         name = "async",
@@ -26,58 +26,76 @@
         sha1 = "849ad3ee7c77506548b7b5db603a4e150b9431aa",
     )
     bower_archive(
+        name = "font-roboto",
+        package = "PolymerElements/font-roboto",
+        version = "1.0.3",
+        sha1 = "edf478d20ae2fc0704d7c155e20162caaabdd5ae",
+    )
+    bower_archive(
         name = "iron-a11y-announcer",
-        package = "iron-a11y-announcer",
-        version = "1.0.5",
-        sha1 = "007902c041dd8863a1fe893f62450852f4d8c69b",
+        package = "PolymerElements/iron-a11y-announcer",
+        version = "1.0.6",
+        sha1 = "14aed1e1b300ea344e80362e875919ea3d104dcc",
     )
     bower_archive(
         name = "iron-a11y-keys-behavior",
-        package = "iron-a11y-keys-behavior",
+        package = "PolymerElements/iron-a11y-keys-behavior",
         version = "1.1.9",
         sha1 = "f58358ee652c67e6e721364ba50fb77a2ece1465",
     )
     bower_archive(
         name = "iron-behaviors",
-        package = "iron-behaviors",
-        version = "1.0.17",
-        sha1 = "47df7e1c2b97978dcafa13edb50fbdb702570acd",
+        package = "PolymerElements/iron-behaviors",
+        version = "1.0.18",
+        sha1 = "e231a1a02b090f5183db917639fdb96cdd0dca18",
+    )
+    bower_archive(
+        name = "iron-checked-element-behavior",
+        package = "PolymerElements/iron-checked-element-behavior",
+        version = "1.0.6",
+        sha1 = "93ad3554cec119d8c5732d1c722ad113e1866370",
     )
     bower_archive(
         name = "iron-fit-behavior",
-        package = "iron-fit-behavior",
-        version = "1.2.5",
-        sha1 = "5938815cd227843fc77ebeac480b999600a76157",
+        package = "PolymerElements/iron-fit-behavior",
+        version = "1.2.7",
+        sha1 = "01c485fbf898307029bbb72ac7e132db1570a842",
     )
     bower_archive(
         name = "iron-flex-layout",
-        package = "iron-flex-layout",
-        version = "1.3.1",
-        sha1 = "ba696394abff5e799fc06eb11bff4720129a1b52",
+        package = "PolymerElements/iron-flex-layout",
+        version = "1.3.7",
+        sha1 = "4d4cf3232cf750a17a7df0a37476117f831ac633",
     )
     bower_archive(
         name = "iron-form-element-behavior",
-        package = "iron-form-element-behavior",
-        version = "1.0.6",
-        sha1 = "8d9e6530edc1b99bec1a5c34853911fba3701220",
+        package = "PolymerElements/iron-form-element-behavior",
+        version = "1.0.7",
+        sha1 = "7b5a79e02cc32f0918725dd26925d0df1e03ed12",
+    )
+    bower_archive(
+        name = "iron-menu-behavior",
+        package = "PolymerElements/iron-menu-behavior",
+        version = "2.0.1",
+        sha1 = "139528ee1e8d86257e2aa445de7761b8ec70ae91",
     )
     bower_archive(
         name = "iron-meta",
-        package = "iron-meta",
-        version = "1.1.2",
-        sha1 = "dc22fe05e1cb5f94f30a7193d3433ca1808773b8",
+        package = "PolymerElements/iron-meta",
+        version = "1.1.3",
+        sha1 = "f77eba3f6f6817f10bda33918bde8f963d450041",
     )
     bower_archive(
         name = "iron-resizable-behavior",
-        package = "iron-resizable-behavior",
-        version = "1.0.5",
-        sha1 = "2ebe983377dceb3794dd335131050656e23e2beb",
+        package = "polymerelements/iron-resizable-behavior",
+        version = "1.0.6",
+        sha1 = "719c2a8a1a784f8aefcdeef41fcc2e5a03518d9e",
     )
     bower_archive(
         name = "iron-validatable-behavior",
-        package = "iron-validatable-behavior",
-        version = "1.1.1",
-        sha1 = "480423380be0536f948735d91bc472f6e7ced5b4",
+        package = "PolymerElements/iron-validatable-behavior",
+        version = "1.1.2",
+        sha1 = "7111f34ff32e1510131dfbdb1eaa51bfa291e8be",
     )
     bower_archive(
         name = "lodash",
@@ -88,20 +106,44 @@
     bower_archive(
         name = "mocha",
         package = "mocha",
-        version = "3.2.0",
-        sha1 = "b77f23f7ad1f1363501bcae96f0f4f47745dad0f",
+        version = "3.5.3",
+        sha1 = "c14f149821e4e96241b20f85134aa757b73038f1",
     )
     bower_archive(
         name = "neon-animation",
-        package = "neon-animation",
-        version = "1.2.4",
-        sha1 = "e8ccbb930c4b7ff470b1450baa901618888a7fd3",
+        package = "polymerelements/neon-animation",
+        version = "1.2.5",
+        sha1 = "588d289f779d02b21ce5b676e257bbd6155649e8",
+    )
+    bower_archive(
+        name = "paper-behaviors",
+        package = "polymerelements/paper-behaviors",
+        version = "1.0.13",
+        sha1 = "a81eab28a952e124c208430e17508d9a1aae4ee7",
+    )
+    bower_archive(
+        name = "paper-material",
+        package = "polymerelements/paper-material",
+        version = "1.0.7",
+        sha1 = "159b7fb6b13b181c4276b25f9c6adbeaacb0d42b",
+    )
+    bower_archive(
+        name = "paper-ripple",
+        package = "polymerelements/paper-ripple",
+        version = "1.0.10",
+        sha1 = "21199db50d02b842da54bd6f4f1d1b10b474e893",
+    )
+    bower_archive(
+        name = "paper-styles",
+        package = "PolymerElements/paper-styles",
+        version = "1.3.1",
+        sha1 = "4ee9c692366949a754e0e39f8031aa60ce66f24d",
     )
     bower_archive(
         name = "sinon-chai",
         package = "sinon-chai",
-        version = "2.8.0",
-        sha1 = "0464b5d944fdf8116bb23e0b02ecfbac945b3517",
+        version = "2.14.0",
+        sha1 = "78f0dc184efe47012a2b1b9a16a4289acf8300dc",
     )
     bower_archive(
         name = "sinonjs",
@@ -117,13 +159,13 @@
     )
     bower_archive(
         name = "web-animations-js",
-        package = "web-animations-js",
-        version = "2.2.2",
-        sha1 = "6276a9f227da7d4ccaf77c202b50e174dd11a2c2",
+        package = "web-animations/web-animations-js",
+        version = "2.3.1",
+        sha1 = "2ba5548d36188fe54555eaad0a576de4b027661e",
     )
     bower_archive(
         name = "webcomponentsjs",
-        package = "webcomponentsjs",
-        version = "0.7.23",
-        sha1 = "3d62269e614175573b0a0f3039aab05d40f0a763",
+        package = "webcomponents/webcomponentsjs",
+        version = "0.7.24",
+        sha1 = "559227f8ee9db9bfbd81989f24510cc0c1bfc65c",
     )
diff --git a/lib/js/bower_components.bzl b/lib/js/bower_components.bzl
index 14d8a04..c769829 100644
--- a/lib/js/bower_components.bzl
+++ b/lib/js/bower_components.bzl
@@ -30,6 +30,10 @@
         seed = True,
     )
     bower_component(
+        name = "font-roboto",
+        license = "//lib:LICENSE-polymer",
+    )
+    bower_component(
         name = "iron-a11y-announcer",
         license = "//lib:LICENSE-polymer",
         deps = [":polymer"],
@@ -45,7 +49,6 @@
         deps = [
             ":iron-behaviors",
             ":iron-flex-layout",
-            ":iron-form-element-behavior",
             ":iron-validatable-behavior",
             ":polymer",
         ],
@@ -60,10 +63,18 @@
         ],
     )
     bower_component(
+        name = "iron-checked-element-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-form-element-behavior",
+            ":iron-validatable-behavior",
+            ":polymer",
+        ],
+    )
+    bower_component(
         name = "iron-dropdown",
         license = "//lib:LICENSE-polymer",
         deps = [
-            ":iron-a11y-keys-behavior",
             ":iron-behaviors",
             ":iron-overlay-behavior",
             ":iron-resizable-behavior",
@@ -88,6 +99,25 @@
         deps = [":polymer"],
     )
     bower_component(
+        name = "iron-icon",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-flex-layout",
+            ":iron-meta",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "iron-iconset-svg",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-meta",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
         name = "iron-input",
         license = "//lib:LICENSE-polymer",
         deps = [
@@ -98,6 +128,16 @@
         seed = True,
     )
     bower_component(
+        name = "iron-menu-behavior",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":iron-flex-layout",
+            ":iron-selector",
+            ":polymer",
+        ],
+    )
+    bower_component(
         name = "iron-meta",
         license = "//lib:LICENSE-polymer",
         deps = [":polymer"],
@@ -168,6 +208,98 @@
         seed = True,
     )
     bower_component(
+        name = "paper-behaviors",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-checked-element-behavior",
+            ":paper-ripple",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-button",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-flex-layout",
+            ":paper-behaviors",
+            ":paper-material",
+            ":paper-ripple",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-input",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":iron-autogrow-textarea",
+            ":iron-behaviors",
+            ":iron-form-element-behavior",
+            ":iron-input",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-item",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-flex-layout",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-listbox",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-behaviors",
+            ":iron-menu-behavior",
+            ":paper-styles",
+            ":polymer",
+        ],
+        seed = True,
+    )
+    bower_component(
+        name = "paper-material",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":paper-styles",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-ripple",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":iron-a11y-keys-behavior",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "paper-styles",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":font-roboto",
+            ":iron-flex-layout",
+            ":polymer",
+        ],
+    )
+    bower_component(
+        name = "polymer-resin",
+        license = "//lib:LICENSE-polymer",
+        deps = [
+            ":polymer",
+            ":webcomponentsjs",
+        ],
+        seed = True,
+    )
+    bower_component(
         name = "polymer",
         license = "//lib:LICENSE-polymer",
         deps = [":webcomponentsjs"],
diff --git a/lib/js/npm.bzl b/lib/js/npm.bzl
new file mode 100644
index 0000000..0fd575d
--- /dev/null
+++ b/lib/js/npm.bzl
@@ -0,0 +1,11 @@
+NPM_VERSIONS = {
+    "bower": "1.8.2",
+    "crisper": "2.0.2",
+    "vulcanize": "1.14.8",
+}
+
+NPM_SHA1S = {
+    "bower": "adf53529c8d4af02ef24fb8d5341c1419d33e2f7",
+    "crisper": "7183c58cea33632fb036c91cefd1b43e390d22a2",
+    "vulcanize": "679107f251c19ab7539529b1e3fdd40829e6fc63",
+}
diff --git a/lib/prolog/prolog.bzl b/lib/prolog/prolog.bzl
index 57c414a..4d4dd3a 100644
--- a/lib/prolog/prolog.bzl
+++ b/lib/prolog/prolog.bzl
@@ -12,14 +12,12 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-load("//tools/bzl:genrule2.bzl", "genrule2")
-
 def prolog_cafe_library(
         name,
         srcs,
         deps = [],
         **kwargs):
-    genrule2(
+    native.genrule(
         name = name + "__pl2j",
         cmd = "$(location //lib/prolog:compiler-bin) " +
               "$$(dirname $@) $@ " +
diff --git a/lib/testcontainers/BUILD b/lib/testcontainers/BUILD
index f99365d..9d14fac 100644
--- a/lib/testcontainers/BUILD
+++ b/lib/testcontainers/BUILD
@@ -1,6 +1,6 @@
 java_library(
     name = "duct-tape",
-    testonly = True,
+    testonly = 1,
     data = ["//lib:LICENSE-testcontainers"],
     visibility = ["//visibility:public"],
     exports = ["@duct-tape//jar"],
@@ -8,7 +8,7 @@
 
 java_library(
     name = "visible-assertions",
-    testonly = True,
+    testonly = 1,
     data = ["//lib:LICENSE-testcontainers"],
     visibility = ["//visibility:public"],
     exports = ["@visible-assertions//jar"],
@@ -16,7 +16,7 @@
 
 java_library(
     name = "jna",
-    testonly = True,
+    testonly = 1,
     data = ["//lib:LICENSE-Apache2.0"],
     visibility = ["//visibility:public"],
     exports = ["@jna//jar"],
@@ -24,7 +24,7 @@
 
 java_library(
     name = "testcontainers",
-    testonly = True,
+    testonly = 1,
     data = ["//lib:LICENSE-testcontainers"],
     visibility = ["//visibility:public"],
     exports = ["@testcontainers//jar"],
@@ -35,3 +35,12 @@
         "//lib/log:ext",
     ],
 )
+
+java_library(
+    name = "testcontainers-elasticsearch",
+    testonly = 1,
+    data = ["//lib:LICENSE-testcontainers"],
+    visibility = ["//visibility:public"],
+    exports = ["@testcontainers-elasticsearch//jar"],
+    runtime_deps = [":testcontainers"],
+)
diff --git a/plugins/download-commands b/plugins/download-commands
index d2f0920..6669a16 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit d2f0920fac4838da70e83a03237ab97413960ec6
+Subproject commit 6669a16692687aeda81ccc4ab938fca59a5fa14a
diff --git a/plugins/hooks b/plugins/hooks
index 8b71877..75df5d5 160000
--- a/plugins/hooks
+++ b/plugins/hooks
@@ -1 +1 @@
-Subproject commit 8b7187734639e41707f142ca67c7ecf21b9cf3bd
+Subproject commit 75df5d54fcb0b56a799ae1dc3aa2f88cbc8e3dc3
diff --git a/plugins/replication b/plugins/replication
index 4a1755e..0fe68ab 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 4a1755eb46f65a812c99a0c552095f4322fc08a3
+Subproject commit 0fe68ab58cccfae23d113a22d3ce66296b7e9fae
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index 6fa8564..951d84b 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit 6fa85646a821e71195a4f1740ab1514a54700c6e
+Subproject commit 951d84b32e4f2393dbcf7c319e0d3f617838948c
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 1568d77..0ffbdf1 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 1568d7755c70cdb26ddc865a7181c90f24480676
+Subproject commit 0ffbdf1e5c3daa9d118ee9f53d5c00497cff6454
diff --git a/polygerrit-ui/.gitignore b/polygerrit-ui/.gitignore
index b3b74a6..7f06bef 100644
--- a/polygerrit-ui/.gitignore
+++ b/polygerrit-ui/.gitignore
@@ -4,3 +4,4 @@
 fonts
 bower_components
 .tmp
+.vscode
\ No newline at end of file
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index f0994c1..38ef3fd 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -13,12 +13,19 @@
         "//lib/js:iron-a11y-keys-behavior",
         "//lib/js:iron-autogrow-textarea",
         "//lib/js:iron-dropdown",
+        "//lib/js:iron-icon",
+        "//lib/js:iron-iconset-svg",
         "//lib/js:iron-input",
         "//lib/js:iron-overlay-behavior",
         "//lib/js:iron-selector",
         "//lib/js:moment",
         "//lib/js:page",
+        "//lib/js:paper-button",
+        "//lib/js:paper-input",
+        "//lib/js:paper-item",
+        "//lib/js:paper-listbox",
         "//lib/js:polymer",
+        "//lib/js:polymer-resin",
         "//lib/js:promise-polyfill",
     ],
 )
@@ -26,7 +33,7 @@
 genrule2(
     name = "fonts",
     srcs = [
-        "//lib/fonts:sourcecodepro",
+        "//lib/fonts:robotofonts",
     ],
     outs = ["fonts.zip"],
     cmd = " && ".join([
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 69cb02b..fb41dc1 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -63,16 +63,17 @@
 
 1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-bazel.html#_gerrit_development_war_file)
 2. Set up a local test site. Docs
-   [here](https://gerrit-review.googlesource.com/Documentation/install-quick.html) and
+   [here](https://gerrit-review.googlesource.com/Documentation/linux-quickstart.html) and
    [here](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
 
 When your project is set up and works using the classic UI, run a test server
 that serves PolyGerrit:
 
 ```sh
-bazel build polygerrit && \
-java -jar bazel-bin/polygerrit.war daemon --polygerrit-dev \
--d ../gerrit_testsite --console-log --show-stack-trace
+bazel build polygerrit &&
+  $(bazel info output_base)/external/local_jdk/bin/java \
+  -jar bazel-bin/polygerrit.war daemon --polygerrit-dev \
+  -d ../gerrit_testsite --console-log --show-stack-trace
 ```
 
 ## Running Tests
@@ -99,6 +100,11 @@
 ./polygerrit-ui/app/run_test.sh
 ```
 
+To allow the tests to run in Safari:
+
+* In the Advanced preferences tab, check "Show Develop menu in menu bar".
+* In the Develop menu, enable the "Allow Remote Automation" option.
+
 If you need to pass additional arguments to `wct`:
 
 ```sh
@@ -128,3 +134,75 @@
 
 We follow the [Google JavaScript Style Guide](https://google.github.io/styleguide/javascriptguide.xml)
 with a few exceptions. When in doubt, remain consistent with the code around you.
+
+In addition, we encourage the use of [ESLint](http://eslint.org/).
+It is available as a command line utility, as well as a plugin for most editors
+and IDEs. It, along with a few dependencies, can also be installed through NPM:
+
+```sh
+sudo npm install -g eslint eslint-config-google eslint-plugin-html
+```
+
+`eslint-config-google` is a port of the Google JS Style Guide to an ESLint
+config module, and `eslint-plugin-html` allows ESLint to lint scripts inside
+HTML.
+We have an .eslintrc.json config file in the polygerrit-ui/ directory configured
+to enforce the preferred style of the PolyGerrit project.
+After installing, you can use `eslint` on any new file you create.
+In addition, you can supply the `--fix` flag to apply some suggested fixes for
+simple style issues.
+If you modify JS inside of `<script>` tags, like for test suites, you may have
+to supply the `--ext .html` flag.
+
+Some useful commands:
+
+* To run ESLint on the whole app, less some dependency code:
+`eslint --ignore-pattern 'bower_components/' --ignore-pattern 'gr-linked-text' --ignore-pattern 'scripts/vendor' --ext .html,.js polygerrit-ui/app`
+* To run ESLint on just the subdirectory you modified:
+`eslint --ext .html,.js polygerrit-ui/app/$YOUR_DIR_HERE`
+* To run the linter on all of your local changes:
+`git diff --name-only master | xargs eslint --ext .html,.js`
+
+We also use the polylint tool to lint use of Polymer. To install polylint,
+execute the following command.
+
+```sh
+npm install -g polylint
+```
+
+To run polylint, execute the following command.
+
+```sh
+bazel test //polygerrit-ui/app:polylint_test
+```
+## Template Type Safety
+Polymer elements are not type checked against the element definition, making it trivial to break the display when refactoring or moving code. We now run additional tests to help ensure that template types are checked.
+
+A few notes to ensure that these tests pass
+- Any functions with optional parameters will need closure annotations.
+- Any Polymer parameters that are nullable or can be multiple types (other than the one explicitly delared) will need type annotations.
+
+A few dependencies are necessary to run these tests:
+``` sh
+npm install -g typescript fried-twinkie
+```
+
+To run on all files, execute the following command:
+
+```sh
+bazel test //polygerrit-ui/app:all --test_tag_filters=template --test_output errors
+```
+
+To run on a specific top level directory (ex: change-list)
+```sh
+bazel test //polygerrit-ui/app:template_test_change-list --test_output errors
+```
+
+To run on a specific file (ex: gr-change-list-view), execute the following command:
+```sh
+bazel test //polygerrit-ui/app:template_test_<TOP_LEVEL_DIRECTORY> --test_arg=<VIEW_NAME> --test_output errors
+```
+
+```sh
+bazel test //polygerrit-ui/app:template_test_change-list --test_arg=gr-change-list-view  --test_output errors
+```
diff --git a/polygerrit-ui/app/.eslintrc.json b/polygerrit-ui/app/.eslintrc.json
new file mode 100644
index 0000000..7cb1a11
--- /dev/null
+++ b/polygerrit-ui/app/.eslintrc.json
@@ -0,0 +1,76 @@
+{
+  "extends": ["eslint:recommended", "google"],
+  "env": {
+    "browser": true,
+    "es6": true
+  },
+  "globals": {
+    "__dirname": false,
+    "app": false,
+    "page": false,
+    "Polymer": false,
+    "process": false,
+    "require": false,
+    "Gerrit": false,
+    "Promise": false,
+    "assert": false,
+    "test": false,
+    "flushAsynchronousOperations": false
+  },
+  "rules": {
+    "arrow-parens": ["error", "as-needed"],
+    "block-spacing": ["error", "always"],
+    "brace-style": ["error", "1tbs", { "allowSingleLine": true }],
+    "camelcase": "off",
+    "comma-dangle": ["error", "always-multiline"],
+    "eol-last": "off",
+    "indent": "off",
+    "indent-legacy": ["error", 2, {
+      "MemberExpression": 2,
+      "FunctionDeclaration": {"body": 1, "parameters": 2},
+      "FunctionExpression": {"body": 1, "parameters": 2},
+      "CallExpression": {"arguments": 2},
+      "ArrayExpression": 1,
+      "ObjectExpression": 1,
+      "SwitchCase": 1
+    }],
+    "keyword-spacing": ["error", { "after": true, "before": true }],
+    "max-len": [
+      "error",
+      80,
+      2,
+      {"ignoreComments": true}
+    ],
+    "new-cap": ["error", { "capIsNewExceptions": ["Polymer"] }],
+    "no-console": "off",
+    "no-restricted-syntax": [
+      "error",
+      {
+        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='test'][property.name='only']",
+        "message": "Remove test.only."
+      },
+      {
+        "selector": "ExpressionStatement > CallExpression > MemberExpression[object.name='suite'][property.name='only']",
+        "message": "Remove suite.only."
+      }
+    ],
+    "no-undef": "off",
+    "no-useless-escape": "off",
+    "no-var": "error",
+    "object-shorthand": ["error", "always"],
+    "prefer-arrow-callback": "error",
+    "prefer-const": "error",
+    "prefer-spread": "error",
+    "quote-props": ["error", "consistent-as-needed"],
+    "require-jsdoc": "off",
+    "semi": [2, "always"],
+    "template-curly-spacing": "error",
+    "valid-jsdoc": "off"
+  },
+  "plugins": [
+    "html"
+  ],
+  "settings": {
+    "html/report-bad-indent": "error"
+  }
+}
diff --git a/polygerrit-ui/app/BUILD b/polygerrit-ui/app/BUILD
index 9d1059f..8012c20 100644
--- a/polygerrit-ui/app/BUILD
+++ b/polygerrit-ui/app/BUILD
@@ -1,15 +1,14 @@
-load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary", "closure_js_library")
 load("//tools/bzl:genrule2.bzl", "genrule2")
 load(
     "//tools/bzl:js.bzl",
     "bower_component_bundle",
-    "vulcanize",
 )
+load(":rules.bzl", "polygerrit_bundle")
 
 package(default_visibility = ["//visibility:public"])
 
-vulcanize(
-    name = "gr-app",
+polygerrit_bundle(
+    name = "polygerrit_ui",
     srcs = glob(
         [
             "**/*.html",
@@ -22,87 +21,8 @@
             "**/*_test.html",
         ],
     ),
-    app = "elements/gr-app.html",
-    deps = ["//polygerrit-ui:polygerrit_components.bower_components"],
-)
-
-closure_js_library(
-    name = "closure_lib",
-    srcs = ["gr-app.js"],
-    convention = "GOOGLE",
-    # TODO(davido): Clean up these issues: http://paste.openstack.org/show/608548
-    # and remove this supression
-    suppress = [
-        "JSC_JSDOC_MISSING_TYPE_WARNING",
-        "JSC_UNNECESSARY_ESCAPE",
-        "JSC_UNUSED_LOCAL_ASSIGNMENT",
-    ],
-    deps = [
-        "//lib/polymer_externs:polymer_closure",
-        "@io_bazel_rules_closure//closure/library",
-    ],
-)
-
-closure_js_binary(
-    name = "closure_bin",
-    # Known issue: Closure compilation not compatible with Polymer behaviors.
-    # See: https://github.com/google/closure-compiler/issues/2042
-    compilation_level = "WHITESPACE_ONLY",
-    defs = [
-        "--polymer_pass",
-        "--jscomp_off=duplicate",
-        "--force_inject_library=es6_runtime",
-    ],
-    language = "ECMASCRIPT5",
-    deps = [":closure_lib"],
-)
-
-filegroup(
-    name = "top_sources",
-    srcs = [
-        "favicon.ico",
-        "index.html",
-    ],
-)
-
-filegroup(
-    name = "css_sources",
-    srcs = glob(["styles/**/*.css"]),
-)
-
-filegroup(
-    name = "app_sources",
-    srcs = [
-        "closure_bin.js",
-        "gr-app.html",
-    ],
-)
-
-genrule2(
-    name = "polygerrit_ui",
-    srcs = [
-        "//lib/fonts:sourcecodepro",
-        "//lib/js:highlightjs_files",
-        ":top_sources",
-        ":css_sources",
-        ":app_sources",
-        # we extract from the zip, but depend on the component for license checking.
-        "@webcomponentsjs//:zipfile",
-        "//lib/js:webcomponentsjs",
-    ],
     outs = ["polygerrit_ui.zip"],
-    cmd = " && ".join([
-        "mkdir -p $$TMP/polygerrit_ui/{styles,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
-        "for f in $(locations :app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/gr-app.$$ext; done",
-        "cp $(locations //lib/fonts:sourcecodepro) $$TMP/polygerrit_ui/fonts/",
-        "for f in $(locations :top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
-        "for f in $(locations :css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
-        "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
-        "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @webcomponentsjs//:zipfile) webcomponentsjs/webcomponents-lite.js",
-        "cd $$TMP",
-        "find . -exec touch -t 198001010000 '{}' ';'",
-        "zip -qr $$ROOT/$@ *",
-    ]),
+    app = "elements/gr-app.html",
 )
 
 bower_component_bundle(
@@ -129,6 +49,16 @@
     ),
 )
 
+filegroup(
+    name = "bower_components",
+    srcs = glob(
+        [
+            "bower_components/**/*.html",
+            "bower_components/**/*.js",
+        ],
+    ),
+)
+
 genrule2(
     name = "pg_code_zip",
     srcs = [":pg_code"],
@@ -146,9 +76,10 @@
 
 sh_test(
     name = "wct_test",
-    size = "large",
+    size = "enormous",
     srcs = ["wct_test.sh"],
     data = [
+        "test/common-test-setup.html",
         "test/index.html",
         ":pg_code.zip",
         ":test_components.zip",
@@ -159,3 +90,116 @@
         "manual",
     ],
 )
+
+sh_test(
+    name = "lint_test",
+    size = "large",
+    srcs = ["lint_test.sh"],
+    data = [
+        ".eslintrc.json",
+        ":pg_code",
+    ],
+    # Should not run sandboxed.
+    tags = [
+        "local",
+        "manual",
+    ],
+)
+
+sh_test(
+    name = "polylint_test",
+    size = "large",
+    srcs = ["polylint_test.sh"],
+    data = [
+        ":pg_code",
+        "//polygerrit-ui:polygerrit_components.bower_components.zip",
+    ],
+    # Should not run sandboxed.
+    tags = [
+        "local",
+        "manual",
+    ],
+)
+
+DIRECTORIES = [
+    "admin",
+    "change",
+    "change-list",
+    "core",
+    "diff",
+    "plugins",
+    "settings",
+    "shared",
+    "gr-app",
+]
+
+[sh_test(
+    name = "template_test_" + directory,
+    size = "enormous",
+    srcs = ["template_test.sh"],
+    args = [directory],
+    data = [
+        ":pg_code",
+        ":template_test_srcs",
+        "//polygerrit-ui:polygerrit_components.bower_components.zip",
+    ],
+    tags = [
+        # Should not run sandboxed.
+        "local",
+        "template",
+    ],
+) for directory in DIRECTORIES]
+
+# Embed bundle
+polygerrit_bundle(
+    name = "polygerrit_embed_ui",
+    srcs = glob(
+        [
+            "**/*.html",
+            "**/*.js",
+        ],
+        exclude = [
+            "bower_components/**",
+            "index.html",
+            "test/**",
+            "**/*_test.html",
+        ],
+    ),
+    outs = ["polygerrit_embed_ui.zip"],
+    app = "embed/change-diff-views.html",
+)
+
+filegroup(
+    name = "embed_test_files",
+    srcs = glob(
+        [
+            "embed/**/*_test.html",
+        ],
+    ),
+)
+
+filegroup(
+    name = "template_test_srcs",
+    srcs = [
+        "template_test_srcs/convert_for_template_tests.py",
+        "template_test_srcs/template_test.js",
+    ],
+)
+
+sh_test(
+    name = "embed_test",
+    size = "small",
+    srcs = ["embed_test.sh"],
+    data = [
+        "embed/test.html",
+        "test/common-test-setup.html",
+        ":embed_test_files",
+        ":polygerrit_embed_ui.zip",
+        ":test_components.zip",
+    ],
+    # Should not run sandboxed.
+    tags = [
+        "local",
+        "manual",
+    ],
+)
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html
new file mode 100644
index 0000000..0148377
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior.html
@@ -0,0 +1,37 @@
+<!--
+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.
+-->
+
+<script>
+(function(window) {
+  'use strict';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.AsyncForeachBehavior */
+  Gerrit.AsyncForeachBehavior = {
+    /**
+     * @template T
+     * @param {!Array<T>} array
+     * @param {!Function} fn
+     * @return {!Promise<undefined>}
+     */
+    asyncForeach(array, fn) {
+      if (!array.length) { return Promise.resolve(); }
+      return fn(array[0]).then(() => this.asyncForeach(array.slice(1), fn));
+    },
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
new file mode 100644
index 0000000..ba15ad7
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/async-foreach-behavior/async-foreach-behavior_test.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>async-foreach-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<link rel="import" href="async-foreach-behavior.html">
+
+<script>
+  suite('async-foreach-behavior tests', () => {
+    test('loops over each item', () => {
+      const fn = sinon.stub().returns(Promise.resolve());
+      return Gerrit.AsyncForeachBehavior.asyncForeach([1, 2, 3], fn)
+          .then(() => {
+            assert.isTrue(fn.calledThrice);
+            assert.equal(fn.getCall(0).args[0], 1);
+            assert.equal(fn.getCall(1).args[0], 2);
+            assert.equal(fn.getCall(2).args[0], 3);
+          });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
index 2ec8538..cda8c530 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior.html
@@ -18,20 +18,20 @@
 (function(window) {
   'use strict';
 
+  window.Gerrit = window.Gerrit || {};
+
   /** @polymerBehavior Gerrit.BaseUrlBehavior */
-  var BaseUrlBehavior = {
-    getBaseUrl: function() {
+  Gerrit.BaseUrlBehavior = {
+    /** @return {string} */
+    getBaseUrl() {
       return window.CANONICAL_PATH || '';
     },
 
-    computeGwtUrl: function(path) {
-      var base = this.getBaseUrl();
-      var clientPath = path.substring(base.length);
+    computeGwtUrl(path) {
+      const base = this.getBaseUrl();
+      const clientPath = path.substring(base.length);
       return base + '/?polygerrit=0#' + clientPath;
     },
   };
-
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.BaseUrlBehavior = BaseUrlBehavior;
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
index 1e277bc..b7c29dc 100644
--- a/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/base-url-behavior/base-url-behavior_test.html
@@ -16,13 +16,13 @@
 -->
 
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
-<title>keyboard-shortcut-behavior</title>
+<title>base-url-behavior</title>
 
 <script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../test/common-test-setup.html"/>
 <script>
+  /** @type {string} */
   window.CANONICAL_PATH = '/r';
 </script>
 <link rel="import" href="base-url-behavior.html">
@@ -42,11 +42,12 @@
 </test-fixture>
 
 <script>
-  suite('base-url-behavior tests', function() {
-    var element;
-    var overlay;
+  suite('base-url-behavior tests', () => {
+    let element;
+    // eslint-disable-next-line no-unused-vars
+    let overlay;
 
-    suiteSetup(function() {
+    suiteSetup(() => {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
@@ -56,20 +57,20 @@
       });
     });
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       overlay = fixture('within-overlay');
     });
 
-    test('getBaseUrl', function() {
+    test('getBaseUrl', () => {
       assert.deepEqual(element.getBaseUrl(), '/r');
     });
 
-    test('computeGwtUrl', function() {
+    test('computeGwtUrl', () => {
       assert.deepEqual(
-        element.computeGwtUrl('/r/c/1/'),
-        '/r/?polygerrit=0#/c/1/'
+          element.computeGwtUrl('/r/c/1/'),
+          '/r/?polygerrit=0#/c/1/'
       );
     });
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html
new file mode 100644
index 0000000..07ce55e
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior.html
@@ -0,0 +1,61 @@
+<!--
+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.
+-->
+<link rel="import" href="../base-url-behavior/base-url-behavior.html">
+<script>
+(function(window) {
+  'use strict';
+
+  const PROBE_PATH = '/Documentation/index.html';
+  const DOCS_BASE_PATH = '/Documentation';
+
+  let cachedPromise;
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.DocsUrlBehavior */
+  Gerrit.DocsUrlBehavior = [{
+
+    /**
+     * Get the docs base URL from either the server config or by probing.
+     * @param {Object} config The server config.
+     * @param {!Object} restApi A REST API instance
+     * @return {!Promise<string>} A promise that resolves with the docs base
+     *     URL.
+     */
+    getDocsBaseUrl(config, restApi) {
+      if (!cachedPromise) {
+        cachedPromise = new Promise(resolve => {
+          if (config && config.gerrit && config.gerrit.doc_url) {
+            resolve(config.gerrit.doc_url);
+          } else {
+            restApi.probePath(this.getBaseUrl() + PROBE_PATH).then(ok => {
+              resolve(ok ? (this.getBaseUrl() + DOCS_BASE_PATH) : null);
+            });
+          }
+        });
+      }
+      return cachedPromise;
+    },
+
+    /** For testing only. */
+    _clearDocsBaseUrlCache() {
+      cachedPromise = undefined;
+    },
+  },
+    Gerrit.BaseUrlBehavior,
+  ];
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
new file mode 100644
index 0000000..8154c78
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/docs-url-behavior/docs-url-behavior_test.html
@@ -0,0 +1,98 @@
+<!--
+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.
+-->
+<!-- Polymer included for the html import polyfill. -->
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<title>docs-url-behavior</title>
+
+<link rel="import" href="docs-url-behavior.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <docs-url-behavior-element></docs-url-behavior-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('docs-url-behavior tests', () => {
+    let element;
+
+    suiteSetup(() => {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'docs-url-behavior-element',
+        behaviors: [Gerrit.DocsUrlBehavior],
+      });
+    });
+
+    setup(() => {
+      element = fixture('basic');
+      element._clearDocsBaseUrlCache();
+    });
+
+    test('null config', () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      return element.getDocsBaseUrl(null, mockRestApi)
+          .then(docsBaseUrl => {
+            assert.isTrue(
+                mockRestApi.probePath.calledWith('/Documentation/index.html'));
+            assert.equal(docsBaseUrl, '/Documentation');
+          });
+    });
+
+    test('no doc config', () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const config = {gerrit: {}};
+      return element.getDocsBaseUrl(config, mockRestApi)
+          .then(docsBaseUrl => {
+            assert.isTrue(
+                mockRestApi.probePath.calledWith('/Documentation/index.html'));
+            assert.equal(docsBaseUrl, '/Documentation');
+          });
+    });
+
+    test('has doc config', () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(true)),
+      };
+      const config = {gerrit: {doc_url: 'foobar'}};
+      return element.getDocsBaseUrl(config, mockRestApi)
+          .then(docsBaseUrl => {
+            assert.isFalse(mockRestApi.probePath.called);
+            assert.equal(docsBaseUrl, 'foobar');
+          });
+    });
+
+    test('no probe', () => {
+      const mockRestApi = {
+        probePath: sinon.stub().returns(Promise.resolve(false)),
+      };
+      return element.getDocsBaseUrl(null, mockRestApi)
+          .then(docsBaseUrl => {
+            assert.isTrue(
+                mockRestApi.probePath.calledWith('/Documentation/index.html'));
+            assert.isNotOk(docsBaseUrl);
+          });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
new file mode 100644
index 0000000..4e67fda
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior.html
@@ -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.
+-->
+
+<script>
+(function(window) {
+  'use strict';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.AccessBehavior */
+  Gerrit.AccessBehavior = {
+    properties: {
+      permissionValues: {
+        type: Object,
+        readOnly: true,
+        value: {
+          abandon: {
+            id: 'abandon',
+            name: 'Abandon',
+          },
+          addPatchSet: {
+            id: 'addPatchSet',
+            name: 'Add Patch Set',
+          },
+          create: {
+            id: 'create',
+            name: 'Create Reference',
+          },
+          createTag: {
+            id: 'createTag',
+            name: 'Create Annotated Tag',
+          },
+          createSignedTag: {
+            id: 'createSignedTag',
+            name: 'Create Signed Tag',
+          },
+          delete: {
+            id: 'delete',
+            name: 'Delete Reference',
+          },
+          deleteDrafts: {
+            id: 'deleteDrafts',
+            name: 'Delete Drafts',
+          },
+          deleteOwnChanges: {
+            id: 'deleteOwnChanges',
+            name: 'Delete Own Changes',
+          },
+          editAssignee: {
+            id: 'editAssignee',
+            name: 'Edit Assignee',
+          },
+          editHashtags: {
+            id: 'editHashtags',
+            name: 'Edit Hashtags',
+          },
+          editTopicName: {
+            id: 'editTopicName',
+            name: 'Edit Topic Name',
+          },
+          forgeAuthor: {
+            id: 'forgeAuthor',
+            name: 'Forge Author Identity',
+          },
+          forgeCommitter: {
+            id: 'forgeCommitter',
+            name: 'Forge Committer Identity',
+          },
+          forgeServerAsCommitter: {
+            id: 'forgeServerAsCommitter',
+            name: 'Forge Server Identity',
+          },
+          owner: {
+            id: 'owner',
+            name: 'Owner',
+          },
+          publishDrafts: {
+            id: 'publishDrafts',
+            name: 'Publish Drafts',
+          },
+          push: {
+            id: 'push',
+            name: 'Push',
+          },
+          pushMerge: {
+            id: 'pushMerge',
+            name: 'Push Merge Commit',
+          },
+          read: {
+            id: 'read',
+            name: 'Read',
+          },
+          rebase: {
+            id: 'rebase',
+            name: 'Rebase',
+          },
+          removeReviewer: {
+            id: 'removeReviewer',
+            name: 'Remove Reviewer',
+          },
+          submit: {
+            id: 'submit',
+            name: 'Submit',
+          },
+          submitAs: {
+            id: 'submitAs',
+            name: 'Submit (On Behalf Of)',
+          },
+          viewDrafts: {
+            id: 'viewDrafts',
+            name: 'View Drafts',
+          },
+          viewPrivateChanges: {
+            id: 'viewPrivateChanges',
+            name: 'View Private Changes',
+          },
+        },
+      },
+    },
+
+    /**
+     * @param {!Object} obj
+     * @return {!Array} returns a sorted array sorted by the id of the original
+     *    object.
+     */
+    toSortedArray(obj) {
+      return Object.keys(obj).map(key => {
+        return {
+          id: key,
+          value: obj[key],
+        };
+      }).sort((a, b) => {
+        // Since IDs are strings, use localeCompare.
+        return a.id.localeCompare(b.id);
+      });
+    },
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
new file mode 100644
index 0000000..62992e1
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-access-behavior/gr-access-behavior_test.html
@@ -0,0 +1,68 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>keyboard-shortcut-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<link rel="import" href="gr-access-behavior.html">
+
+<test-fixture id="basic">
+  <template>
+    <test-element></test-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-access-behavior tests', () => {
+    let element;
+
+    suiteSetup(() => {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element',
+        behaviors: [Gerrit.AccessBehavior],
+      });
+    });
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('toSortedArray', () => {
+      const rules = {
+        'global:Project-Owners': {
+          action: 'ALLOW', force: false,
+        },
+        '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+          action: 'ALLOW', force: false,
+        },
+      };
+      const expectedResult = [
+        {id: '4c97682e6ce6b7247f3381b6f1789356666de7f', value: {
+          action: 'ALLOW', force: false,
+        }},
+        {id: 'global:Project-Owners', value: {
+          action: 'ALLOW', force: false,
+        }},
+      ];
+      assert.deepEqual(element.toSortedArray(rules), expectedResult);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html b/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html
new file mode 100644
index 0000000..10065af
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html
@@ -0,0 +1,45 @@
+<!--
+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.
+-->
+
+<script>
+(function(window) {
+  'use strict';
+
+  const ANONYMOUS_NAME = 'Anonymous';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.AnonymousNameBehavior */
+  Gerrit.AnonymousNameBehavior = {
+    /**
+     * enableEmail when true enables to fallback to using email if
+     * the account name is not avilable.
+     */
+    getUserName(config, account, enableEmail) {
+      if (account && account.name) {
+        return account.name;
+      } else if (enableEmail && account && account.email) {
+        return account.email;
+      } else if (config && config.user &&
+          config.user.anonymous_coward_name !== 'Anonymous Coward') {
+        return config.user.anonymous_coward_name;
+      }
+
+      return ANONYMOUS_NAME;
+    },
+  };
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html b/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html
new file mode 100644
index 0000000..e4a409b
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html
@@ -0,0 +1,84 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-anonymous-name-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<link rel="import" href="gr-anonymous-name-behavior.html">
+
+<test-fixture id="basic">
+  <template>
+    <test-element-anon></test-element-anon>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-anonymous-name-behavior tests', () => {
+    let element;
+    // eslint-disable-next-line no-unused-vars
+    const config = {
+      user: {
+        anonymous_coward_name: 'Anonymous Coward',
+      },
+    };
+
+    suiteSetup(() => {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element-anon',
+        behaviors: [
+          Gerrit.AnonymousNameBehavior,
+        ],
+      });
+    });
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('test for it to return name', () => {
+      const account = {
+        name: 'test-user',
+      };
+      assert.deepEqual(element.getUserName(config, account, true), 'test-user');
+    });
+
+    test('test for it to return email', () => {
+      const account = {
+        email: 'test-user@test-url.com',
+      };
+      assert.deepEqual(element.getUserName(config, account, true),
+          'test-user@test-url.com');
+    });
+
+    test('test for it not to Anonymous Coward as the anon name', () => {
+      assert.deepEqual(element.getUserName(config, null, true), 'Anonymous');
+    });
+
+    test('test for the config returning the anon name', () => {
+      const config = {
+        user: {
+          anonymous_coward_name: 'Test Anon',
+        },
+      };
+      assert.deepEqual(element.getUserName(config, null, true), 'Test Anon');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
index ca955b3..20568e6 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior.html
@@ -17,8 +17,10 @@
 (function(window) {
   'use strict';
 
+  window.Gerrit = window.Gerrit || {};
+
   /** @polymerBehavior Gerrit.ChangeTableBehavior */
-  var ChangeTableBehavior = {
+  Gerrit.ChangeTableBehavior = {
     properties: {
       columnNames: {
         type: Array,
@@ -26,31 +28,35 @@
           'Subject',
           'Status',
           'Owner',
+          'Assignee',
           'Project',
           'Branch',
           'Updated',
           'Size',
         ],
         readOnly: true,
-      }
+      },
     },
 
     /**
      * Returns the complement to the given column array
      * @param {Array} columns
+     * @return {!Array}
      */
-    getComplementColumns: function(columns) {
-      return this.columnNames.filter(function(column) {
-        return columns.indexOf(column) === -1;
+    getComplementColumns(columns) {
+      return this.columnNames.filter(column => {
+        return !columns.includes(column);
       });
     },
 
-    isColumnHidden: function(columnToCheck, columnsToDisplay) {
-      return columnsToDisplay.indexOf(columnToCheck) === -1;
+    /**
+     * @param {string} columnToCheck
+     * @param {!Array} columnsToDisplay
+     * @return {boolean}
+     */
+    isColumnHidden(columnToCheck, columnsToDisplay) {
+      return !columnsToDisplay.includes(columnToCheck);
     },
   };
-
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.ChangeTableBehavior = ChangeTableBehavior;
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
index 42ea615..c265db87c 100644
--- a/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-change-table-behavior/gr-change-table-behavior_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-table-behavior.html">
 
 <test-fixture id="basic">
@@ -39,11 +38,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-table-behavior tests', function() {
-    var element;
-    var overlay;
+  suite('gr-change-table-behavior tests', () => {
+    let element;
+    // eslint-disable-next-line no-unused-vars
+    let overlay;
 
-    suiteSetup(function() {
+    suiteSetup(() => {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
@@ -51,16 +51,17 @@
       });
     });
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       overlay = fixture('within-overlay');
     });
 
-    test('getComplementColumns', function() {
-      var columns = [
+    test('getComplementColumns', () => {
+      let columns = [
         'Subject',
         'Status',
         'Owner',
+        'Assignee',
         'Project',
         'Branch',
         'Updated',
@@ -71,6 +72,7 @@
       columns = [
         'Subject',
         'Status',
+        'Assignee',
         'Project',
         'Branch',
         'Size',
@@ -79,12 +81,13 @@
           ['Owner', 'Updated']);
     });
 
-    test('isColumnHidden', function() {
-      var columnToCheck = 'Project';
-      var columnsToDisplay = [
+    test('isColumnHidden', () => {
+      const columnToCheck = 'Project';
+      let columnsToDisplay = [
         'Subject',
         'Status',
         'Owner',
+        'Assignee',
         'Project',
         'Branch',
         'Updated',
@@ -92,10 +95,11 @@
       ];
       assert.isFalse(element.isColumnHidden(columnToCheck, columnsToDisplay));
 
-      var columnsToDisplay = [
+      columnsToDisplay = [
         'Subject',
         'Status',
         'Owner',
+        'Assignee',
         'Branch',
         'Updated',
         'Size',
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
new file mode 100644
index 0000000..597300e
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior.html
@@ -0,0 +1,62 @@
+<!--
+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.
+-->
+<link rel="import" href="../base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../gr-url-encoding-behavior.html">
+<script>
+(function(window) {
+  'use strict';
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior Gerrit.ListViewBehavior */
+  Gerrit.ListViewBehavior = [{
+    computeLoadingClass(loading) {
+      return loading ? 'loading' : '';
+    },
+
+    computeShownItems(items) {
+      return items.slice(0, 25);
+    },
+
+    getUrl(path, item) {
+      return this.getBaseUrl() + path + this.encodeURL(item, true);
+    },
+
+    /**
+     * @param {Object} params
+     * @return {string}
+     */
+    getFilterValue(params) {
+      if (!params) { return ''; }
+      return params.filter || '';
+    },
+
+    /**
+     * @param {Object} params
+     * @return {number}
+     */
+    getOffsetValue(params) {
+      if (params && params.offset) {
+        return params.offset;
+      }
+      return 0;
+    },
+  },
+    Gerrit.BaseUrlBehavior,
+    Gerrit.URLEncodingBehavior,
+  ];
+})(window);
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
new file mode 100644
index 0000000..599f691
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-list-view-behavior/gr-list-view-behavior_test.html
@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>keyboard-shortcut-behavior</title>
+
+<script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
+<link rel="import" href="gr-list-view-behavior.html">
+
+<test-fixture id="basic">
+  <template>
+    <test-element></test-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-list-view-behavior tests', () => {
+    let element;
+    // eslint-disable-next-line no-unused-vars
+    let overlay;
+
+    suiteSetup(() => {
+      // Define a Polymer element that uses this behavior.
+      Polymer({
+        is: 'test-element',
+        behaviors: [Gerrit.ListViewBehavior],
+      });
+    });
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    test('computeLoadingClass', () => {
+      assert.equal(element.computeLoadingClass(true), 'loading');
+      assert.equal(element.computeLoadingClass(false), '');
+    });
+
+    test('computeShownItems', () => {
+      const myArr = new Array(26);
+      assert.equal(element.computeShownItems(myArr).length, 25);
+    });
+
+    test('getUrl', () => {
+      assert.equal(element.getUrl('/path/to/something/', 'item'),
+          '/path/to/something/item');
+      assert.equal(element.getUrl('/path/to/something/', 'item%test'),
+          '/path/to/something/item%2525test');
+    });
+
+    test('getFilterValue', () => {
+      let params;
+      assert.equal(element.getFilterValue(params), null);
+
+      params = {filter: null};
+      assert.equal(element.getFilterValue(params), null);
+
+      params = {filter: 'test'};
+      assert.equal(element.getFilterValue(params), 'test');
+    });
+
+    test('getOffsetValue', () => {
+      let params;
+      assert.equal(element.getOffsetValue(params), 0);
+
+      params = {offset: null};
+      assert.equal(element.getOffsetValue(params), 0);
+
+      params = {offset: 1};
+      assert.equal(element.getOffsetValue(params), 1);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
index acf3a62..13c232e 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html
@@ -17,8 +17,46 @@
 (function(window) {
   'use strict';
 
-  /** @polymerBehavior Gerrit.PatchSetBehavior */
-  var PatchSetBehavior = {
+  // Tags identifying ChangeMessages that move change into WIP state.
+  const WIP_TAGS = [
+    'autogenerated:gerrit:newWipPatchSet',
+    'autogenerated:gerrit:setWorkInProgress',
+  ];
+
+  // Tags identifying ChangeMessages that move change out of WIP state.
+  const READY_TAGS = [
+    'autogenerated:gerrit:setReadyForReview',
+  ];
+
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior this */
+  Gerrit.PatchSetBehavior = {
+    EDIT_NAME: 'edit',
+
+    /**
+     * As patchNum can be either a string (e.g. 'edit', 'PARENT') OR a number,
+     * this function checks for patchNum equality.
+     *
+     * @param {string|number} a
+     * @param {string|number|undefined} b Undefined sometimes because
+     *    computeLatestPatchNum can return undefined.
+     * @return {boolean}
+     */
+    patchNumEquals(a, b) {
+      return a + '' === b + '';
+    },
+
+    /**
+     * Whether the given patch is a numbered parent of a merge (i.e. a negative
+     * number).
+     * @param  {string|number} n
+     * @return {Boolean}
+     */
+    isMergeParent(n) {
+      return (n + '')[0] === '-';
+    },
+
     /**
      * Given an object of revisions, get a particular revision based on patch
      * num.
@@ -27,18 +65,183 @@
      * @param {number|string} patchNum The number index of the revision
      * @return {Object} The correspondent revision obj from {revisions}
      */
-    getRevisionByPatchNum: function(revisions, patchNum) {
-      patchNum = parseInt(patchNum, 10);
-      for (var rev in revisions) {
-        if (revisions.hasOwnProperty(rev) &&
-            revisions[rev]._number === patchNum) {
-          return revisions[rev];
+    getRevisionByPatchNum(revisions, patchNum) {
+      for (const rev of Object.values(revisions || {})) {
+        if (Gerrit.PatchSetBehavior.patchNumEquals(rev._number, patchNum)) {
+          return rev;
         }
       }
     },
-  };
 
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.PatchSetBehavior = PatchSetBehavior;
+    /**
+     * Find change edit base revision if change edit exists.
+     *
+     * @param {!Array<!Object>} revisions The revisions array.
+     * @return {Object} change edit parent revision or null if change edit
+     *     doesn't exist.
+     */
+    findEditParentRevision(revisions) {
+      const editInfo =
+          revisions.find(info => info._number ===
+              Gerrit.PatchSetBehavior.EDIT_NAME);
+
+      if (!editInfo) { return null; }
+
+      return revisions.find(info => info._number === editInfo.basePatchNum) ||
+          null;
+    },
+
+    /**
+     * Find change edit base patch set number if change edit exists.
+     *
+     * @param {!Array<!Object>} revisions The revisions array.
+     * @return {number} Change edit patch set number or -1.
+     */
+    findEditParentPatchNum(revisions) {
+      const revisionInfo =
+          Gerrit.PatchSetBehavior.findEditParentRevision(revisions);
+      return revisionInfo ? revisionInfo._number : -1;
+    },
+
+    /**
+     * Sort given revisions array according to the patch set number. The sort
+     * algorithm is change edit aware. Change edit has patch set number equals
+     * 0, but must appear after the patch set it was based on. Example: change
+     * edit is based on patch set 2, and another patch set was uploaded after
+     * change edit creation, the sorted order should be: 1, 2, (0|edit), 3.
+     *
+     * @param {!Array<!Object>} revisions The revisions array
+     * @return {!Array<!Object>} The sorted {revisions} array
+     */
+    sortRevisions(revisions) {
+      const editParent =
+          Gerrit.PatchSetBehavior.findEditParentPatchNum(revisions);
+      // Map a normal patchNum to 2 * (patchNum - 1) + 1... I.e. 1 -> 1,
+      // 2 -> 3, 3 -> 5, etc.
+      // Map an edit to the patchNum of parent*2... I.e. edit on 2 -> 4.
+      const num = r => r._number === Gerrit.PatchSetBehavior.EDIT_NAME ?
+          2 * editParent :
+          2 * (r._number - 1) + 1;
+      return revisions.sort((a, b) => num(a) - num(b));
+    },
+
+    /**
+     * Construct a chronological list of patch sets derived from change details.
+     * Each element of this list is an object with the following properties:
+     *
+     *   * num {number} The number identifying the patch set
+     *   * desc {!string} Optional patch set description
+     *   * wip {boolean} If true, this patch set was never subject to review.
+     *
+     * The wip property is determined by the change's current work_in_progress
+     * property and its log of change messages.
+     *
+     * @param {!Object} change The change details
+     * @return {!Array<!Object>} Sorted list of patch set objects, as described
+     *     above
+     */
+    computeAllPatchSets(change) {
+      if (!change) { return []; }
+      let patchNums = [];
+      if (change.revisions &&
+          Object.keys(change.revisions).length) {
+        patchNums =
+          Gerrit.PatchSetBehavior.sortRevisions(Object.values(change.revisions))
+              .map(e => {
+                // TODO(kaspern): Mark which patchset an edit was made on, if an
+                // edit exists -- perhaps with a temporary description.
+                return {
+                  num: e._number,
+                  desc: e.description,
+                };
+              });
+      }
+      return Gerrit.PatchSetBehavior._computeWipForPatchSets(change, patchNums);
+    },
+
+    /**
+     * Populate the wip properties of the given list of patch sets.
+     *
+     * @param {!Object} change The change details
+     * @param {!Array<!Object>} patchNums Sorted list of patch set objects, as
+     *     generated by computeAllPatchSets
+     * @return {!Array<!Object>} The given list of patch set objects, with the
+     *     wip property set on each of them
+     */
+    _computeWipForPatchSets(change, patchNums) {
+      if (!change.messages || !change.messages.length) {
+        return patchNums;
+      }
+      const psWip = {};
+      let wip = change.work_in_progress;
+      for (let i = 0; i < change.messages.length; i++) {
+        const msg = change.messages[i];
+        if (WIP_TAGS.includes(msg.tag)) {
+          wip = true;
+        } else if (READY_TAGS.includes(msg.tag)) {
+          wip = false;
+        }
+        if (psWip[msg._revision_number] !== false) {
+          psWip[msg._revision_number] = wip;
+        }
+      }
+
+      for (let i = 0; i < patchNums.length; i++) {
+        patchNums[i].wip = psWip[patchNums[i].num];
+      }
+      return patchNums;
+    },
+
+    /** @return {number|undefined} */
+    computeLatestPatchNum(allPatchSets) {
+      if (!allPatchSets || !allPatchSets.length) { return undefined; }
+      if (allPatchSets[allPatchSets.length - 1].num ===
+          Gerrit.PatchSetBehavior.EDIT_NAME) {
+        return allPatchSets[allPatchSets.length - 2].num;
+      }
+      return allPatchSets[allPatchSets.length - 1].num;
+    },
+
+    /** @return {Boolean} */
+    hasEditBasedOnCurrentPatchSet(allPatchSets) {
+      if (!allPatchSets || !allPatchSets.length) { return false; }
+      return allPatchSets[allPatchSets.length - 1].num ===
+          Gerrit.PatchSetBehavior.EDIT_NAME;
+    },
+
+    /**
+     * Check whether there is no newer patch than the latest patch that was
+     * available when this change was loaded.
+     *
+     * @return {Promise<boolean>} A promise that yields true if the latest patch
+     *     has been loaded, and false if a newer patch has been uploaded in the
+     *     meantime. The promise is rejected on network error.
+     */
+    fetchIsLatestKnown(change, restAPI) {
+      const knownLatest = Gerrit.PatchSetBehavior.computeLatestPatchNum(
+          Gerrit.PatchSetBehavior.computeAllPatchSets(change));
+      return restAPI.getChangeDetail(change._number)
+          .then(detail => {
+            if (!detail) {
+              return Promise.reject('Unable to check for latest patchset.');
+            }
+            const actualLatest = Gerrit.PatchSetBehavior.computeLatestPatchNum(
+                Gerrit.PatchSetBehavior.computeAllPatchSets(detail));
+            return actualLatest <= knownLatest;
+          });
+    },
+
+    /**
+     * @param {number|string} patchNum
+     * @param {!Array<!Object>} revisions A sorted array of revisions.
+     *
+     * @return {number} The index of the revision with the given patchNum.
+     */
+    findSortedIndex(patchNum, revisions) {
+      revisions = revisions || [];
+      const findNum = rev => rev._number + '' === patchNum + '';
+      return revisions.findIndex(findNum);
+    },
+  };
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
index 862c734..54c1355 100644
--- a/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-patch-set-behavior/gr-patch-set-behavior_test.html
@@ -16,16 +16,16 @@
 <!-- Polymer included for the html import polyfill. -->
 <script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
 <title>gr-patch-set-behavior</title>
 
-<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-patch-set-behavior.html">
 
 <script>
-  suite('gr-path-list-behavior tests', function() {
-    test('getRevisionByPatchNum', function() {
-      var get = Gerrit.PatchSetBehavior.getRevisionByPatchNum;
-      var revisions = [
+  suite('gr-path-list-behavior tests', () => {
+    test('getRevisionByPatchNum', () => {
+      const get = Gerrit.PatchSetBehavior.getRevisionByPatchNum;
+      const revisions = [
         {_number: 0},
         {_number: 1},
         {_number: 2},
@@ -34,5 +34,216 @@
       assert.deepEqual(get(revisions, 2), revisions[2]);
       assert.equal(get(revisions, '3'), undefined);
     });
+
+    test('fetchIsLatestKnown on latest', done => {
+      const knownChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+      };
+      const mockRestApi = {
+        getChangeDetail() {
+          return Promise.resolve(knownChange);
+        },
+      };
+      Gerrit.PatchSetBehavior.fetchIsLatestKnown(knownChange, mockRestApi)
+          .then(isLatest => {
+            assert.isTrue(isLatest);
+            done();
+          });
+    });
+
+    test('fetchIsLatestKnown not on latest', done => {
+      const knownChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+        },
+      };
+      const actualChange = {
+        revisions: {
+          sha1: {description: 'patch 1', _number: 1},
+          sha2: {description: 'patch 2', _number: 2},
+          sha3: {description: 'patch 3', _number: 3},
+        },
+      };
+      const mockRestApi = {
+        getChangeDetail() {
+          return Promise.resolve(actualChange);
+        },
+      };
+      Gerrit.PatchSetBehavior.fetchIsLatestKnown(knownChange, mockRestApi)
+          .then(isLatest => {
+            assert.isFalse(isLatest);
+            done();
+          });
+    });
+
+    test('_computeWipForPatchSets', () => {
+      // Compute patch sets for a given timeline on a change. The initial WIP
+      // property of the change can be true or false. The map of tags by
+      // revision is keyed by patch set number. Each value is a list of change
+      // message tags in the order that they occurred in the timeline. These
+      // indicate actions that modify the WIP property of the change and/or
+      // create new patch sets.
+      //
+      // Returns the actual results with an assertWip method that can be used
+      // to compare against an expected value for a particular patch set.
+      const compute = (initialWip, tagsByRevision) => {
+        const change = {
+          messages: [],
+          work_in_progress: initialWip,
+        };
+        const revs = Object.keys(tagsByRevision).sort((a, b) => {
+          return a - b;
+        });
+        for (const rev of revs) {
+          for (const tag of tagsByRevision[rev]) {
+            change.messages.push({
+              tag,
+              _revision_number: rev,
+            });
+          }
+        }
+        let patchNums = revs.map(rev => { return {num: rev}; });
+        patchNums = Gerrit.PatchSetBehavior._computeWipForPatchSets(
+            change, patchNums);
+        const actualWipsByRevision = {};
+        for (const patchNum of patchNums) {
+          actualWipsByRevision[patchNum.num] = patchNum.wip;
+        }
+        const verifier = {
+          assertWip(revision, expectedWip) {
+            const patchNum = patchNums.find(patchNum => {
+              return patchNum.num == revision;
+            });
+            if (!patchNum) {
+              assert.fail('revision ' + revision + ' not found');
+            }
+            assert.equal(patchNum.wip, expectedWip,
+                'wip state for ' + revision + ' is ' +
+              patchNum.wip + '; expected ' + expectedWip);
+            return verifier;
+          },
+        };
+        return verifier;
+      };
+
+      compute(false, {1: ['upload']}).assertWip(1, false);
+      compute(true, {1: ['upload']}).assertWip(1, true);
+
+      const setWip = 'autogenerated:gerrit:setWorkInProgress';
+      const uploadInWip = 'autogenerated:gerrit:newWipPatchSet';
+      const clearWip = 'autogenerated:gerrit:setReadyForReview';
+
+      compute(false, {
+        1: ['upload', setWip],
+        2: ['upload'],
+        3: ['upload', clearWip],
+        4: ['upload', setWip],
+      }).assertWip(1, false) // Change was created with PS1 ready for review
+          .assertWip(2, true) // PS2 was uploaded during WIP
+          .assertWip(3, false) // PS3 was marked ready for review after upload
+          .assertWip(4, false); // PS4 was uploaded ready for review
+
+      compute(false, {
+        1: [uploadInWip, null, 'addReviewer'],
+        2: ['upload'],
+        3: ['upload', clearWip, setWip],
+        4: ['upload'],
+        5: ['upload', clearWip],
+        6: [uploadInWip],
+      }).assertWip(1, true) // Change was created in WIP
+          .assertWip(2, true) // PS2 was uploaded during WIP
+          .assertWip(3, false) // PS3 was marked ready for review
+          .assertWip(4, true) // PS4 was uploaded during WIP
+          .assertWip(5, false) // PS5 was marked ready for review
+          .assertWip(6, true); // PS6 was uploaded with WIP option
+    });
+
+    test('patchNumEquals', () => {
+      const equals = Gerrit.PatchSetBehavior.patchNumEquals;
+      assert.isFalse(equals('edit', 'PARENT'));
+      assert.isFalse(equals('edit', NaN));
+      assert.isFalse(equals(1, '2'));
+
+      assert.isTrue(equals(1, '1'));
+      assert.isTrue(equals(1, 1));
+      assert.isTrue(equals('edit', 'edit'));
+      assert.isTrue(equals('PARENT', 'PARENT'));
+    });
+
+    test('isMergeParent', () => {
+      const isParent = Gerrit.PatchSetBehavior.isMergeParent;
+      assert.isFalse(isParent(1));
+      assert.isFalse(isParent(4321));
+      assert.isFalse(isParent('52'));
+      assert.isFalse(isParent('edit'));
+      assert.isFalse(isParent('PARENT'));
+      assert.isFalse(isParent(0));
+
+      assert.isTrue(isParent(-23));
+      assert.isTrue(isParent(-1));
+      assert.isTrue(isParent('-42'));
+    });
+
+    test('findEditParentRevision', () => {
+      const findParent = Gerrit.PatchSetBehavior.findEditParentRevision;
+      let revisions = [
+        {_number: 0},
+        {_number: 1},
+        {_number: 2},
+      ];
+      assert.strictEqual(findParent(revisions), null);
+
+      revisions = [...revisions, {_number: 'edit', basePatchNum: 3}];
+      assert.strictEqual(findParent(revisions), null);
+
+      revisions = [...revisions, {_number: 3}];
+      assert.deepEqual(findParent(revisions), {_number: 3});
+    });
+
+    test('findEditParentPatchNum', () => {
+      const findNum = Gerrit.PatchSetBehavior.findEditParentPatchNum;
+      let revisions = [
+        {_number: 0},
+        {_number: 1},
+        {_number: 2},
+      ];
+      assert.equal(findNum(revisions), -1);
+
+      revisions =
+          [...revisions, {_number: 'edit', basePatchNum: 3}, {_number: 3}];
+      assert.deepEqual(findNum(revisions), 3);
+    });
+
+    test('sortRevisions', () => {
+      const sort = Gerrit.PatchSetBehavior.sortRevisions;
+      const revisions = [
+        {_number: 0},
+        {_number: 2},
+        {_number: 1},
+      ];
+      const sorted = [
+        {_number: 0},
+        {_number: 1},
+        {_number: 2},
+      ];
+
+      assert.deepEqual(sort(revisions), sorted);
+
+      // Edit patchset should follow directly after its basePatchNum.
+      revisions.push({_number: 'edit', basePatchNum: 2});
+      sorted.push({_number: 'edit', basePatchNum: 2});
+      assert.deepEqual(sort(revisions), sorted);
+
+      revisions[3].basePatchNum = 0;
+      const edit = sorted.pop();
+      edit.basePatchNum = 0;
+      // Edit patchset should be at index 1.
+      sorted.splice(1, 0, edit);
+      assert.deepEqual(sort(revisions), sorted);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
index f9c4a80..d3491c7 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
@@ -13,58 +13,101 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
+<script src="../../scripts/util.js"></script>
 <script>
 (function(window) {
   'use strict';
 
+  window.Gerrit = window.Gerrit || {};
   /** @polymerBehavior Gerrit.PathListBehavior */
-  var PathListBehavior = {
-    specialFilePathCompare: function(a, b) {
+  Gerrit.PathListBehavior = {
+
+    COMMIT_MESSAGE_PATH: '/COMMIT_MSG',
+    MERGE_LIST_PATH: '/MERGE_LIST',
+
+    /**
+     * @param {string} a
+     * @param {string} b
+     * @return {number}
+     */
+    specialFilePathCompare(a, b) {
       // The commit message always goes first.
-      var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
-      if (a === COMMIT_MESSAGE_PATH) {
+      if (a === Gerrit.PathListBehavior.COMMIT_MESSAGE_PATH) {
         return -1;
       }
-      if (b === COMMIT_MESSAGE_PATH) {
+      if (b === Gerrit.PathListBehavior.COMMIT_MESSAGE_PATH) {
         return 1;
       }
 
       // The merge list always comes next.
-      var MERGE_LIST_PATH = '/MERGE_LIST';
-      if (a === MERGE_LIST_PATH) {
+      if (a === Gerrit.PathListBehavior.MERGE_LIST_PATH) {
         return -1;
       }
-      if (b === MERGE_LIST_PATH) {
+      if (b === Gerrit.PathListBehavior.MERGE_LIST_PATH) {
         return 1;
       }
 
-      var aLastDotIndex = a.lastIndexOf('.');
-      var aExt = a.substr(aLastDotIndex + 1);
-      var aFile = a.substr(0, aLastDotIndex) || a;
+      const aLastDotIndex = a.lastIndexOf('.');
+      const aExt = a.substr(aLastDotIndex + 1);
+      const aFile = a.substr(0, aLastDotIndex) || a;
 
-      var bLastDotIndex = b.lastIndexOf('.');
-      var bExt = b.substr(bLastDotIndex + 1);
-      var bFile = b.substr(0, bLastDotIndex) || b;
+      const bLastDotIndex = b.lastIndexOf('.');
+      const bExt = b.substr(bLastDotIndex + 1);
+      const bFile = b.substr(0, bLastDotIndex) || b;
 
       // Sort header files above others with the same base name.
-      var headerExts = ['h', 'hxx', 'hpp'];
+      const headerExts = ['h', 'hxx', 'hpp'];
       if (aFile.length > 0 && aFile === bFile) {
-        if (headerExts.indexOf(aExt) !== -1 &&
-            headerExts.indexOf(bExt) !== -1) {
+        if (headerExts.includes(aExt) && headerExts.includes(bExt)) {
           return a.localeCompare(b);
         }
-        if (headerExts.indexOf(aExt) !== -1) {
+        if (headerExts.includes(aExt)) {
           return -1;
         }
-        if (headerExts.indexOf(bExt) !== -1) {
+        if (headerExts.includes(bExt)) {
           return 1;
         }
       }
       return aFile.localeCompare(bFile) || a.localeCompare(b);
     },
-  };
 
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.PathListBehavior = PathListBehavior;
+    computeDisplayPath(path) {
+      if (path === Gerrit.PathListBehavior.COMMIT_MESSAGE_PATH) {
+        return 'Commit message';
+      } else if (path === Gerrit.PathListBehavior.MERGE_LIST_PATH) {
+        return 'Merge list';
+      }
+      return path;
+    },
+
+    computeTruncatedPath(path) {
+      return Gerrit.PathListBehavior.truncatePath(
+          Gerrit.PathListBehavior.computeDisplayPath(path));
+    },
+
+    /**
+     * Truncates URLs to display filename only
+     * Example
+     * // returns '.../text.html'
+     * util.truncatePath.('dir/text.html');
+     * Example
+     * // returns 'text.html'
+     * util.truncatePath.('text.html');
+     *
+     * @param {string} path
+     * @param {number=} opt_threshold
+     * @return {string} Returns the truncated value of a URL.
+     */
+    truncatePath(path, opt_threshold) {
+      const threshold = opt_threshold || 1;
+      const pathPieces = path.split('/');
+
+      if (pathPieces.length <= threshold) { return path; }
+
+      const index = pathPieces.length - threshold;
+      // Character is an ellipsis.
+      return `\u2026/${pathPieces.slice(index).join('/')}`;
+    },
+  };
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
index 2b42587..f48fb98 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
@@ -16,25 +16,75 @@
 <!-- Polymer included for the html import polyfill. -->
 <script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
 <title>gr-path-list-behavior</title>
 
-<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-path-list-behavior.html">
 
 <script>
-  suite('gr-path-list-behavior tests', function() {
-    test('special sort', function() {
-      var sort = Gerrit.PathListBehavior.specialFilePathCompare;
-      var testFiles = [
+  suite('gr-path-list-behavior tests', () => {
+    test('special sort', () => {
+      const sort = Gerrit.PathListBehavior.specialFilePathCompare;
+      const testFiles = [
         '/a.h',
         '/MERGE_LIST',
         '/a.cpp',
         '/COMMIT_MSG',
         '/asdasd',
-        '/mrPeanutbutter.py'
+        '/mrPeanutbutter.py',
       ];
-      assert.deepEqual(testFiles.sort(sort),
-          ['/COMMIT_MSG', '/MERGE_LIST', '/a.h', '/a.cpp', '/asdasd', '/mrPeanutbutter.py']);
+      assert.deepEqual(
+          testFiles.sort(sort),
+          [
+            '/COMMIT_MSG',
+            '/MERGE_LIST',
+            '/a.h',
+            '/a.cpp',
+            '/asdasd',
+            '/mrPeanutbutter.py',
+          ]);
+    });
+
+    test('file display name', () => {
+      const name = Gerrit.PathListBehavior.computeDisplayPath;
+      assert.equal(name('/foo/bar/baz'), '/foo/bar/baz');
+      assert.equal(name('/foobarbaz'), '/foobarbaz');
+      assert.equal(name('/COMMIT_MSG'), 'Commit message');
+      assert.equal(name('/MERGE_LIST'), 'Merge list');
+    });
+
+    test('truncatePath with long path should add ellipsis', () => {
+      const truncatePath = Gerrit.PathListBehavior.truncatePath;
+      let path = 'level1/level2/level3/level4/file.js';
+      let shortenedPath = truncatePath(path);
+      // The expected path is truncated with an ellipsis.
+      const expectedPath = '\u2026/file.js';
+      assert.equal(shortenedPath, expectedPath);
+
+      path = 'level2/file.js';
+      shortenedPath = truncatePath(path);
+      assert.equal(shortenedPath, expectedPath);
+    });
+
+    test('truncatePath with opt_threshold', () => {
+      const truncatePath = Gerrit.PathListBehavior.truncatePath;
+      let path = 'level1/level2/level3/level4/file.js';
+      let shortenedPath = truncatePath(path, 2);
+      // The expected path is truncated with an ellipsis.
+      const expectedPath = '\u2026/level4/file.js';
+      assert.equal(shortenedPath, expectedPath);
+
+      path = 'level2/file.js';
+      shortenedPath = truncatePath(path, 2);
+      assert.equal(shortenedPath, path);
+    });
+
+    test('truncatePath with short path should not add ellipsis', () => {
+      const truncatePath = Gerrit.PathListBehavior.truncatePath;
+      const path = 'file.js';
+      const expectedPath = 'file.js';
+      const shortenedPath = truncatePath(path);
+      assert.equal(shortenedPath, expectedPath);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
index 6b35328..3e9e19e 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html
@@ -15,4 +15,6 @@
 -->
 <link rel="import" href="../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../elements/shared/gr-tooltip/gr-tooltip.html">
+<script src="../../scripts/rootElement.js"></script>
+
 <script src="gr-tooltip-behavior.js"></script>
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
index e4c4d11..7da82b7 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior.js
@@ -14,40 +14,49 @@
 (function(window) {
   'use strict';
 
-  var BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
+  const BOTTOM_OFFSET = 7.2; // Height of the arrow in tooltip.
+
+  window.Gerrit = window.Gerrit || {};
 
   /** @polymerBehavior Gerrit.TooltipBehavior */
-  var TooltipBehavior = {
+  Gerrit.TooltipBehavior = {
 
     properties: {
-      hasTooltip: Boolean,
+      hasTooltip: {
+        type: Boolean,
+        observer: '_setupTooltipListeners',
+      },
 
       _isTouchDevice: {
         type: Boolean,
-        value: function() {
+        value() {
           return 'ontouchstart' in document.documentElement;
         },
       },
-      _tooltip: Element,
+      _tooltip: Object,
       _titleText: String,
+      _hasSetupTooltipListeners: {
+        type: Boolean,
+        value: false,
+      },
     },
 
-    attached: function() {
-      if (!this.hasTooltip) { return; }
-
-      this.addEventListener('mouseenter', this._handleShowTooltip.bind(this));
-      this.addEventListener('mouseleave', this._handleHideTooltip.bind(this));
-      this.addEventListener('tap', this._handleHideTooltip.bind(this));
-
-      this.listen(window, 'scroll', '_handleWindowScroll');
-    },
-
-    detached: function() {
+    detached() {
       this._handleHideTooltip();
       this.unlisten(window, 'scroll', '_handleWindowScroll');
     },
 
-    _handleShowTooltip: function(e) {
+    _setupTooltipListeners() {
+      if (this._hasSetupTooltipListeners || !this.hasTooltip) { return; }
+      this._hasSetupTooltipListeners = true;
+
+      this.addEventListener('mouseenter', this._handleShowTooltip.bind(this));
+      this.addEventListener('mouseleave', this._handleHideTooltip.bind(this));
+      this.addEventListener('tap', this._handleHideTooltip.bind(this));
+      this.listen(window, 'scroll', '_handleWindowScroll');
+    },
+
+    _handleShowTooltip(e) {
       if (this._isTouchDevice) { return; }
 
       if (!this.hasAttribute('title') ||
@@ -61,21 +70,21 @@
       this._titleText = this.getAttribute('title');
       this.setAttribute('title', '');
 
-      var tooltip = document.createElement('gr-tooltip');
+      const tooltip = document.createElement('gr-tooltip');
       tooltip.text = this._titleText;
       tooltip.maxWidth = this.getAttribute('max-width');
 
       // Set visibility to hidden before appending to the DOM so that
       // calculations can be made based on the element’s size.
       tooltip.style.visibility = 'hidden';
-      Polymer.dom(document.body).appendChild(tooltip);
+      Gerrit.getRootElement().appendChild(tooltip);
       this._positionTooltip(tooltip);
       tooltip.style.visibility = null;
 
       this._tooltip = tooltip;
     },
 
-    _handleHideTooltip: function(e) {
+    _handleHideTooltip(e) {
       if (this._isTouchDevice) { return; }
       if (!this.hasAttribute('title') ||
           this._titleText == null) {
@@ -89,19 +98,23 @@
       this._tooltip = null;
     },
 
-    _handleWindowScroll: function(e) {
+    _handleWindowScroll(e) {
       if (!this._tooltip) { return; }
 
       this._positionTooltip(this._tooltip);
     },
 
-    _positionTooltip: function(tooltip) {
-      var rect = this.getBoundingClientRect();
-      var boxRect = tooltip.getBoundingClientRect();
-      var parentRect = tooltip.parentElement.getBoundingClientRect();
-      var top = rect.top - parentRect.top;
-      var left = rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
-      var right = parentRect.width - left - boxRect.width;
+    _positionTooltip(tooltip) {
+      // This flush is needed for tooltips to be positioned correctly in Firefox
+      // and Safari.
+      Polymer.dom.flush();
+      const rect = this.getBoundingClientRect();
+      const boxRect = tooltip.getBoundingClientRect();
+      const parentRect = tooltip.parentElement.getBoundingClientRect();
+      const top = rect.top - parentRect.top;
+      const left =
+          rect.left - parentRect.left + (rect.width - boxRect.width) / 2;
+      const right = parentRect.width - left - boxRect.width;
       if (left < 0) {
         tooltip.updateStyles({
           '--gr-tooltip-arrow-center-offset': left + 'px',
@@ -117,7 +130,4 @@
           'px))';
     },
   };
-
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.TooltipBehavior = TooltipBehavior;
 })(window);
diff --git a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
index 99bfc03..f442c43 100644
--- a/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/gr-tooltip-behavior/gr-tooltip-behavior_test.html
@@ -19,8 +19,7 @@
 
 <script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../test/common-test-setup.html"/>
 <link rel="import" href="gr-tooltip-behavior.html">
 
 <script>void(0);</script>
@@ -32,22 +31,22 @@
 </test-fixture>
 
 <script>
-  suite('gr-tooltip-behavior tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-tooltip-behavior tests', () => {
+    let element;
+    let sandbox;
 
     function makeTooltip(tooltipRect, parentRect) {
       return {
-        getBoundingClientRect: function() { return tooltipRect; },
+        getBoundingClientRect() { return tooltipRect; },
         updateStyles: sinon.stub(),
         style: {left: 0, top: 0},
         parentElement: {
-          getBoundingClientRect: function() { return parentRect; },
+          getBoundingClientRect() { return parentRect; },
         },
       };
     }
 
-    suiteSetup(function() {
+    suiteSetup(() => {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'tooltip-behavior-element',
@@ -55,20 +54,20 @@
       });
     });
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('normal position', function() {
-      sandbox.stub(element, 'getBoundingClientRect', function() {
+    test('normal position', () => {
+      sandbox.stub(element, 'getBoundingClientRect', () => {
         return {top: 100, left: 100, width: 200};
       });
-      var tooltip = makeTooltip(
+      const tooltip = makeTooltip(
           {height: 30, width: 50},
           {top: 0, left: 0, width: 1000});
 
@@ -78,45 +77,51 @@
       assert.equal(tooltip.style.top, '100px');
     });
 
-    test('left side position', function() {
-      sandbox.stub(element, 'getBoundingClientRect', function() {
+    test('left side position', () => {
+      sandbox.stub(element, 'getBoundingClientRect', () => {
         return {top: 100, left: 10, width: 50};
       });
-      var tooltip = makeTooltip(
+      const tooltip = makeTooltip(
           {height: 30, width: 120},
           {top: 0, left: 0, width: 1000});
 
       element._positionTooltip(tooltip);
       assert.isTrue(tooltip.updateStyles.called);
-      var offset = tooltip.updateStyles
+      const offset = tooltip.updateStyles
           .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
       assert.isBelow(parseFloat(offset.replace(/px$/, '')), 0);
       assert.equal(tooltip.style.left, '0px');
       assert.equal(tooltip.style.top, '100px');
     });
 
-    test('right side position', function() {
-      sandbox.stub(element, 'getBoundingClientRect', function() {
+    test('right side position', () => {
+      sandbox.stub(element, 'getBoundingClientRect', () => {
         return {top: 100, left: 950, width: 50};
       });
-      var tooltip = makeTooltip(
+      const tooltip = makeTooltip(
           {height: 30, width: 120},
           {top: 0, left: 0, width: 1000});
 
       element._positionTooltip(tooltip);
       assert.isTrue(tooltip.updateStyles.called);
-      var offset = tooltip.updateStyles
+      const offset = tooltip.updateStyles
           .lastCall.args[0]['--gr-tooltip-arrow-center-offset'];
       assert.isAbove(parseFloat(offset.replace(/px$/, '')), 0);
       assert.equal(tooltip.style.left, '915px');
       assert.equal(tooltip.style.top, '100px');
     });
 
-    test('hides tooltip when detached', function() {
+    test('hides tooltip when detached', () => {
       sandbox.stub(element, '_handleHideTooltip');
       element.remove();
       flushAsynchronousOperations();
       assert.isTrue(element._handleHideTooltip.called);
     });
+
+    test('sets up listeners when has-tooltip is changed', () => {
+      const addListenerStub = sandbox.stub(element, 'addEventListener');
+      element.hasTooltip = true;
+      assert.isTrue(addListenerStub.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
index b7d71fc..99c7c16 100644
--- a/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-url-encoding-behavior.html
@@ -17,15 +17,17 @@
 (function(window) {
   'use strict';
 
+  window.Gerrit = window.Gerrit || {};
+
   /** @polymerBehavior Gerrit.URLEncodingBehavior */
-  var URLEncodingBehavior = {
+  Gerrit.URLEncodingBehavior = {
     /**
      * Pretty-encodes a URL. Double-encodes the string, and then replaces
      *   benevolent characters for legibility.
      */
-    encodeURL: function(url, replaceSlashes) {
+    encodeURL(url, replaceSlashes) {
       // @see Issue 4255 regarding double-encoding.
-      var output = encodeURIComponent(encodeURIComponent(url));
+      let output = encodeURIComponent(encodeURIComponent(url));
       // @see Issue 4577 regarding more readable URLs.
       output = output.replace(/%253A/g, ':');
       output = output.replace(/%2520/g, '+');
@@ -35,8 +37,5 @@
       return output;
     },
   };
-
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.URLEncodingBehavior = URLEncodingBehavior;
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
index 3d99cec..bd996760 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html
@@ -20,35 +20,53 @@
 (function(window) {
   'use strict';
 
-  var getKeyboardEvent = function(e) {
-    return Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
+  // Must be declared outside behavior implementation to be accessed inside
+  // behavior functions.
+
+  /** @return {!Object} */
+  const getKeyboardEvent = function(e) {
+    e = Polymer.dom(e.detail ? e.detail.keyboardEvent : e);
+    // When e is a keyboardEvent, e.event is not null.
+    if (e.event) { e = e.event; }
+    return e;
   };
 
-  var KeyboardShortcutBehaviorImpl = {
-    modifierPressed: function(e) {
+  window.Gerrit = window.Gerrit || {};
+
+  /** @polymerBehavior KeyboardShortcutBehavior */
+  Gerrit.KeyboardShortcutBehavior = [{
+    modifierPressed(e) {
       e = getKeyboardEvent(e);
-      // When e is a keyboardEvent, e.event is not null.
-      if (e.event) { e = e.event; }
       return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey;
     },
 
-    shouldSuppressKeyboardShortcut: function(e) {
+    isModifierPressed(e, modifier) {
+      return getKeyboardEvent(e)[modifier];
+    },
+
+    shouldSuppressKeyboardShortcut(e) {
       e = getKeyboardEvent(e);
-      if (e.path[0].tagName === 'INPUT' || e.path[0].tagName === 'TEXTAREA') {
+      const tagName = Polymer.dom(e).rootTarget.tagName;
+      if (tagName === 'INPUT' || tagName === 'TEXTAREA') {
         return true;
       }
-      for (var i = 0; i < e.path.length; i++) {
+      for (let i = 0; e.path && i < e.path.length; i++) {
         if (e.path[i].tagName === 'GR-OVERLAY') { return true; }
       }
       return false;
     },
-  };
 
-  window.Gerrit = window.Gerrit || {};
-  /** @polymerBehavior Gerrit.KeyboardShortcutBehavior */
-  window.Gerrit.KeyboardShortcutBehavior = [
+    // Alias for getKeyboardEvent.
+    /** @return {!Object} */
+    getKeyboardEvent(e) {
+      return getKeyboardEvent(e);
+    },
+
+    getRootTarget(e) {
+      return Polymer.dom(getKeyboardEvent(e)).rootTarget;
+    },
+  },
     Polymer.IronA11yKeysBehavior,
-    KeyboardShortcutBehaviorImpl,
   ];
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
index 9ede5d9..6906ea2 100644
--- a/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../test/common-test-setup.html"/>
 <link rel="import" href="keyboard-shortcut-behavior.html">
 
 <test-fixture id="basic">
@@ -39,77 +38,77 @@
 </test-fixture>
 
 <script>
-  suite('keyboard-shortcut-behavior tests', function() {
-    var element;
-    var overlay;
-    var sandbox;
+  suite('keyboard-shortcut-behavior tests', () => {
+    let element;
+    let overlay;
+    let sandbox;
 
-    suiteSetup(function() {
+    suiteSetup(() => {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
         behaviors: [Gerrit.KeyboardShortcutBehavior],
         keyBindings: {
-          'k': '_handleKey'
+          k: '_handleKey',
         },
-        _handleKey: function() {},
+        _handleKey() {},
       });
     });
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       overlay = fixture('within-overlay');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('doesn’t block kb shortcuts for non-whitelisted els', function(done) {
-      var divEl = document.createElement('div');
+    test('doesn’t block kb shortcuts for non-whitelisted els', done => {
+      const divEl = document.createElement('div');
       element.appendChild(divEl);
-      element._handleKey = function(e) {
+      element._handleKey = e => {
         assert.isFalse(element.shouldSuppressKeyboardShortcut(e));
         done();
       };
       MockInteractions.keyDownOn(divEl, 75, null, 'k');
     });
 
-    test('blocks kb shortcuts for input els', function(done) {
-      var inputEl = document.createElement('input');
+    test('blocks kb shortcuts for input els', done => {
+      const inputEl = document.createElement('input');
       element.appendChild(inputEl);
-      element._handleKey = function(e) {
+      element._handleKey = e => {
         assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
         done();
       };
       MockInteractions.keyDownOn(inputEl, 75, null, 'k');
     });
 
-    test('blocks kb shortcuts for textarea els', function(done) {
-      var textareaEl = document.createElement('textarea');
+    test('blocks kb shortcuts for textarea els', done => {
+      const textareaEl = document.createElement('textarea');
       element.appendChild(textareaEl);
-      element._handleKey = function(e) {
+      element._handleKey = e => {
         assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
         done();
       };
       MockInteractions.keyDownOn(textareaEl, 75, null, 'k');
     });
 
-    test('blocks kb shortcuts for anything in a gr-overlay', function(done) {
-      var divEl = document.createElement('div');
-      var element = overlay.querySelector('test-element');
+    test('blocks kb shortcuts for anything in a gr-overlay', done => {
+      const divEl = document.createElement('div');
+      const element = overlay.querySelector('test-element');
       element.appendChild(divEl);
-      element._handleKey = function(e) {
+      element._handleKey = e => {
         assert.isTrue(element.shouldSuppressKeyboardShortcut(e));
         done();
       };
       MockInteractions.keyDownOn(divEl, 75, null, 'k');
     });
 
-    test('modifierPressed returns accurate values', function() {
-      var spy = sandbox.spy(element, 'modifierPressed');
-      element._handleKey = function(e) {
+    test('modifierPressed returns accurate values', () => {
+      const spy = sandbox.spy(element, 'modifierPressed');
+      element._handleKey = e => {
         element.modifierPressed(e);
       };
       MockInteractions.keyDownOn(element, 75, 'shift', 'k');
@@ -127,5 +126,26 @@
       MockInteractions.keyDownOn(element, 75, 'alt', 'k');
       assert.isTrue(spy.lastCall.returnValue);
     });
+
+    test('isModifierPressed returns accurate value', () => {
+      const spy = sandbox.spy(element, 'isModifierPressed');
+      element._handleKey = e => {
+        element.isModifierPressed(e, 'shiftKey');
+      };
+      MockInteractions.keyDownOn(element, 75, 'shift', 'k');
+      assert.isTrue(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'ctrl', 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'meta', 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, null, 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+      MockInteractions.keyDownOn(element, 75, 'alt', 'k');
+      assert.isFalse(spy.lastCall.returnValue);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
index f71fe8f..a6106e4 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior.html
@@ -19,8 +19,10 @@
 (function(window) {
   'use strict';
 
+  window.Gerrit = window.Gerrit || {};
+
   /** @polymerBehavior Gerrit.RESTClientBehavior */
-  var RESTClientBehavior = {
+  Gerrit.RESTClientBehavior = [{
     ChangeDiffType: {
       ADDED: 'ADDED',
       COPIED: 'COPIED',
@@ -32,7 +34,6 @@
 
     ChangeStatus: {
       ABANDONED: 'ABANDONED',
-      DRAFT: 'DRAFT',
       MERGED: 'MERGED',
       NEW: 'NEW',
     },
@@ -88,56 +89,52 @@
       REVIEWER_UPDATES: 19,
 
       // Set the submittable boolean.
-      SUBMITTABLE: 20
+      SUBMITTABLE: 20,
     },
 
-    listChangesOptionsToHex: function() {
-      var v = 0;
-      for (var i = 0; i < arguments.length; i++) {
-        v |= 1 << arguments[i];
+    listChangesOptionsToHex(...args) {
+      let v = 0;
+      for (let i = 0; i < args.length; i++) {
+        v |= 1 << args[i];
       }
       return v.toString(16);
     },
 
-    changeBaseURL: function(changeNum, patchNum) {
-      var v =  this.getBaseUrl() + '/changes/' + changeNum;
+    /**
+     *  @return {string}
+     */
+    changeBaseURL(changeNum, patchNum) {
+      let v = this.getBaseUrl() + '/changes/' + changeNum;
       if (patchNum) {
         v += '/revisions/' + patchNum;
       }
       return v;
     },
 
-    changePath: function(changeNum) {
+    changePath(changeNum) {
       return this.getBaseUrl() + '/c/' + changeNum;
     },
 
-    changeIsOpen: function(status) {
-      return status === this.ChangeStatus.NEW ||
-          status === this.ChangeStatus.DRAFT;
+    changeIsOpen(status) {
+      return status === this.ChangeStatus.NEW;
     },
 
-    changeStatusString: function(change) {
-      // "Closed" states should take precedence over "open" ones.
+    changeStatusString(change) {
+      const states = [];
       if (change.status === this.ChangeStatus.MERGED) {
-        return 'Merged';
+        states.push('Merged');
+      } else if (change.status === this.ChangeStatus.ABANDONED) {
+        states.push('Abandoned');
+      } else if (change.mergeable === false) {
+        // 'mergeable' prop may not always exist (@see Issue 6819)
+        states.push('Merge Conflict');
       }
-      if (change.status === this.ChangeStatus.ABANDONED) {
-        return 'Abandoned';
-      }
-      if (change.mergeable === false) {
-        return 'Merge Conflict';
-      }
-      if (change.status === this.ChangeStatus.DRAFT) {
-        return 'Draft';
-      }
-      return '';
+      if (change.work_in_progress) { states.push('WIP'); }
+      if (change.is_private) { states.push('Private'); }
+      return states.join(', ');
     },
-  };
-
-  window.Gerrit = window.Gerrit || {};
-  window.Gerrit.RESTClientBehavior = [
+  },
     Gerrit.BaseUrlBehavior,
-    RESTClientBehavior
   ];
 })(window);
 </script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
index 2b3e858..968b855 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior/rest-client-behavior_test.html
@@ -20,11 +20,12 @@
 
 <script src="../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
 <script src="../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../test/common-test-setup.html"/>
 <script>
+  /** @type {string} */
   window.CANONICAL_PATH = '/r';
 </script>
 
-<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="../base-url-behavior/base-url-behavior.html">
 <link rel="import" href="rest-client-behavior.html">
 
@@ -43,11 +44,12 @@
 </test-fixture>
 
 <script>
-  suite('rest-client-behavior tests', function() {
-    var element;
-    var overlay;
+  suite('rest-client-behavior tests', () => {
+    let element;
+    // eslint-disable-next-line no-unused-vars
+    let overlay;
 
-    suiteSetup(function() {
+    suiteSetup(() => {
       // Define a Polymer element that uses this behavior.
       Polymer({
         is: 'test-element',
@@ -58,20 +60,126 @@
       });
     });
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       overlay = fixture('within-overlay');
     });
 
-    test('changeBaseURL', function() {
+    test('changeBaseURL', () => {
       assert.deepEqual(
-        element.changeBaseURL('1', '1'),
-        '/r/changes/1/revisions/1'
+          element.changeBaseURL('1', '1'),
+          '/r/changes/1/revisions/1'
       );
     });
 
-    test('changePath', function() {
+    test('changePath', () => {
       assert.deepEqual(element.changePath('1'), '/r/c/1');
     });
+
+    test('Open status', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        mergeable: true,
+      };
+      const status = element.changeStatusString(change);
+      assert.equal(status, '');
+    });
+
+    test('Merge conflict', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        mergeable: false,
+      };
+      const status = element.changeStatusString(change);
+      assert.equal(status, 'Merge Conflict');
+    });
+
+    test('mergeable prop undefined', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+      };
+      const status = element.changeStatusString(change);
+      assert.equal(status, '');
+    });
+
+    test('Merged status', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'MERGED',
+        labels: {},
+      };
+      const status = element.changeStatusString(change);
+      assert.equal(status, 'Merged');
+    });
+
+    test('Abandoned status', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'ABANDONED',
+        labels: {},
+      };
+      const status = element.changeStatusString(change);
+      assert.equal(status, 'Abandoned');
+    });
+
+    test('Open status with private and wip', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        is_private: true,
+        work_in_progress: true,
+        labels: {},
+        mergeable: true,
+      };
+      const status = element.changeStatusString(change);
+      assert.equal(status, 'WIP, Private');
+    });
+
+    test('Merge conflict with private and wip', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        is_private: true,
+        work_in_progress: true,
+        labels: {},
+        mergeable: false,
+      };
+      const status = element.changeStatusString(change);
+      assert.equal(status, 'Merge Conflict, WIP, Private');
+    });
   });
-</script>
\ No newline at end of file
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
new file mode 100644
index 0000000..529e14c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.html
@@ -0,0 +1,141 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-permission/gr-permission.html">
+
+<script src="../../../scripts/util.js"></script>
+
+<dom-module id="gr-access-section">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        margin-bottom: 1em;
+      }
+      fieldset {
+        border: 1px solid #d1d2d3;
+      }
+      .header,
+      .editingRef .editContainer,
+      #deletedContainer {
+        align-items: baseline;
+        background: #f6f6f6;
+        border-bottom: 1px dotted #d1d2d3;
+        display: flex;
+        justify-content: space-between;
+        padding: .7em .7em;
+      }
+      #deletedContainer {
+        border-bottom: 0;
+      }
+      .sectionContent {
+        padding: .7em;
+      }
+      #deletedContainer,
+      .deleted #mainContainer,
+      .global,
+      #addPermission,
+      #updateBtns,
+      .editingRef .header,
+      .editContainer {
+        display: none;
+      }
+      .deleted #deletedContainer,
+      #mainContainer,
+      .editing #addPermission,
+      .editing #updateBtns  {
+        display: block;
+      }
+      .editingRef .editContainer {
+        display: flex;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <fieldset id="section"
+        class$="gr-form-styles [[_computeSectionClass(editing, _editingRef, _deleted)]]">
+      <div id="mainContainer">
+        <div class="header">
+          <span class="name">
+            <h3>[[_computeSectionName(section.id)]]</h3>
+          </span>
+          <div id="updateBtns">
+            <gr-button
+                id="editBtn"
+                class$="[[_computeEditBtnClass(section.id)]]"
+                on-tap="_handleEditReference">Edit Reference</gr-button>
+            <gr-button
+                id="deleteBtn"
+                on-tap="_handleRemoveReference">Remove</gr-button>
+          </div><!-- end updateBtns -->
+        </div><!-- end header -->
+        <div class="editContainer">
+          <input
+              id="editRefInput"
+              bind-value="{{section.id}}"
+              is="iron-input"
+              type="text">
+          <gr-button
+              id="undoEdit"
+              on-tap="_undoReferenceEdit">Undo</gr-button>
+        </div><!-- end editContainer -->
+        <div class="sectionContent">
+          <template
+              is="dom-repeat"
+              items="{{_permissions}}"
+              as="permission">
+            <gr-permission
+                name="[[_computePermissionName(section.id, permission, permissionValues, capabilities)]]"
+                permission="{{permission}}"
+                labels="[[labels]]"
+                section="[[section.id]]"
+                editing="[[editing]]"
+                groups="[[groups]]">
+            </gr-permission>
+          </template>
+          <div id="addPermission">
+            Add permission:
+            <select id="permissionSelect">
+              <!-- called with a third parameter so that permissions update
+                  after a new section is added. -->
+              <template
+                  is="dom-repeat"
+                  items="[[_computePermissions(section.id, capabilities, labels, section.value.permissions.*)]]">
+                <option value="[[item.value.id]]">[[item.value.name]]</option>
+              </template>
+            </select>
+            <gr-button id="addBtn" on-tap="_handleAddPermission">Add</gr-button>
+          </div><!-- end addPermission -->
+        </div><!-- end sectionContent -->
+      </div><!-- end mainContainer -->
+      <div id="deletedContainer">
+        [[_computeSectionName(section.id)]] was deleted
+        <gr-button
+            id="undoRemoveBtn"
+            on-tap="_handleUndoRemove">Undo</gr-button>
+      </div><!-- end deletedContainer -->
+    </fieldset>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-access-section.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
new file mode 100644
index 0000000..07a7a62
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section.js
@@ -0,0 +1,200 @@
+// 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.
+(function() {
+  'use strict';
+
+  const GLOBAL_NAME = 'GLOBAL_CAPABILITIES';
+
+  // The name that gets automatically input when a new reference is added.
+  const NEW_NAME = 'refs/heads/*';
+  const REFS_NAME = 'refs/';
+  const ON_BEHALF_OF = '(On Behalf Of)';
+  const LABEL = 'Label';
+
+  Polymer({
+    is: 'gr-access-section',
+
+    properties: {
+      capabilities: Object,
+      /** @type {?} */
+      section: {
+        type: Object,
+        notify: true,
+        observer: '_sectionChanged',
+      },
+      groups: Object,
+      labels: Object,
+      editing: {
+        type: Boolean,
+        value: false,
+      },
+      _originalId: String,
+      _editingRef: {
+        type: Boolean,
+        value: false,
+      },
+      _deleted: {
+        type: Boolean,
+        value: false,
+      },
+      _permissions: Array,
+    },
+
+    behaviors: [
+      Gerrit.AccessBehavior,
+    ],
+
+    _sectionChanged(section) {
+      this._permissions = this.toSortedArray(section.value.permissions);
+      this._originalId = section.id;
+    },
+
+    _computePermissions(name, capabilities, labels) {
+      let allPermissions;
+      if (name === GLOBAL_NAME) {
+        allPermissions = this.toSortedArray(capabilities);
+      } else {
+        const labelOptions = this._computeLabelOptions(labels);
+        allPermissions = labelOptions.concat(
+            this.toSortedArray(this.permissionValues));
+      }
+      return allPermissions.filter(permission => {
+        return !this.section.value.permissions[permission.id];
+      });
+    },
+
+    _computeLabelOptions(labels) {
+      const labelOptions = [];
+      for (const labelName of Object.keys(labels)) {
+        labelOptions.push({
+          id: 'label-' + labelName,
+          value: {
+            name: `${LABEL} ${labelName}`,
+            id: 'label-' + labelName,
+          },
+        });
+        labelOptions.push({
+          id: 'labelAs-' + labelName,
+          value: {
+            name: `${LABEL} ${labelName} ${ON_BEHALF_OF}`,
+            id: 'labelAs-' + labelName,
+          },
+        });
+      }
+      return labelOptions;
+    },
+
+    _computePermissionName(name, permission, permissionValues, capabilities) {
+      if (name === GLOBAL_NAME) {
+        return capabilities[permission.id].name;
+      } else if (permissionValues[permission.id]) {
+        return permissionValues[permission.id].name;
+      } else if (permission.value.label) {
+        let behalfOf = '';
+        if (permission.id.startsWith('labelAs-')) {
+          behalfOf = ON_BEHALF_OF;
+        }
+        return `${LABEL} ${permission.value.label}${behalfOf}`;
+      }
+    },
+
+    _computeSectionName(name) {
+      // When a new section is created, it doesn't yet have a ref. Set into
+      // edit mode so that the user can input one.
+      if (!name) {
+        this._editingRef = true;
+        // Needed for the title value. This is the same default as GWT.
+        name = NEW_NAME;
+        // Needed for the input field value.
+        this.set('section.id', name);
+      }
+      if (name === GLOBAL_NAME) {
+        return 'Global Capabilities';
+      } else if (name.startsWith(REFS_NAME)) {
+        return `Reference: ${name}`;
+      }
+      return name;
+    },
+
+    _handleRemoveReference() {
+      this._deleted = true;
+      this.set('section.value.deleted', true);
+    },
+
+    _handleUndoRemove() {
+      this._deleted = false;
+      delete this.section.value.deleted;
+    },
+
+    _handleEditReference() {
+      this._editingRef = true;
+    },
+
+    _undoReferenceEdit() {
+      this._editingRef = false;
+      this.set('section.id', this._originalId);
+    },
+
+    _computeSectionClass(editing, editingRef, deleted) {
+      const classList = [];
+      if (editing) {
+        classList.push('editing');
+      }
+      if (editingRef) {
+        classList.push('editingRef');
+      }
+      if (deleted) {
+        classList.push('deleted');
+      }
+      return classList.join(' ');
+    },
+
+    _computeEditBtnClass(name) {
+      return name === GLOBAL_NAME ? 'global' : '';
+    },
+
+    _handleAddPermission() {
+      const value = this.$.permissionSelect.value;
+      const permission = {
+        id: value,
+        value: {rules: {}},
+      };
+
+      // This is needed to update the 'label' property of the
+      // 'label-<label-name>' permission.
+      //
+      // The value from the add permission dropdown will either be
+      // label-<label-name> or labelAs-<labelName>.
+      // But, the format of the API response is as such:
+      // "permissions": {
+      //  "label-Code-Review": {
+      //    "label": "Code-Review",
+      //    "rules": {...}
+      //    }
+      //  }
+      // }
+      // When we add a new item, we have to push the new permission in the same
+      // format as the ones that have been returned by the API.
+      if (value.startsWith('label')) {
+        permission.value.label =
+            value.replace('label-', '').replace('labelAs-', '');
+      }
+      // Add to the end of the array (used in dom-repeat) and also to the
+      // section object that is two way bound with its parent element.
+      this.push('_permissions', permission);
+      this.set(['section.value.permissions', permission.id],
+          permission.value);
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
new file mode 100644
index 0000000..38c5d67
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-access-section/gr-access-section_test.html
@@ -0,0 +1,466 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-access-section</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-access-section.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-access-section></gr-access-section>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-access-section tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('unit tests', () => {
+      setup(() => {
+        element.section = {
+          id: 'refs/*',
+          value: {
+            permissions: {
+              read: {
+                rules: {},
+              },
+            },
+          },
+        };
+        element.capabilities = {
+          accessDatabase: {
+            id: 'accessDatabase',
+            name: 'Access Database',
+          },
+          administrateServer: {
+            id: 'administrateServer',
+            name: 'Administrate Server',
+          },
+          batchChangesLimit: {
+            id: 'batchChangesLimit',
+            name: 'Batch Changes Limit',
+          },
+          createAccount: {
+            id: 'createAccount',
+            name: 'Create Account',
+          },
+        };
+        element.labels = {
+          'Code-Review': {
+            values: {
+              ' 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',
+            },
+            default_value: 0,
+          },
+        };
+        element._sectionChanged(element.section);
+        flushAsynchronousOperations();
+      });
+
+      test('_sectionChanged', () => {
+        // _sectionChanged was called in setup, so just make assertions.
+        const expectedPermissions = [
+          {
+            id: 'read',
+            value: {
+              rules: {},
+            },
+          },
+        ];
+        assert.deepEqual(element._permissions, expectedPermissions);
+        assert.equal(element._originalId, element.section.id);
+      });
+
+      test('_computeLabelOptions', () => {
+        const expectedLabelOptions = [
+          {
+            id: 'label-Code-Review',
+            value: {
+              name: 'Label Code-Review',
+              id: 'label-Code-Review',
+            },
+          },
+          {
+            id: 'labelAs-Code-Review',
+            value: {
+              name: 'Label Code-Review (On Behalf Of)',
+              id: 'labelAs-Code-Review',
+            },
+          },
+        ];
+
+        assert.deepEqual(element._computeLabelOptions(element.labels),
+            expectedLabelOptions);
+      });
+
+      test('_computePermissions', () => {
+        sandbox.stub(element, 'toSortedArray').returns(
+            [{
+              id: 'push',
+              value: {
+                rules: {},
+              },
+            },
+            {
+              id: 'read',
+              value: {
+                rules: {},
+              },
+            },
+            ]);
+
+        const expectedPermissions = [{
+          id: 'push',
+          value: {
+            rules: {},
+          },
+        },
+        ];
+        const labelOptions = [
+          {
+            id: 'label-Code-Review',
+            value: {
+              name: 'Label Code-Review',
+              id: 'label-Code-Review',
+            },
+          },
+          {
+            id: 'labelAs-Code-Review',
+            value: {
+              name: 'Label Code-Review (On Behalf Of)',
+              id: 'labelAs-Code-Review',
+            },
+          },
+        ];
+
+        // For global capabilities, just return the sorted array filtered by
+        // existing permissions.
+        let name = 'GLOBAL_CAPABILITIES';
+        assert.deepEqual(element._computePermissions(name, element.capabilities,
+            element.labels), expectedPermissions);
+
+        // Uses the capabilities array to come up with possible values.
+        assert.isTrue(element.toSortedArray.lastCall.
+            calledWithExactly(element.capabilities));
+
+
+        // For everything else, include possible label values before filtering.
+        name = 'refs/for/*';
+        assert.deepEqual(element._computePermissions(name, element.capabilities,
+            element.labels), labelOptions.concat(expectedPermissions));
+
+        // Uses permissionValues (defined in gr-access-behavior) to come up with
+        // possible values.
+        assert.isTrue(element.toSortedArray.lastCall.
+            calledWithExactly(element.permissionValues));
+      });
+
+      test('_computePermissionName', () => {
+        let name = 'GLOBAL_CAPABILITIES';
+        let permission = {
+          id: 'administrateServer',
+          value: {},
+        };
+        assert.equal(element._computePermissionName(name, permission,
+            element.permissionValues, element.capabilities),
+            element.capabilities[permission.id].name);
+
+        name = 'refs/for/*';
+        permission = {
+          id: 'abandon',
+          value: {},
+        };
+
+        assert.equal(element._computePermissionName(
+            name, permission, element.permissionValues, element.capabilities),
+            element.permissionValues[permission.id].name);
+
+        name = 'refs/for/*';
+        permission = {
+          id: 'label-Code-Review',
+          value: {
+            label: 'Code-Review',
+          },
+        };
+
+        assert.equal(element._computePermissionName(name, permission,
+            element.permissionValues, element.capabilities),
+            'Label Code-Review');
+
+        permission = {
+          id: 'labelAs-Code-Review',
+          value: {
+            label: 'Code-Review',
+          },
+        };
+
+        assert.equal(element._computePermissionName(name, permission,
+            element.permissionValues, element.capabilities),
+            'Label Code-Review(On Behalf Of)');
+      });
+
+      test('_computeSectionName', () => {
+        let name;
+        // When computing the section name for an undefined name, it means a
+        // new section is being added. In this case, it should defualt to
+        // 'refs/heads/*'.
+        element._editingRef = false;
+        assert.equal(element._computeSectionName(name),
+            'Reference: refs/heads/*');
+        assert.isTrue(element._editingRef);
+        assert.equal(element.section.id, 'refs/heads/*');
+
+        // Reset editing to false.
+        element._editingRef = false;
+        name = 'GLOBAL_CAPABILITIES';
+        assert.equal(element._computeSectionName(name), 'Global Capabilities');
+        assert.isFalse(element._editingRef);
+
+        name = 'refs/for/*';
+        assert.equal(element._computeSectionName(name),
+            'Reference: refs/for/*');
+        assert.isFalse(element._editingRef);
+      });
+
+      test('_handleEditReference', () => {
+        element._handleEditReference();
+        assert.isTrue(element._editingRef);
+      });
+
+      test('_undoReferenceEdit', () => {
+        element._originalId = 'refs/for/old';
+        element.section.id = 'refs/for/new';
+        element.editing = true;
+        element._undoReferenceEdit();
+        assert.isFalse(element._editingRef);
+        assert.equal(element.section.id, 'refs/for/old');
+      });
+
+      test('_computeSectionClass', () => {
+        let editingRef = false;
+        let editing = false;
+        let deleted = false;
+        assert.equal(element._computeSectionClass(editing, editingRef, deleted),
+            '');
+
+        editing = true;
+        assert.equal(element._computeSectionClass(editing, editingRef, deleted),
+            'editing');
+
+        editingRef = true;
+        assert.equal(element._computeSectionClass(editing, editingRef, deleted),
+            'editing editingRef');
+
+        deleted = true;
+        assert.equal(element._computeSectionClass(editing, editingRef, deleted),
+            'editing editingRef deleted');
+
+        editingRef = false;
+        assert.equal(element._computeSectionClass(editing, editingRef, deleted),
+            'editing deleted');
+      });
+
+      test('_computeEditBtnClass', () => {
+        let name = 'GLOBAL_CAPABILITIES';
+        assert.equal(element._computeEditBtnClass(name), 'global');
+        name = 'refs/for/*';
+        assert.equal(element._computeEditBtnClass(name), '');
+      });
+    });
+
+    suite('interactive tests', () => {
+      setup(() => {
+        element.labels = {
+          'Code-Review': {
+            values: {
+              ' 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',
+            },
+            default_value: 0,
+          },
+        };
+      });
+      suite('Global section', () => {
+        setup(() => {
+          element.section = {
+            id: 'GLOBAL_CAPABILITIES',
+            value: {
+              permissions: {
+                accessDatabase: {
+                  rules: {},
+                },
+              },
+            },
+          };
+          element.capabilities = {
+            accessDatabase: {
+              id: 'accessDatabase',
+              name: 'Access Database',
+            },
+            administrateServer: {
+              id: 'administrateServer',
+              name: 'Administrate Server',
+            },
+            batchChangesLimit: {
+              id: 'batchChangesLimit',
+              name: 'Batch Changes Limit',
+            },
+            createAccount: {
+              id: 'createAccount',
+              name: 'Create Account',
+            },
+          };
+          element._sectionChanged(element.section);
+          flushAsynchronousOperations();
+        });
+
+        test('classes are assigned correctly', () => {
+          assert.isFalse(element.$.section.classList.contains('editing'));
+          assert.isFalse(element.$.section.classList.contains('deleted'));
+          assert.isTrue(element.$.editBtn.classList.contains('global'));
+        });
+      });
+
+      suite('Non-global section', () => {
+        setup(() => {
+          element.section = {
+            id: 'refs/*',
+            value: {
+              permissions: {
+                read: {
+                  rules: {},
+                },
+              },
+            },
+          };
+          element.capabilities = {};
+          element._sectionChanged(element.section);
+          flushAsynchronousOperations();
+        });
+
+        test('classes are assigned correctly', () => {
+          assert.isFalse(element.$.section.classList.contains('editing'));
+          assert.isFalse(element.$.section.classList.contains('deleted'));
+          assert.isFalse(element.$.editBtn.classList.contains('global'));
+        });
+
+        test('add permission', () => {
+          element.$.permissionSelect.value = 'label-Code-Review';
+          assert.equal(element._permissions.length, 1);
+          assert.equal(Object.keys(element.section.value.permissions).length,
+              1);
+          MockInteractions.tap(element.$.addBtn);
+          flushAsynchronousOperations();
+
+          // The permission is added to both the permissions array and also
+          // the section's permission object.
+          assert.equal(element._permissions.length, 2);
+          let permission = {
+            id: 'label-Code-Review',
+            value: {
+              label: 'Code-Review',
+              rules: {},
+            },
+          };
+          assert.equal(element._permissions.length, 2);
+          assert.deepEqual(element._permissions[1], permission);
+          assert.equal(Object.keys(element.section.value.permissions).length,
+              2);
+          assert.deepEqual(
+              element.section.value.permissions['label-Code-Review'],
+              permission.value);
+
+
+          element.$.permissionSelect.value = 'abandon';
+          MockInteractions.tap(element.$.addBtn);
+          flushAsynchronousOperations();
+
+          permission = {
+            id: 'abandon',
+            value: {
+              rules: {},
+            },
+          };
+
+          assert.equal(element._permissions.length, 3);
+          assert.deepEqual(element._permissions[2], permission);
+          assert.equal(Object.keys(element.section.value.permissions).length,
+              3);
+          assert.deepEqual(element.section.value.permissions['abandon'],
+              permission.value);
+        });
+
+        test('edit section reference', () => {
+          assert.isFalse(element.$.section.classList.contains('editing'));
+          element.editing = true;
+          assert.isTrue(element.$.section.classList.contains('editing'));
+          assert.isFalse(element._editingRef);
+          MockInteractions.tap(element.$.editBtn);
+          element.$.editRefInput.bindValue='new/ref';
+          flushAsynchronousOperations();
+          assert.equal(element.section.id, 'new/ref');
+          assert.isTrue(element._editingRef);
+          assert.isTrue(element.$.section.classList.contains('editingRef'));
+          MockInteractions.tap(element.$.undoEdit);
+          flushAsynchronousOperations();
+          assert.isFalse(element._editingRef);
+          assert.isFalse(element.$.section.classList.contains('editingRef'));
+          assert.equal(element.section.id, 'refs/*');
+        });
+
+        test('remove section', () => {
+          element.editing = true;
+          assert.isFalse(element._deleted);
+          MockInteractions.tap(element.$.deleteBtn);
+          flushAsynchronousOperations();
+          assert.isTrue(element._deleted);
+          assert.isTrue(element.$.section.classList.contains('deleted'));
+          assert.isTrue(element.section.value.deleted);
+
+          MockInteractions.tap(element.$.undoRemoveBtn);
+          flushAsynchronousOperations();
+          assert.isFalse(element._deleted);
+          assert.isNotOk(element.section.value.deleted);
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
new file mode 100644
index 0000000..efacb1e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-create-group-dialog/gr-create-group-dialog.html">
+
+<dom-module id="gr-admin-group-list">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-table-styles"></style>
+    <gr-list-view
+        create-new="[[_createNewCapability]]"
+        filter="[[_filter]]"
+        items="[[_groups]]"
+        items-per-page="[[_groupsPerPage]]"
+        loading="[[_loading]]"
+        offset="[[_offset]]"
+        on-create-clicked="_handleCreateClicked"
+        path="[[_path]]">
+      <table id="list" class="genericList">
+        <tr class="headerRow">
+          <th class="name topHeader">Group Name</th>
+          <th class="description topHeader">Group Description</th>
+          <th class="visibleToAll topHeader">Visible To All</th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+        <tbody class$="[[computeLoadingClass(_loading)]]">
+          <template is="dom-repeat" items="[[_shownGroups]]">
+            <tr class="table">
+              <td class="name">
+                <a href$="[[_computeGroupUrl(item.group_id)]]">[[item.name]]</a>
+              </td>
+              <td class="description">[[item.description]]</td>
+              <td class="visibleToAll">[[_visibleToAll(item)]]</td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </gr-list-view>
+    <gr-overlay id="createOverlay" with-backdrop>
+      <gr-confirm-dialog
+          id="createDialog"
+          class="confirmDialog"
+          disabled="[[!_hasNewGroupName]]"
+          confirm-label="Create"
+          on-confirm="_handleCreateGroup"
+          on-cancel="_handleCloseCreate">
+        <div class="header">
+          Create Group
+        </div>
+        <div class="main">
+          <gr-create-group-dialog
+              has-new-group-name="{{_hasNewGroupName}}"
+              params="[[params]]"
+              id="createNewModal"></gr-create-group-dialog>
+        </div>
+      </gr-confirm-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-admin-group-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
new file mode 100644
index 0000000..72439e2
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list.js
@@ -0,0 +1,144 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-admin-group-list',
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+      _path: {
+        type: String,
+        readOnly: true,
+        value: '/admin/groups',
+      },
+      _hasNewGroupName: Boolean,
+      _createNewCapability: {
+        type: Boolean,
+        value: false,
+      },
+      _groups: Array,
+
+      /**
+       * Because  we request one more than the groupsPerPage, _shownGroups
+       * may be one less than _groups.
+       * */
+      _shownGroups: {
+        type: Array,
+        computed: 'computeShownItems(_groups)',
+      },
+
+      _groupsPerPage: {
+        type: Number,
+        value: 25,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: String,
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+    ],
+
+    attached() {
+      this._getCreateGroupCapability();
+      this.fire('title-change', {title: 'Groups'});
+      this._maybeOpenCreateOverlay(this.params);
+    },
+
+    _paramsChanged(params) {
+      this._loading = true;
+      this._filter = this.getFilterValue(params);
+      this._offset = this.getOffsetValue(params);
+
+      return this._getGroups(this._filter, this._groupsPerPage,
+          this._offset);
+    },
+
+    /**
+     * Opens the create overlay if the route has a hash 'create'
+     * @param {!Object} params
+     */
+    _maybeOpenCreateOverlay(params) {
+      if (params && params.openCreateModal) {
+        this.$.createOverlay.open();
+      }
+    },
+
+    _computeGroupUrl(id) {
+      return this.getUrl(this._path + '/', id);
+    },
+
+    _getCreateGroupCapability() {
+      return this.$.restAPI.getAccount().then(account => {
+        if (!account) { return; }
+        return this.$.restAPI.getAccountCapabilities(['createGroup'])
+            .then(capabilities => {
+              if (capabilities.createGroup) {
+                this._createNewCapability = true;
+              }
+            });
+      });
+    },
+
+    _getGroups(filter, groupsPerPage, offset) {
+      this._groups = [];
+      return this.$.restAPI.getGroups(filter, groupsPerPage, offset)
+          .then(groups => {
+            if (!groups) {
+              return;
+            }
+            this._groups = Object.keys(groups)
+             .map(key => {
+               const group = groups[key];
+               group.name = key;
+               return group;
+             });
+            this._loading = false;
+          });
+    },
+
+    _handleCreateGroup() {
+      this.$.createNewModal.handleCreateGroup();
+    },
+
+    _handleCloseCreate() {
+      this.$.createOverlay.close();
+    },
+
+    _handleCreateClicked() {
+      this.$.createOverlay.open();
+    },
+
+    _visibleToAll(item) {
+      return item.options.visible_to_all === true ? 'Y' : 'N';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
new file mode 100644
index 0000000..06428c7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-group-list/gr-admin-group-list_test.html
@@ -0,0 +1,183 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-admin-group-list</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-admin-group-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-admin-group-list></gr-admin-group-list>
+  </template>
+</test-fixture>
+
+<script>
+  let counter = 0;
+  const groupGenerator = () => {
+    return {
+      name: `test${++counter}`,
+      id: '59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+      url: '#/admin/groups/uuid-59b92f35489e62c80d1ab1bf0c2d17843038df8b',
+      options: {
+        visible_to_all: false,
+      },
+      description: 'Gerrit Site Administrators',
+      group_id: 1,
+      owner: 'Administrators',
+      owner_id: '7ca042f4d5847936fcb90ca91057673157fd06fc',
+    };
+  };
+
+  suite('gr-admin-group-list tests', () => {
+    let element;
+    let groups;
+    let sandbox;
+    let value;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list with groups', () => {
+      setup(done => {
+        groups = _.times(26, groupGenerator);
+
+        stub('gr-rest-api-interface', {
+          getGroups(num, offset) {
+            return Promise.resolve(groups);
+          },
+        });
+
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('test for test group in the list', done => {
+        flush(() => {
+          assert.equal(element._groups[1].name, '1');
+          assert.equal(element._groups[1].options.visible_to_all, false);
+          done();
+        });
+      });
+
+      test('_shownGroups', () => {
+        assert.equal(element._shownGroups.length, 25);
+      });
+
+      test('_maybeOpenCreateOverlay', () => {
+        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+        element._maybeOpenCreateOverlay();
+        assert.isFalse(overlayOpen.called);
+        const params = {};
+        element._maybeOpenCreateOverlay(params);
+        assert.isFalse(overlayOpen.called);
+        params.openCreateModal = true;
+        element._maybeOpenCreateOverlay(params);
+        assert.isTrue(overlayOpen.called);
+      });
+    });
+
+    suite('test with less then 25 groups', () => {
+      setup(done => {
+        groups = _.times(25, groupGenerator);
+
+        stub('gr-rest-api-interface', {
+          getGroups(num, offset) {
+            return Promise.resolve(groups);
+          },
+        });
+
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('_shownGroups', () => {
+        assert.equal(element._shownGroups.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getGroups', () => {
+          return Promise.resolve(groups);
+        });
+        const value = {
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(value).then(() => {
+          assert.isTrue(element.$.restAPI.getGroups.lastCall
+              .calledWithExactly('test', 25, 25));
+          done();
+        });
+      });
+    });
+
+    suite('loading', () => {
+      test('correct contents are displayed', () => {
+        assert.isTrue(element._loading);
+        assert.equal(element.computeLoadingClass(element._loading), 'loading');
+        assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+        element._loading = false;
+        element._groups = _.times(25, groupGenerator);
+
+        flushAsynchronousOperations();
+        assert.equal(element.computeLoadingClass(element._loading), '');
+        assert.equal(getComputedStyle(element.$.loading).display, 'none');
+      });
+    });
+
+    suite('create new', () => {
+      test('_handleCreateClicked called when create-click fired', () => {
+        sandbox.stub(element, '_handleCreateClicked');
+        element.$$('gr-list-view').fire('create-clicked');
+        assert.isTrue(element._handleCreateClicked.called);
+      });
+
+      test('_handleCreateClicked opens modal', () => {
+        const openStub = sandbox.stub(element.$.createOverlay, 'open');
+        element._handleCreateClicked();
+        assert.isTrue(openStub.called);
+      });
+
+      test('_handleCreateGroup called when confirm fired', () => {
+        sandbox.stub(element, '_handleCreateGroup');
+        element.$.createDialog.fire('confirm');
+        assert.isTrue(element._handleCreateGroup.called);
+      });
+
+      test('_handleCloseCreate called when cancel fired', () => {
+        sandbox.stub(element, '_handleCloseCreate');
+        element.$.createDialog.fire('cancel');
+        assert.isTrue(element._handleCloseCreate.called);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
index 527485d..951df45 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.html
@@ -15,11 +15,127 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../styles/gr-menu-page-styles.html">
+<link rel="import" href="../../../styles/gr-page-nav-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-page-nav/gr-page-nav.html">
 <link rel="import" href="../../shared/gr-placeholder/gr-placeholder.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-admin-group-list/gr-admin-group-list.html">
+<link rel="import" href="../gr-group/gr-group.html">
+<link rel="import" href="../gr-group-audit-log/gr-group-audit-log.html">
+<link rel="import" href="../gr-group-members/gr-group-members.html">
+<link rel="import" href="../gr-plugin-list/gr-plugin-list.html">
+<link rel="import" href="../gr-project/gr-project.html">
+<link rel="import" href="../gr-project-access/gr-project-access.html">
+<link rel="import" href="../gr-project-commands/gr-project-commands.html">
+<link rel="import" href="../gr-project-detail-list/gr-project-detail-list.html">
+<link rel="import" href="../gr-project-list/gr-project-list.html">
 
 <dom-module id="gr-admin-view">
   <template>
-    <gr-placeholder title="Admin" path="[[path]]"></gr-placeholder>
+    <style include="shared-styles"></style>
+    <style include="gr-menu-page-styles"></style>
+    <style include="gr-page-nav-styles"></style>
+    <gr-page-nav class="navStyles">
+      <ul class="sectionContent">
+        <template id="adminNav" is="dom-repeat" items="[[_filteredLinks]]">
+          <li class$="sectionTitle [[_computeSelectedClass(item.view, params)]]">
+            <a class="title" href="[[_computeLinkURL(item)]]"
+                  rel="noopener">[[item.name]]</a>
+          </li>
+          <template is="dom-repeat" items="[[item.children]]" as="child">
+            <li class$="[[_computeSelectedClass(child.view, params)]]">
+              <a href$="[[_computeLinkURL(child)]]"
+                  rel="noopener">[[child.name]]</a>
+            </li>
+          </template>
+          <template is="dom-if" if="[[item.subsection]]">
+            <!--If a section has a subsection, render that.-->
+            <li class$="[[_computeSelectedClass(item.subsection.view, params)]]">
+              <a class="title" href$="[[_computeLinkURL(item.subsection)]]"
+                  rel="noopener">
+                [[item.subsection.name]]</a>
+            </li>
+            <!--Loop through the links in the sub-section.-->
+            <template is="dom-repeat"
+                items="[[item.subsection.children]]" as="child">
+              <li class$="subsectionItem [[_computeSelectedClass(child.view, params, child.detailType)]]">
+                <a href$="[[_computeLinkURL(child)]]">[[child.name]]</a>
+              </li>
+            </template>
+          </template>
+        </template>
+      </ul>
+    </gr-page-nav>
+    <template is="dom-if" if="[[_showProjectList]]" restamp="true">
+      <main class="table">
+        <gr-project-list class="table" params="[[params]]"></gr-project-list>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showProjectMain]]" restamp="true">
+      <main>
+        <gr-project project="[[params.project]]"></gr-project>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showGroup]]" restamp="true">
+      <main>
+        <gr-group
+            group-id="[[params.groupId]]"
+            on-name-changed="_updateGroupName"></gr-group>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showGroupMembers]]" restamp="true">
+      <main>
+        <gr-group-members
+            group-id="[[params.groupId]]"></gr-group-members>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showGroupList]]" restamp="true">
+      <main class="table">
+        <gr-admin-group-list class="table" params="[[params]]">
+        </gr-admin-group-list>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showPluginList]]" restamp="true">
+      <main class="table">
+        <gr-plugin-list class="table" params="[[params]]"></gr-plugin-list>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showProjectDetailList]]" restamp="true">
+      <main class="table">
+        <gr-project-detail-list
+            params="[[params]]"
+            class="table"></gr-project-detail-list>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showGroupAuditLog]]" restamp="true">
+      <main class="table">
+        <gr-group-audit-log
+            group-id="[[params.groupId]]"
+            class="table"></gr-group-audit-log>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showProjectCommands]]" restamp="true">
+      <main>
+        <gr-project-commands
+            project="[[params.project]]"></gr-project-commands>
+      </main>
+    </template>
+    <template is="dom-if" if="[[_showProjectAccess]]" restamp="true">
+      <main class="table">
+        <gr-project-access
+            path="[[path]]"
+            project="[[params.project]]"></gr-project-access>
+      </main>
+    </template>
+    <template is="dom-if" if="[[params.placeholder]]" restamp="true">
+      <gr-placeholder title="Admin" path="[[path]]"></gr-placeholder>
+    </template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-admin-view.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
index cb248e1..ae28a05 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.js
@@ -14,11 +14,258 @@
 (function() {
   'use strict';
 
+  const ADMIN_LINKS = [{
+    name: 'Projects',
+    url: '/admin/projects',
+    view: 'gr-project-list',
+    viewableToAll: true,
+    children: [],
+  }, {
+    name: 'Groups',
+    section: 'Groups',
+    url: '/admin/groups',
+    view: 'gr-admin-group-list',
+    children: [],
+  }, {
+    name: 'Plugins',
+    capability: 'viewPlugins',
+    section: 'Plugins',
+    url: '/admin/plugins',
+    view: 'gr-plugin-list',
+  }];
+
+  const ACCOUNT_CAPABILITIES = ['createProject', 'createGroup', 'viewPlugins'];
+
   Polymer({
     is: 'gr-admin-view',
 
     properties: {
+      /** @type {?} */
+      params: Object,
       path: String,
+      adminView: String,
+
+      _projectName: String,
+      _groupId: {
+        type: Number,
+        observer: '_computeGroupName',
+      },
+      _groupName: String,
+      _groupOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _filteredLinks: Array,
+      _showDownload: {
+        type: Boolean,
+        value: false,
+      },
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+      _showGroup: Boolean,
+      _showGroupAuditLog: Boolean,
+      _showGroupList: Boolean,
+      _showGroupMembers: Boolean,
+      _showProjectCommands: Boolean,
+      _showProjectMain: Boolean,
+      _showProjectList: Boolean,
+      _showProjectDetailList: Boolean,
+      _showPluginList: Boolean,
+      _showProjectAccess: Boolean,
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    observers: [
+      '_paramsChanged(params)',
+    ],
+
+    attached() {
+      this.reload();
+    },
+
+    reload() {
+      return this.$.restAPI.getAccount().then(account => {
+        this._account = account;
+        if (!account) {
+          // Return so that  account capabilities don't load with no account.
+          return this._filteredLinks = this._filterLinks(link => {
+            return link.viewableToAll;
+          });
+        }
+        this._loadAccountCapabilities();
+      });
+    },
+
+    _filterLinks(filterFn) {
+      const links = ADMIN_LINKS.filter(filterFn);
+      const filteredLinks = [];
+      for (const link of links) {
+        const linkCopy = Object.assign({}, link);
+        linkCopy.children = linkCopy.children ?
+            linkCopy.children.filter(filterFn) : [];
+        if (linkCopy.name === 'Projects' && this._projectName) {
+          linkCopy.subsection = {
+            name: this._projectName,
+            view: 'gr-project',
+            url: `/admin/projects/${this.encodeURL(this._projectName, true)}`,
+            children: [{
+              name: 'Access',
+              detailType: 'access',
+              view: 'gr-project-access',
+              url: `/admin/projects/` +
+                  `${this.encodeURL(this._projectName, true)},access`,
+            },
+            {
+              name: 'Commands',
+              detailType: 'commands',
+              view: 'gr-project-commands',
+              url: `/admin/projects/` +
+                  `${this.encodeURL(this._projectName, true)},commands`,
+            },
+            {
+              name: 'Branches',
+              detailType: 'branches',
+              view: 'gr-project-detail-list',
+              url: `/admin/projects/` +
+                  `${this.encodeURL(this._projectName, true)},branches`,
+            },
+            {
+              name: 'Tags',
+              detailType: 'tags',
+              view: 'gr-project-detail-list',
+              url: `/admin/projects/` +
+                  `${this.encodeURL(this._projectName, true)},tags`,
+            }],
+          };
+        }
+        if (linkCopy.name === 'Groups' && this._groupId && this._groupName) {
+          linkCopy.subsection = {
+            name: this._groupName,
+            view: 'gr-group',
+            url: `/admin/groups/${this.encodeURL(this._groupId + '', true)}`,
+            children: [
+              {
+                name: 'Members',
+                detailType: 'members',
+                view: 'gr-group-members',
+                url: `/admin/groups/${this.encodeURL(this._groupId, true)}` +
+                    ',members',
+              },
+            ],
+          };
+          if (this._isAdmin || this._groupOwner) {
+            linkCopy.subsection.children.push(
+                {
+                  name: 'Audit Log',
+                  detailType: 'audit-log',
+                  view: 'gr-group-audit-log',
+                  url: '/admin/groups/' +
+                      `${this.encodeURL(this._groupId + '', true)},audit-log`,
+                }
+            );
+          }
+        }
+        filteredLinks.push(linkCopy);
+      }
+      return filteredLinks;
+    },
+
+    _loadAccountCapabilities() {
+      return this.$.restAPI.getAccountCapabilities(ACCOUNT_CAPABILITIES)
+          .then(capabilities => {
+            this._filteredLinks = this._filterLinks(link => {
+              return !link.capability ||
+                  capabilities.hasOwnProperty(link.capability);
+            });
+          });
+    },
+
+    _paramsChanged(params) {
+      this.set('_showGroup', params.adminView === 'gr-group');
+      this.set('_showGroupAuditLog', params.adminView === 'gr-group-audit-log');
+      this.set('_showGroupList', params.adminView === 'gr-admin-group-list');
+      this.set('_showGroupMembers', params.adminView === 'gr-group-members');
+      this.set('_showProjectCommands',
+          params.adminView === 'gr-project-commands');
+      this.set('_showProjectMain', params.adminView === 'gr-project');
+      this.set('_showProjectList',
+          params.adminView === 'gr-project-list');
+      this.set('_showProjectDetailList',
+          params.adminView === 'gr-project-detail-list');
+      this.set('_showPluginList', params.adminView === 'gr-plugin-list');
+      this.set('_showProjectAccess', params.adminView === 'gr-project-access');
+      if (params.project !== this._projectName) {
+        this._projectName = params.project || '';
+        // Reloads the admin menu.
+        this.reload();
+      }
+      if (params.groupId !== this._groupId) {
+        this._groupId = params.groupId || '';
+        // Reloads the admin menu.
+        this.reload();
+      }
+    },
+
+    // TODO (beckysiegel): Update these functions after router abstraction is
+    // updated. They are currently copied from gr-dropdown (and should be
+    // updated there as well once complete).
+    _computeURLHelper(host, path) {
+      return '//' + host + this.getBaseUrl() + path;
+    },
+
+    _computeRelativeURL(path) {
+      const host = window.location.host;
+      return this._computeURLHelper(host, path);
+    },
+
+    _computeLinkURL(link) {
+      if (!link || typeof link.url === 'undefined') { return ''; }
+      if (link.target) {
+        return link.url;
+      }
+      return this._computeRelativeURL(link.url);
+    },
+
+    /**
+     * @param {string} itemView
+     * @param {Object} params
+     * @param {string=} opt_detailType
+     */
+    _computeSelectedClass(itemView, params, opt_detailType) {
+      if (params.detailType && params.detailType !== opt_detailType) {
+        return '';
+      }
+      return itemView === params.adminView ? 'selected' : '';
+    },
+
+    _computeGroupName(groupId) {
+      if (!groupId) { return ''; }
+      const promises = [];
+      this.$.restAPI.getGroupConfig(groupId).then(group => {
+        this._groupName = group.name;
+        this.reload();
+        promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
+          this._isAdmin = isAdmin;
+        }));
+        promises.push(this.$.restAPI.getIsGroupOwner(group.name).then(
+            isOwner => {
+              this._groupOwner = isOwner;
+            }));
+        return Promise.all(promises).then(() => {
+          this.reload();
+        });
+      });
+    },
+
+    _updateGroupName(e) {
+      this._groupName = e.detail.name;
+      this.reload();
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
new file mode 100644
index 0000000..117940a3
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view_test.html
@@ -0,0 +1,215 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-admin-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-admin-view.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-admin-view></gr-admin-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-admin-view tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      stub('gr-rest-api-interface', {
+        getProjectConfig() {
+          return Promise.resolve({});
+        },
+      });
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_computeURLHelper', () => {
+      const path = '/test';
+      const host = 'http://www.testsite.com';
+      const computedPath = element._computeURLHelper(host, path);
+      assert.equal(computedPath, '//http://www.testsite.com/test');
+    });
+
+    test('link URLs', () => {
+      assert.equal(
+          element._computeLinkURL({url: '/test'}),
+          '//' + window.location.host + '/test');
+      assert.equal(
+          element._computeLinkURL({url: '/test', target: '_blank'}),
+          '/test');
+    });
+
+    test('current page gets selected and is displayed', () => {
+      element._filteredLinks = [{
+        name: 'Projects',
+        url: '/admin/projects',
+        view: 'gr-project-list',
+        children: [],
+      }];
+
+      element.params = {
+        adminView: 'gr-project-list',
+      };
+
+      flushAsynchronousOperations();
+      assert.equal(Polymer.dom(element.root).querySelectorAll(
+          '.selected').length, 1);
+      assert.ok(element.$$('gr-project-list'));
+      assert.isNotOk(element.$$('gr-admin-create-project'));
+    });
+
+    test('_filteredLinks admin', done => {
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
+        return Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        });
+      });
+      element._loadAccountCapabilities().then(() => {
+        assert.equal(element._filteredLinks.length, 3);
+
+        // Projects
+        assert.equal(element._filteredLinks[0].children.length, 0);
+        assert.isNotOk(element._filteredLinks[0].subsection);
+
+        // Groups
+        assert.equal(element._filteredLinks[1].children.length, 0);
+
+        // Plugins
+        assert.equal(element._filteredLinks[2].children.length, 0);
+        done();
+      });
+    });
+
+    test('_filteredLinks non admin authenticated', done => {
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
+        return Promise.resolve({});
+      });
+      element._loadAccountCapabilities().then(() => {
+        assert.equal(element._filteredLinks.length, 2);
+
+        // Projects
+        assert.equal(element._filteredLinks[0].children.length, 0);
+        assert.isNotOk(element._filteredLinks[0].subsection);
+
+        // Groups
+        assert.equal(element._filteredLinks[1].children.length, 0);
+        done();
+      });
+    });
+
+    test('_filteredLinks non admin unathenticated', done => {
+      element.reload().then(() => {
+        assert.equal(element._filteredLinks.length, 1);
+
+        // Projects
+        assert.equal(element._filteredLinks[0].children.length, 0);
+        assert.isNotOk(element._filteredLinks[0].subsection);
+        done();
+      });
+    });
+
+    test('Project shows up in nav', done => {
+      element._projectName = 'Test Project';
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
+        return Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        });
+      });
+      element._loadAccountCapabilities().then(() => {
+        assert.equal(element._filteredLinks.length, 3);
+
+        // Projects
+        assert.equal(element._filteredLinks[0].children.length, 0);
+        assert.equal(element._filteredLinks[0].subsection.name, 'Test Project');
+
+        // Groups
+        assert.equal(element._filteredLinks[1].children.length, 0);
+
+        // Plugins
+        assert.equal(element._filteredLinks[2].children.length, 0);
+        done();
+      });
+    });
+
+    test('Nav is reloaded when project changes', () => {
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
+        return Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        });
+      });
+      sandbox.stub(element.$.restAPI, 'getAccount', () => {
+        return Promise.resolve({_id: 1});
+      });
+      sandbox.stub(element, 'reload');
+      element.params = {project: 'Test Project', adminView: 'gr-project'};
+      assert.equal(element.reload.callCount, 1);
+      element.params = {project: 'Test Project 2',
+        adminView: 'gr-project'};
+      assert.equal(element.reload.callCount, 2);
+    });
+
+    test('Nav is reloaded when group changes', () => {
+      sandbox.stub(element, '_computeGroupName');
+      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', () => {
+        return Promise.resolve({
+          createGroup: true,
+          createProject: true,
+          viewPlugins: true,
+        });
+      });
+      sandbox.stub(element.$.restAPI, 'getAccount', () => {
+        return Promise.resolve({_id: 1});
+      });
+      sandbox.stub(element, 'reload');
+      element.params = {groupId: '1', adminView: 'gr-group'};
+      assert.equal(element.reload.callCount, 1);
+    });
+
+    test('Nav is reloaded when group name changes', done => {
+      const newName = 'newName';
+      sandbox.stub(element, '_computeGroupName');
+      sandbox.stub(element, 'reload', () => {
+        assert.equal(element._groupName, newName);
+        assert.isTrue(element.reload.called);
+        done();
+      });
+      element.params = {group: 1, adminView: 'gr-group'};
+      element._groupName = 'oldName';
+      flushAsynchronousOperations();
+      element.$$('gr-group').fire('name-changed', {name: newName});
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
new file mode 100644
index 0000000..e132100
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html
@@ -0,0 +1,45 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-confirm-delete-item-dialog">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        width: 30em;
+      }
+    </style>
+    <gr-confirm-dialog
+        confirm-label="Delete [[_computeItemName(itemType)]]"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header">[[_computeItemName(itemType)]] Deletion</div>
+      <div class="main">
+        <label for="branchInput">
+          Do you really want to delete the following [[_computeItemName(itemType)]]?
+        </label>
+        <div>
+          [[item]]
+        </div>
+      </div>
+    </gr-confirm-dialog>
+  </template>
+  <script src="gr-confirm-delete-item-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
new file mode 100644
index 0000000..ddc98e9
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.js
@@ -0,0 +1,62 @@
+// 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.
+(function() {
+  'use strict';
+
+  const DETAIL_TYPES = {
+    BRANCHES: 'branches',
+    TAGS: 'tags',
+  };
+
+  Polymer({
+    is: 'gr-confirm-delete-item-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      item: String,
+      itemType: String,
+    },
+
+    _handleConfirmTap(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.fire('confirm', null, {bubbles: false});
+    },
+
+    _handleCancelTap(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.fire('cancel', null, {bubbles: false});
+    },
+
+    _computeItemName(detailType) {
+      if (detailType === DETAIL_TYPES.BRANCHES) {
+        return 'Branch';
+      } else if (detailType === DETAIL_TYPES.TAGS) {
+        return 'Tag';
+      }
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
new file mode 100644
index 0000000..8671ad1
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html
@@ -0,0 +1,76 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-delete-item-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-confirm-delete-item-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-delete-item-dialog></gr-confirm-delete-item-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-delete-item-dialog tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_handleConfirmTap', () => {
+      const confirmHandler = sandbox.stub();
+      element.addEventListener('confirm', confirmHandler);
+      sandbox.stub(element, '_handleConfirmTap');
+      element.$$('gr-confirm-dialog').fire('confirm');
+      assert.isTrue(confirmHandler.called);
+      assert.isTrue(element._handleConfirmTap.called);
+    });
+
+    test('_handleCancelTap', () => {
+      const cancelHandler = sandbox.stub();
+      element.addEventListener('cancel', cancelHandler);
+      sandbox.stub(element, '_handleCancelTap');
+      element.$$('gr-confirm-dialog').fire('cancel');
+      assert.isTrue(cancelHandler.called);
+      assert.isTrue(element._handleCancelTap.called);
+    });
+
+    test('_computeItemName function for branches', () => {
+      assert.deepEqual(element._computeItemName('branches'), 'Branch');
+      assert.notEqual(element._computeItemName('branches'), 'Tag');
+    });
+
+    test('_computeItemName function for tags', () => {
+      assert.deepEqual(element._computeItemName('tags'), 'Tag');
+      assert.notEqual(element._computeItemName('tags'), 'Branch');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
new file mode 100644
index 0000000..7c9b551
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.html
@@ -0,0 +1,101 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<dom-module id="gr-create-change-dialog">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      :host {
+        display: inline-block;
+      }
+      input {
+        width: 25em;
+      }
+      gr-autocomplete {
+        border: none;
+        float: right;
+        --gr-autocomplete: {
+          border: 1px solid #d1d2d3;
+          border-radius: 2px;
+          font-size: 1em;
+          height: 2em;
+          padding: 0 .15em;
+          width: 20em;
+        }
+      }
+    </style>
+    <div class="gr-form-styles">
+      <div id="form">
+        <section>
+          <span class="title">Select branch for new change</span>
+          <span class="value">
+            <gr-autocomplete
+                id="branchInput"
+                text="{{branch}}"
+                query="[[_query]]"
+                placeholder="Destination branch">
+            </gr-autocomplete>
+          </span>
+        </section>
+        <section>
+          <span class="title">Enter topic for new change (optional)</span>
+          <input
+              is="iron-input"
+              id="tagNameInput"
+              bind-value="{{topic}}">
+        </section>
+        <section>
+          <span class="title">Description</span>
+          <iron-autogrow-textarea
+              id="messageInput"
+              class="message"
+              autocomplete="on"
+              rows="4"
+              max-rows="15"
+              bind-value="{{subject}}"
+              placeholder="Insert the description of the change.">
+          </iron-autogrow-textarea>
+        </section>
+        <section>
+          <span class="title">Options</span>
+          <section>
+            <label for="privateChangeCheckBox">Private Change</label>
+            <input
+                type="checkbox"
+                id="privateChangeCheckBox"
+                checked$="[[_projectConfig.private_by_default.inherited_value]]">
+          </section>
+        </section>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-create-change-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
new file mode 100644
index 0000000..8e8bc7d
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog.js
@@ -0,0 +1,97 @@
+// 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.
+(function() {
+  'use strict';
+
+  const SUGGESTIONS_LIMIT = 15;
+  const REF_PREFIX = 'refs/heads/';
+
+  Polymer({
+    is: 'gr-create-change-dialog',
+
+    properties: {
+      projectName: String,
+      branch: String,
+      /** @type {?} */
+      _projectConfig: Object,
+      subject: String,
+      topic: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getProjectBranchesSuggestions.bind(this);
+        },
+      },
+      canCreate: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    attached() {
+      this.$.restAPI.getProjectConfig(this.projectName).then(config => {
+        this._projectConfig = config;
+      });
+    },
+
+    observers: [
+      '_allowCreate(branch, subject)',
+    ],
+
+    _allowCreate(branch, subject) {
+      this.canCreate = !!branch && !!subject;
+    },
+
+    handleCreateChange() {
+      const isPrivate = this.$.privateChangeCheckBox.checked;
+      return this.$.restAPI.createChange(this.projectName, this.branch,
+          this.subject, this.topic, isPrivate, true)
+          .then(changeCreated => {
+            if (!changeCreated) {
+              return;
+            }
+            Gerrit.Nav.navigateToChange(changeCreated);
+          });
+    },
+
+    _getProjectBranchesSuggestions(input) {
+      if (input.startsWith(REF_PREFIX)) {
+        input = input.substring(REF_PREFIX.length);
+      }
+      return this.$.restAPI.getProjectBranches(
+          input, this.projectName, SUGGESTIONS_LIMIT).then(response => {
+            const branches = [];
+            let branch;
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              if (response[key].ref.startsWith('refs/heads/')) {
+                branch = response[key].ref.substring('refs/heads/'.length);
+              } else {
+                branch = response[key].ref;
+              }
+              branches.push({
+                name: branch,
+              });
+            }
+            return branches;
+          });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
new file mode 100644
index 0000000..dbc99fb
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-change-dialog/gr-create-change-dialog_test.html
@@ -0,0 +1,147 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-create-change-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-create-change-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-create-change-dialog></gr-create-change-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-create-change-dialog tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getProjectBranches(input) {
+          if (input.startsWith('test')) {
+            return Promise.resolve([
+              {
+                ref: 'refs/heads/test-branch',
+                revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+                can_delete: true,
+              },
+            ]);
+          } else {
+            return Promise.resolve({});
+          }
+        },
+      });
+      element = fixture('basic');
+      element.projectName = 'test-project';
+      element._projectConfig = {
+        private_by_default: {},
+        work_in_progress_by_default: {},
+      };
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('new change created with private', () => {
+      element._projectConfig = {
+        private_by_default: {
+          inherited_value: true,
+        },
+      };
+
+      const configInputObj = {
+        branch: 'test-branch',
+        topic: 'test-topic',
+        subject: 'first change created with polygerrit ui',
+        work_in_progress: false,
+        project: element.projectName,
+      };
+
+      const saveStub = sandbox.stub(element.$.restAPI,
+          'createChange', () => {
+            return Promise.resolve({});
+          });
+
+      element.project = element.projectName;
+      element.branch = 'test-branch';
+      element.topic = 'test-topic';
+      element.subject = 'first change created with polygerrit ui';
+      assert.isTrue(element.$.privateChangeCheckBox.checked);
+
+      element.$.branchInput.bindValue = configInputObj.branch;
+      element.$.tagNameInput.bindValue = configInputObj.topic;
+      element.$.messageInput.bindValue = configInputObj.subject;
+
+      element.handleCreateChange().then(() => {
+        assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
+      });
+    });
+
+    test('new change created with wip', () => {
+      const configInputObj = {
+        branch: 'test-branch',
+        topic: 'test-topic',
+        subject: 'first change created with polygerrit ui',
+        project: element.projectName,
+      };
+
+      const saveStub = sandbox.stub(element.$.restAPI,
+          'createChange', () => {
+            return Promise.resolve({});
+          });
+
+      element.project = element.projectName;
+      element.branch = 'test-branch';
+      element.topic = 'test-topic';
+      element.subject = 'first change created with polygerrit ui';
+      assert.isFalse(element.$.privateChangeCheckBox.checked);
+
+      element.$.branchInput.bindValue = configInputObj.branch;
+      element.$.tagNameInput.bindValue = configInputObj.topic;
+      element.$.messageInput.bindValue = configInputObj.subject;
+
+      element.handleCreateChange().then(() => {
+        assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
+      });
+    });
+
+    test('_getProjectBranchesSuggestions empty', done => {
+      element._getProjectBranchesSuggestions('nonexistent').then(branches => {
+        assert.equal(branches.length, 0);
+        done();
+      });
+    });
+
+    test('_getProjectBranchesSuggestions non-empty', done => {
+      element._getProjectBranchesSuggestions('test-branch').then(branches => {
+        assert.equal(branches.length, 1);
+        assert.equal(branches[0].name, 'test-branch');
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
new file mode 100644
index 0000000..6612e38
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.html
@@ -0,0 +1,51 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-create-group-dialog">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      :host {
+        display: inline-block;
+      }
+      input {
+        width: 20em;
+      }
+    </style>
+    <div class="gr-form-styles">
+      <div id="form">
+        <section>
+          <span class="title">Group name</span>
+          <input
+              is="iron-input"
+              id="groupNameInput"
+              bind-value="{{_name}}">
+        </section>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-create-group-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
new file mode 100644
index 0000000..51024d7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog.js
@@ -0,0 +1,64 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-create-group-dialog',
+
+    properties: {
+      params: Object,
+      hasNewGroupName: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+      _name: Object,
+      _groupCreated: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    observers: [
+      '_updateGroupName(_name)',
+    ],
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    _computeGroupUrl(groupId) {
+      return this.getBaseUrl() + '/admin/groups/' +
+          this.encodeURL(groupId, true);
+    },
+
+    _updateGroupName(name) {
+      this.hasNewGroupName = !!name;
+    },
+
+    handleCreateGroup() {
+      return this.$.restAPI.createGroup({name: this._name})
+          .then(groupRegistered => {
+            if (groupRegistered.status !== 201) { return; }
+            this._groupCreated = true;
+            return this.$.restAPI.getGroupConfig(this._name)
+                .then(group => {
+                  page.show(this._computeGroupUrl(group.group_id));
+                });
+          });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
new file mode 100644
index 0000000..7766ea6
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-group-dialog/gr-create-group-dialog_test.html
@@ -0,0 +1,92 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-create-group-dialog</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-create-group-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-create-group-dialog></gr-create-group-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-create-group-dialog tests', () => {
+    let element;
+    let sandbox;
+    const GROUP_NAME = 'test-group';
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('name is updated correctly', () => {
+      assert.isFalse(element.hasNewGroupName);
+
+      element.$.groupNameInput.bindValue = GROUP_NAME;
+
+      assert.isTrue(element.hasNewGroupName);
+      assert.deepEqual(element._name, GROUP_NAME);
+    });
+
+    test('test for redirecting to group on successful creation', done => {
+      sandbox.stub(element.$.restAPI, 'createGroup')
+          .returns(Promise.resolve({status: 201}));
+
+      sandbox.stub(element.$.restAPI, 'getGroupConfig')
+          .returns(Promise.resolve({group_id: 551}));
+
+      const showStub = sandbox.stub(page, 'show');
+      element.handleCreateGroup()
+          .then(() => {
+            assert.isTrue(showStub.calledWith('/admin/groups/551'));
+            done();
+          });
+    });
+
+    test('test for unsuccessful group creation', done => {
+      sandbox.stub(element.$.restAPI, 'createGroup')
+          .returns(Promise.resolve({status: 409}));
+
+      sandbox.stub(element.$.restAPI, 'getGroupConfig')
+          .returns(Promise.resolve({group_id: 551}));
+
+      const showStub = sandbox.stub(page, 'show');
+      element.handleCreateGroup()
+          .then(() => {
+            assert.isFalse(showStub.called);
+            done();
+          });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
new file mode 100644
index 0000000..dcc23e1
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<dom-module id="gr-create-pointer-dialog">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      :host {
+        display: inline-block;
+      }
+      input {
+        width: 20em;
+      }
+      .hideItem {
+        display: none;
+      }
+    </style>
+    <div class="gr-form-styles">
+      <div id="form">
+        <section>
+          <span class="title">[[detailType]] name</span>
+          <input
+              is="iron-input"
+              id="itemNameInput"
+              placeholder="[[detailType]] Name"
+              bind-value="{{_itemName}}">
+        </section>
+        <section>
+          <span class="title">Initial Revision</span>
+          <input
+              is="iron-input"
+              id="itemRevisionInput"
+              placeholder="Revision (Branch or SHA-1)"
+              bind-value="{{_itemRevision}}">
+        </section>
+        <section class$="[[_computeHideItemClass(itemDetail)]]">
+          <span class="title">Annotation</span>
+          <input
+              is="iron-input"
+              id="itemAnnotationInput"
+              placeholder="Annotation (Optional)"
+              bind-value="{{_itemAnnotation}}">
+        </section>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-create-pointer-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
new file mode 100644
index 0000000..4d552f4
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog.js
@@ -0,0 +1,88 @@
+// 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.
+(function() {
+  'use strict';
+
+  const DETAIL_TYPES = {
+    branches: 'branches',
+    tags: 'tags',
+  };
+
+  Polymer({
+    is: 'gr-create-pointer-dialog',
+
+    properties: {
+      detailType: String,
+      projectName: String,
+      hasNewItemName: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+      itemDetail: String,
+      _itemName: String,
+      _itemRevision: String,
+      _itemAnnotation: String,
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    observers: [
+      '_updateItemName(_itemName)',
+    ],
+
+    _updateItemName(name) {
+      this.hasNewItemName = !!name;
+    },
+
+    _computeItemUrl(project) {
+      if (this.itemDetail === DETAIL_TYPES.branches) {
+        return this.getBaseUrl() + '/admin/projects/' +
+            this.encodeURL(this.projectName, true) + ',branches';
+      } else if (this.itemDetail === DETAIL_TYPES.tags) {
+        return this.getBaseUrl() + '/admin/projects/' +
+            this.encodeURL(this.projectName, true) + ',tags';
+      }
+    },
+
+    handleCreateItem() {
+      const USE_HEAD = this._itemRevision ? this._itemRevision : 'HEAD';
+      if (this.itemDetail === DETAIL_TYPES.branches) {
+        return this.$.restAPI.createProjectBranch(this.projectName,
+            this._itemName, {revision: USE_HEAD})
+            .then(itemRegistered => {
+              if (itemRegistered.status === 201) {
+                page.show(this._computeItemUrl(this.itemDetail));
+              }
+            });
+      } else if (this.itemDetail === DETAIL_TYPES.tags) {
+        return this.$.restAPI.createProjectTag(this.projectName,
+            this._itemName,
+            {revision: USE_HEAD, message: this._itemAnnotation || null})
+            .then(itemRegistered => {
+              if (itemRegistered.status === 201) {
+                page.show(this._computeItemUrl(this.itemDetail));
+              }
+            });
+      }
+    },
+
+    _computeHideItemClass(type) {
+      return type === DETAIL_TYPES.branches ? 'hideItem' : '';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
new file mode 100644
index 0000000..dd74574
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html
@@ -0,0 +1,123 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-create-pointer-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-create-pointer-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-create-pointer-dialog></gr-create-pointer-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-create-pointer-dialog tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('branch created', () => {
+      sandbox.stub(element.$.restAPI, 'createProjectBranch', () => {
+        return Promise.resolve({});
+      });
+
+      assert.isFalse(element.hasNewItemName);
+
+      element._itemName = 'test-branch';
+      element.itemDetail = 'branches';
+
+      element.$.itemNameInput.bindValue = 'test-branch2';
+      element.$.itemRevisionInput.bindValue = 'HEAD';
+
+      assert.isTrue(element.hasNewItemName);
+
+      assert.equal(element._itemName, 'test-branch2');
+
+      assert.equal(element._itemRevision, 'HEAD');
+    });
+
+    test('tag created', () => {
+      sandbox.stub(element.$.restAPI, 'createProjectTag', () => {
+        return Promise.resolve({});
+      });
+
+      assert.isFalse(element.hasNewItemName);
+
+      element._itemName = 'test-tag';
+      element.itemDetail = 'tags';
+
+      element.$.itemNameInput.bindValue = 'test-tag2';
+      element.$.itemRevisionInput.bindValue = 'HEAD';
+
+      assert.isTrue(element.hasNewItemName);
+
+      assert.equal(element._itemName, 'test-tag2');
+
+      assert.equal(element._itemRevision, 'HEAD');
+    });
+
+    test('tag created with annotations', () => {
+      sandbox.stub(element.$.restAPI, 'createProjectTag', () => {
+        return Promise.resolve({});
+      });
+
+      assert.isFalse(element.hasNewItemName);
+
+      element._itemName = 'test-tag';
+      element._itemAnnotation = 'test-message';
+      element.itemDetail = 'tags';
+
+      element.$.itemNameInput.bindValue = 'test-tag2';
+      element.$.itemAnnotationInput.bindValue = 'test-message2';
+      element.$.itemRevisionInput.bindValue = 'HEAD';
+
+      assert.isTrue(element.hasNewItemName);
+
+      assert.equal(element._itemName, 'test-tag2');
+
+      assert.equal(element._itemAnnotation, 'test-message2');
+
+      assert.equal(element._itemRevision, 'HEAD');
+    });
+
+    test('_computeHideItemClass returns hideItem if type is branches', () => {
+      assert.equal(element._computeHideItemClass('branches'), 'hideItem');
+    });
+
+    test('_computeHideItemClass returns strings if not branches', () => {
+      assert.equal(element._computeHideItemClass('tags'), '');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.html b/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.html
new file mode 100644
index 0000000..88dca81
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.html
@@ -0,0 +1,103 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<dom-module id="gr-create-project-dialog">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      :host {
+        display: inline-block;
+      }
+      input {
+        width: 20em;
+      }
+      gr-autocomplete {
+        border: none;
+        --gr-autocomplete: {
+          border: 1px solid #d1d2d3;
+          border-radius: 2px;
+          font-size: 1em;
+          height: 2em;
+          padding: 0 .15em;
+          width: 20em;
+        }
+      }
+    </style>
+
+    <div class="gr-form-styles">
+      <div id="form">
+        <section>
+          <span class="title">Project name</span>
+          <input is="iron-input"
+              id="projectNameInput"
+              autocomplete="on"
+              bind-value="{{_projectConfig.name}}">
+        </section>
+        <section>
+          <span class="title">Rights inherit from</span>
+          <span class="value">
+            <gr-autocomplete
+                id="rightsInheritFromInput"
+                text="{{_projectConfig.parent}}"
+                query="[[_query]]"
+                placeholder="Optional, defaults to 'All-Projects'">
+            </gr-autocomplete>
+          </span>
+        </section>
+        <section>
+          <span class="title">Create initial empty commit</span>
+          <span class="value">
+            <gr-select
+                id="initalCommit"
+                bind-value="{{_projectConfig.create_empty_commit}}">
+              <select>
+                <option value="false">False</option>
+                <option value="true">True</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+        <section>
+          <span class="title">Only serve as parent for other projects</span>
+          <span class="value">
+            <gr-select
+                id="parentProject"
+                bind-value="{{_projectConfig.permissions_only}}">
+              <select>
+                <option value="false">False</option>
+                <option value="true">True</option>
+              </select>
+            </gr-select>
+          </span>
+        </section>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-create-project-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.js b/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.js
new file mode 100644
index 0000000..f8fb510
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog.js
@@ -0,0 +1,95 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-create-project-dialog',
+
+    properties: {
+      params: Object,
+      hasNewProjectName: {
+        type: Boolean,
+        notify: true,
+        value: false,
+      },
+
+      /** @type {?} */
+      _projectConfig: {
+        type: Object,
+        value: () => {
+          // Set default values for dropdowns.
+          return {
+            create_empty_commit: true,
+            permissions_only: false,
+          };
+        },
+      },
+      _projectCreated: {
+        type: Boolean,
+        value: false,
+      },
+
+      _query: {
+        type: Function,
+        value() {
+          return this._getProjectSuggestions.bind(this);
+        },
+      },
+    },
+
+    observers: [
+      '_updateProjectName(_projectConfig.name)',
+    ],
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    _computeProjectUrl(projectName) {
+      return this.getBaseUrl() + '/admin/projects/' +
+          this.encodeURL(projectName, true);
+    },
+
+    _updateProjectName(name) {
+      this.hasNewProjectName = !!name;
+    },
+
+    handleCreateProject() {
+      return this.$.restAPI.createProject(this._projectConfig)
+          .then(projectRegistered => {
+            if (projectRegistered.status === 201) {
+              this._projectCreated = true;
+              page.show(this._computeProjectUrl(this._projectConfig.name));
+            }
+          });
+    },
+
+    _getProjectSuggestions(input) {
+      return this.$.restAPI.getSuggestedProjects(input)
+          .then(response => {
+            const projects = [];
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              projects.push({
+                name: key,
+                value: response[key],
+              });
+            }
+            return projects;
+          });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog_test.html b/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog_test.html
new file mode 100644
index 0000000..66273c0
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-create-project-dialog/gr-create-project-dialog_test.html
@@ -0,0 +1,95 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-create-project-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-create-project-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-create-project-dialog></gr-create-project-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-create-project-dialog tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('default values are populated', () => {
+      assert.isTrue(element.$.initalCommit.bindValue);
+      assert.isFalse(element.$.parentProject.bindValue);
+    });
+
+    test('project created', done => {
+      const configInputObj = {
+        name: 'test-project',
+        create_empty_commit: true,
+        parent: 'All-Project',
+        permissions_only: false,
+      };
+
+      const saveStub = sandbox.stub(element.$.restAPI,
+          'createProject', () => {
+            return Promise.resolve({});
+          });
+
+      assert.isFalse(element.hasNewProjectName);
+
+      element._projectConfig = {
+        name: 'test-project',
+        create_empty_commit: true,
+        parent: 'All-Project',
+        permissions_only: false,
+      };
+
+      element.$.projectNameInput.bindValue = configInputObj.name;
+      element.$.rightsInheritFromInput.bindValue = configInputObj.parent;
+      element.$.initalCommit.bindValue =
+          configInputObj.create_empty_commit;
+      element.$.parentProject.bindValue =
+          configInputObj.permissions_only;
+
+      assert.isTrue(element.hasNewProjectName);
+
+      assert.deepEqual(element._projectConfig, configInputObj);
+
+      element.handleCreateProject().then(() => {
+        assert.isTrue(saveStub.lastCall.calledWithExactly(configInputObj));
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
new file mode 100644
index 0000000..43c893c
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.html
@@ -0,0 +1,60 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-group-audit-log">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-table-styles"></style>
+    <table id="list" class="genericList">
+      <tr class="headerRow">
+        <th class="date topHeader">Date</th>
+        <th class="type topHeader">Type</th>
+        <th class="member topHeader">Member</th>
+        <th class="by-user topHeader">By User</th>
+      </tr>
+      <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+        <td>Loading...</td>
+      </tr>
+      <template is="dom-repeat" items="[[_auditLog]]"
+          class$="[[computeLoadingClass(_loading)]]">
+        <tr class="table">
+          <td class="date">
+            <gr-date-formatter
+                has-tooltip
+                date-str="[[item.date]]">
+            </gr-date-formatter>
+          </td>
+          <td class="type">[[itemType(item.type)]]</td>
+          <td class="member">
+            <a href$="[[_computeGroupUrl(item.member.group_id)]]">
+              [[_getNameForMember(item.member)]]
+            </a>
+          </td>
+          <td class="by-user">[[_getNameForUser(item.user)]]</td>
+        </tr>
+      </template>
+    </table>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-group-audit-log.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
new file mode 100644
index 0000000..0bc9a99
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log.js
@@ -0,0 +1,99 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-group-audit-log',
+
+    properties: {
+      groupId: Object,
+      _auditLog: Object,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+    ],
+
+    attached() {
+      this.fire('title-change', {title: 'Audit Log'});
+    },
+
+    ready() {
+      this._getAuditLogs();
+    },
+
+    _getAuditLogs() {
+      if (!this.groupId) {
+        return '';
+      }
+      return this.$.restAPI.getGroupAuditLog(this.groupId).then(auditLog => {
+        if (!auditLog) {
+          this._auditLog = [];
+          return;
+        }
+        this._auditLog = auditLog;
+        this._loading = false;
+      });
+    },
+
+    _status(item) {
+      return item.disabled ? 'Disabled' : 'Enabled';
+    },
+
+    _computeGroupUrl(id) {
+      if (!id) {
+        return '';
+      }
+      return this.getBaseUrl() + '/admin/groups/' + id;
+    },
+
+    itemType(type) {
+      let item;
+      switch (type) {
+        case 'ADD_GROUP':
+        case 'ADD_USER':
+          item = 'Added';
+          break;
+        case 'REMOVE_GROUP':
+        case 'REMOVE_USER':
+          item = 'Removed';
+          break;
+        default:
+          item = '';
+      }
+      return item;
+    },
+
+    _getNameForUser(account) {
+      const accountId = account._account_id ? ' (' +
+        account._account_id + ')' : '';
+      return this._getNameForMember(account) + accountId;
+    },
+
+    _getNameForMember(account) {
+      if (account && account.name) {
+        return account.name;
+      } else if (account && account.username) {
+        return account.username;
+      } else if (account && account.email) {
+        return account.email.split('@')[0];
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
new file mode 100644
index 0000000..b179718
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-audit-log/gr-group-audit-log_test.html
@@ -0,0 +1,121 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-group-audit-log</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-group-audit-log.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-group-audit-log></gr-group-audit-log>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-group-audit-log tests', () => {
+    let element;
+
+    setup(() => {
+      element = fixture('basic');
+    });
+
+    suite('members', () => {
+      test('test getNameForMember', () => {
+        let account = {
+          member: {
+            username: 'test-user',
+            _account_id: 12,
+          },
+        };
+        assert.equal(element._getNameForMember(account.member, false),
+            'test-user');
+
+        account = {
+          member: {
+            name: 'test-name',
+            _account_id: 12,
+          },
+        };
+        assert.equal(element._getNameForMember(account.member), 'test-name');
+
+        account = {
+          user: {
+            email: 'test-email@gmail.com',
+          },
+        };
+        assert.equal(element._getNameForMember(account.user), 'test-email');
+      });
+    });
+
+    suite('users', () => {
+      test('test _getName', () => {
+        let account = {
+          user: {
+            username: 'test-user',
+            _account_id: 12,
+          },
+        };
+        assert.equal(element._getNameForUser(account.user), 'test-user (12)');
+
+        account = {
+          user: {
+            name: 'test-name',
+            _account_id: 12,
+          },
+        };
+        assert.equal(element._getNameForUser(account.user), 'test-name (12)');
+
+        account = {
+          user: {
+            email: 'test-email@gmail.com',
+            _account_id: 12,
+          },
+        };
+        assert.equal(element._getNameForUser(account.user), 'test-email (12)');
+      });
+
+      test('test _account_id not present', () => {
+        let account = {
+          user: {
+            username: 'test-user',
+          },
+        };
+        assert.equal(element._getNameForUser(account.user), 'test-user');
+
+        account = {
+          user: {
+            name: 'test-name',
+          },
+        };
+        assert.equal(element._getNameForUser(account.user), 'test-name');
+
+        account = {
+          user: {
+            email: 'test-email@gmail.com',
+          },
+        };
+        assert.equal(element._getNameForUser(account.user), 'test-email');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
new file mode 100644
index 0000000..4de9a95
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.html
@@ -0,0 +1,190 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
+
+<dom-module id="gr-group-members">
+  <template>
+    <style include="gr-form-styles"></style>
+    <style include="shared-styles">
+      main {
+        margin: 2em 1em;
+      }
+      .loading {
+        display: none;
+      }
+      #loading.loading {
+        display: block;
+      }
+      #loading:not(.loading) {
+        display: none;
+      }
+      .input {
+        width: 15em;
+      }
+      gr-autocomplete {
+        width: 20em;
+        --gr-autocomplete: {
+          font-size: 1em;
+          height: 2em;
+          width: 20em;
+        }
+      }
+      a {
+        color: var(--default-text-color);
+        text-decoration: none;
+      }
+      a:hover {
+        text-decoration: underline;
+      }
+      th {
+        border-bottom: 1px solid #eee;
+        font-family: var(--font-family-bold);
+        text-align: left;
+      }
+      .canModify #groupMemberSearchInput,
+      .canModify #saveGroupMember,
+      .canModify .deleteHeader,
+      .canModify .deleteColumn,
+      .canModify #includedGroupSearchInput,
+      .canModify #saveIncludedGroups,
+      .canModify .deleteIncludedHeader,
+      .canModify #saveIncludedGroups {
+        display: none;
+      }
+    </style>
+    <main class$="gr-form-styles [[_computeHideItemClass(_groupOwner, _isAdmin)]]">
+      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+        Loading...
+      </div>
+      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+        <h1 id="Title">[[_groupName]]</h1>
+        <div id="form">
+          <h3 id="members">Members</h3>
+          <fieldset>
+            <span class="value">
+              <gr-autocomplete
+                  id="groupMemberSearchInput"
+                  text="{{_groupMemberSearchName}}"
+                  value="{{_groupMemberSearchId}}"
+                  query="[[_queryMembers]]"
+                  placeholder="Name Or Email">
+              </gr-autocomplete>
+            </span>
+            <gr-button
+                id="saveGroupMember"
+                on-tap="_handleSavingGroupMember"
+                disabled="[[!_groupMemberSearchId]]">
+              Add
+            </gr-button>
+            <table id="groupMembers">
+              <tr class="headerRow">
+                <th class="nameHeader">Name</th>
+                <th class="emailAddressHeader">Email Address</th>
+                <th class="deleteHeader">Delete Member</th>
+              </tr>
+              <tbody>
+                <template is="dom-repeat" items="[[_groupMembers]]">
+                  <tr>
+                    <td class="nameColumn">
+                      <gr-account-link account="[[item]]"></gr-account-link>
+                    </td>
+                    <td>[[item.email]]</td>
+                    <td class="deleteColumn">
+                      <gr-button
+                          class="deleteButton"
+                          on-tap="_handleDeleteMember">
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                </template>
+              </tbody>
+            </table>
+          </fieldset>
+          <h3 id="includedGroups">Included Groups</h3>
+          <fieldset>
+            <span class="value">
+              <gr-autocomplete
+                  id="includedGroupSearchInput"
+                  text="{{_includedGroupSearch}}"
+                  query="[[_queryIncludedGroup]]"
+                  placeholder="Group Name">
+              </gr-autocomplete>
+            </span>
+            <gr-button
+                id="saveIncludedGroups"
+                on-tap="_handleSavingIncludedGroups"
+                disabled="[[!_includedGroupSearch]]">
+              Add
+            </gr-button>
+            <table id="includedGroups">
+              <tr class="headerRow">
+                <th class="groupNameHeader">Group Name</th>
+                <th class="descriptionHeader">Description</th>
+                <th class="deleteIncludedHeader">
+                  Delete Group
+                </th>
+              </tr>
+              <tbody>
+                <template is="dom-repeat" items="[[_includedGroups]]">
+                  <tr>
+                    <td class="nameColumn">
+                      <a href$="[[_computeGroupUrl(item.url)]]"
+                          rel="noopener">
+                        [[item.name]]
+                      </a>
+                    </td>
+                    <td>[[item.description]]</td>
+                    <td class="deleteColumn">
+                      <gr-button
+                          class="deleteIncludedGroupButton"
+                          on-tap="_handleDeleteIncludedGroup">
+                        Delete
+                      </gr-button>
+                    </td>
+                  </tr>
+                </template>
+              </tbody>
+            </table>
+          </fieldset>
+        </div>
+      </div>
+    </main>
+    <gr-overlay id="overlay" with-backdrop>
+      <gr-confirm-delete-item-dialog
+          class="confirmDialog"
+          on-confirm="_handleDeleteConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          item="[[_itemName]]"
+          item-type="[[_itemType]]"></gr-confirm-delete-item-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-group-members.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
new file mode 100644
index 0000000..5257e69
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members.js
@@ -0,0 +1,266 @@
+// 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.
+(function() {
+  'use strict';
+
+  const SUGGESTIONS_LIMIT = 15;
+  const SAVING_ERROR_TEXT = 'Group may not exist, or you may not have '+
+      'permission to add it';
+
+  const URL_REGEX = '^(?:[a-z]+:)?//';
+
+  Polymer({
+    is: 'gr-group-members',
+
+    properties: {
+      groupId: Number,
+      _groupMemberSearchId: String,
+      _groupMemberSearchName: String,
+      _includedGroupSearch: String,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _groupName: String,
+      _groupMembers: Object,
+      _includedGroups: Object,
+      _itemName: String,
+      _itemType: String,
+      _queryMembers: {
+        type: Function,
+        value() {
+          return this._getAccountSuggestions.bind(this);
+        },
+      },
+      _queryIncludedGroup: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
+        },
+      },
+      _groupOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    attached() {
+      this._loadGroupDetails();
+
+      this.fire('title-change', {title: 'Members'});
+    },
+
+    _loadGroupDetails() {
+      if (!this.groupId) { return; }
+
+      const promises = [];
+
+      return this.$.restAPI.getGroupConfig(this.groupId).then(
+          config => {
+            if (!config.name) { return; }
+
+            this._groupName = config.name;
+
+            promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
+              this._isAdmin = isAdmin ? true : false;
+            }));
+
+            promises.push(this.$.restAPI.getIsGroupOwner(config.name)
+                .then(isOwner => {
+                  this._groupOwner = isOwner ? true : false;
+                }));
+
+            promises.push(this.$.restAPI.getGroupMembers(config.name).then(
+                members => {
+                  this._groupMembers = members;
+                }));
+
+            promises.push(this.$.restAPI.getIncludedGroup(config.name)
+                .then(includedGroup => {
+                  this._includedGroups = includedGroup;
+                }));
+
+            return Promise.all(promises).then(() => {
+              this._loading = false;
+            });
+          });
+    },
+
+    _computeLoadingClass(loading) {
+      return loading ? 'loading' : '';
+    },
+
+    _isLoading() {
+      return this._loading || this._loading === undefined;
+    },
+
+    _computeGroupUrl(url) {
+      const r = new RegExp(URL_REGEX, 'i');
+      if (r.test(url)) {
+        return url;
+      }
+
+      // For GWT compatibility
+      if (url.startsWith('#')) {
+        return this.getBaseUrl() + url.slice(1);
+      }
+      return this.getBaseUrl() + url;
+    },
+
+    _handleSavingGroupMember() {
+      return this.$.restAPI.saveGroupMembers(this._groupName,
+          this._groupMemberSearchId).then(config => {
+            if (!config) {
+              return;
+            }
+            this.$.restAPI.getGroupMembers(this._groupName).then(members => {
+              this._groupMembers = members;
+            });
+            this._groupMemberSearchId = '';
+          });
+    },
+
+    _handleDeleteConfirm() {
+      this.$.overlay.close();
+      if (this._itemType === 'member') {
+        return this.$.restAPI.deleteGroupMembers(this._groupName,
+            this._itemId)
+            .then(itemDeleted => {
+              if (itemDeleted.status === 204) {
+                this.$.restAPI.getGroupMembers(this._groupName)
+                    .then(members => {
+                      this._groupMembers = members;
+                    });
+              }
+            });
+      } else if (this._itemType === 'includedGroup') {
+        return this.$.restAPI.deleteIncludedGroup(this._groupName,
+            this._itemName)
+            .then(itemDeleted => {
+              if (itemDeleted.status === 204) {
+                this.$.restAPI.getIncludedGroup(this._groupName)
+                    .then(includedGroup => {
+                      this._includedGroups = includedGroup;
+                    });
+              }
+            });
+      }
+    },
+
+    _handleConfirmDialogCancel() {
+      this.$.overlay.close();
+    },
+
+    _handleDeleteMember(e) {
+      const id = e.model.get('item._account_id');
+      const name = e.model.get('item.name');
+      const username = e.model.get('item.username');
+      const email = e.model.get('item.email');
+      const item = username || name || email || id;
+      if (!item) {
+        return '';
+      }
+      this._itemName = item;
+      this._itemId = id;
+      this._itemType = 'member';
+      this.$.overlay.open();
+    },
+
+    _handleSavingIncludedGroups() {
+      return this.$.restAPI.saveIncludedGroup(this._groupName,
+          this._includedGroupSearch, err => {
+            if (err.status === 404) {
+              this.dispatchEvent(new CustomEvent('show-alert', {
+                detail: {message: SAVING_ERROR_TEXT},
+                bubbles: true,
+              }));
+              return err;
+            }
+            throw Error(err.statusText);
+          })
+          .then(config => {
+            if (!config) {
+              return;
+            }
+            this.$.restAPI.getIncludedGroup(this._groupName)
+                .then(includedGroup => {
+                  this._includedGroups = includedGroup;
+                });
+            this._includedGroupSearch = '';
+          });
+    },
+
+    _handleDeleteIncludedGroup(e) {
+      const name = e.model.get('item.name');
+      if (!name) {
+        return '';
+      }
+      this._itemName = name;
+      this._itemType = 'includedGroup';
+      this.$.overlay.open();
+    },
+
+    _getAccountSuggestions(input) {
+      if (input.length === 0) { return Promise.resolve([]); }
+      return this.$.restAPI.getSuggestedAccounts(
+          input, SUGGESTIONS_LIMIT).then(accounts => {
+            const accountSuggestions = [];
+            let nameAndEmail;
+            if (!accounts) { return []; }
+            for (const key in accounts) {
+              if (!accounts.hasOwnProperty(key)) { continue; }
+              if (accounts[key].email !== undefined) {
+                nameAndEmail = accounts[key].name +
+                  ' <' + accounts[key].email + '>';
+              } else {
+                nameAndEmail = accounts[key].name;
+              }
+              accountSuggestions.push({
+                name: nameAndEmail,
+                value: accounts[key]._account_id,
+              });
+            }
+            return accountSuggestions;
+          });
+    },
+
+    _getGroupSuggestions(input) {
+      return this.$.restAPI.getSuggestedGroups(input)
+          .then(response => {
+            const groups = [];
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              groups.push({
+                name: key,
+                value: response[key],
+              });
+            }
+            return groups;
+          });
+    },
+
+    _computeHideItemClass(owner, admin) {
+      return admin || owner ? '' : 'canModify';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
new file mode 100644
index 0000000..d670d4d
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group-members/gr-group-members_test.html
@@ -0,0 +1,255 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-group-members</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-group-members.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-group-members></gr-group-members>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-group-members tests', () => {
+    let element;
+    let sandbox;
+    let groups;
+    let groupMembers;
+    let includedGroups;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      groups = {
+        name: 'Administrators',
+        owner: 'Administrators',
+        group_id: 1,
+      };
+
+      groupMembers = [
+        {
+          _account_id: 1000097,
+          name: 'Jane Roe',
+          email: 'jane.roe@example.com',
+          username: 'jane',
+        },
+        {
+          _account_id: 1000096,
+          name: 'Test User',
+          email: 'john.doe@example.com',
+        },
+        {
+          _account_id: 1000095,
+          name: 'Gerrit',
+        },
+        {
+          _account_id: 1000098,
+        },
+      ];
+
+      includedGroups = [{
+        url: 'https://group/url',
+        options: {},
+        id: 'testId',
+        name: 'testName',
+      },
+      {
+        url: '/group/url',
+        options: {},
+        id: 'testId2',
+        name: 'testName2',
+      },
+      {
+        url: '#/group/url',
+        options: {},
+        id: 'testId3',
+        name: 'testName3',
+      },
+      ];
+
+      stub('gr-rest-api-interface', {
+        getSuggestedAccounts(input) {
+          if (input.startsWith('test')) {
+            return Promise.resolve([
+              {
+                _account_id: 1000096,
+                name: 'test-account',
+                email: 'test.account@example.com',
+                username: 'test123',
+              },
+              {
+                _account_id: 1001439,
+                name: 'test-admin',
+                email: 'test.admin@example.com',
+                username: 'test_admin',
+              },
+              {
+                _account_id: 1001439,
+                name: 'test-git',
+                username: 'test_git',
+              },
+            ]);
+          } else {
+            return Promise.resolve({});
+          }
+        },
+        getLoggedIn() { return Promise.resolve(true); },
+        getConfig() {
+          return Promise.resolve();
+        },
+        getGroupConfig() {
+          return Promise.resolve(groups);
+        },
+        getGroupMembers() {
+          return Promise.resolve(groupMembers);
+        },
+        getIsGroupOwner() {
+          return Promise.resolve(true);
+        },
+        getIncludedGroup() {
+          return Promise.resolve(includedGroups);
+        },
+        getAccountCapabilities() {
+          return Promise.resolve();
+        },
+      });
+      element = fixture('basic');
+      sandbox.stub(element, 'getBaseUrl').returns('https://test/site');
+      element.groupId = 1;
+      return element._loadGroupDetails();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_includedGroups', () => {
+      assert.equal(element._includedGroups.length, 3);
+      assert.equal(Polymer.dom(element.root)
+          .querySelectorAll('.nameColumn a')[0].href, includedGroups[0].url);
+      assert.equal(Polymer.dom(element.root)
+          .querySelectorAll('.nameColumn a')[1].href,
+          'https://test/site/group/url');
+      assert.equal(Polymer.dom(element.root)
+          .querySelectorAll('.nameColumn a')[2].href,
+          'https://test/site/group/url');
+    });
+
+    test('save correctly', () => {
+      element._groupOwner = true;
+
+      const memberName = 'test-admin';
+
+      const saveStub = sandbox.stub(element.$.restAPI, 'saveGroupMembers',
+          () => {
+            return Promise.resolve({});
+          });
+
+      const button = Polymer.dom(element.root).querySelector('gr-button');
+
+      assert.isTrue(button.hasAttribute('disabled'));
+
+      element.$.groupMemberSearchInput.text = memberName;
+      element.$.groupMemberSearchInput.value = 1234;
+
+      assert.isFalse(button.hasAttribute('disabled'));
+
+      return element._handleSavingGroupMember().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+        assert.isTrue(saveStub.lastCall.calledWithExactly('Administrators',
+            1234));
+      });
+    });
+
+    test('add included group 404 shows helpful error text', () => {
+      element._groupOwner = true;
+
+      const memberName = 'bad-name';
+      const alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
+
+      sandbox.stub(element.$.restAPI, 'saveGroupMembers',
+          () => Promise.reject({status: 404}));
+
+      element.$.groupMemberSearchInput.text = memberName;
+      element.$.groupMemberSearchInput.value = 1234;
+
+      return element._handleSavingIncludedGroups().then(() => {
+        assert.isTrue(alertStub.called);
+      });
+    });
+
+    test('_getAccountSuggestions empty', () => {
+      return element._getAccountSuggestions('nonexistent').then(accounts => {
+        assert.equal(accounts.length, 0);
+      });
+    });
+
+    test('_getAccountSuggestions non-empty', () => {
+      return element._getAccountSuggestions('test-').then(accounts => {
+        assert.equal(accounts.length, 3);
+        assert.equal(accounts[0].name,
+            'test-account <test.account@example.com>');
+        assert.equal(accounts[1].name, 'test-admin <test.admin@example.com>');
+        assert.equal(accounts[2].name, 'test-git');
+      });
+    });
+
+    test('_computeHideItemClass returns string for admin', () => {
+      const admin = true;
+      const owner = false;
+      assert.equal(element._computeHideItemClass(owner, admin), '');
+    });
+
+    test('_computeHideItemClass returns hideItem for admin and owner', () => {
+      const admin = false;
+      const owner = false;
+      assert.equal(element._computeHideItemClass(owner, admin), 'canModify');
+    });
+
+    test('_computeHideItemClass returns string for owner', () => {
+      const admin = false;
+      const owner = true;
+      assert.equal(element._computeHideItemClass(owner, admin), '');
+    });
+
+    test('delete member', () => {
+      const deletelBtns = Polymer.dom(element.root)
+          .querySelectorAll('.deleteButton');
+      MockInteractions.tap(deletelBtns[0]);
+      assert.equal(element._itemId, '1000097');
+      assert.equal(element._itemName, 'jane');
+      MockInteractions.tap(deletelBtns[1]);
+      assert.equal(element._itemId, '1000096');
+      assert.equal(element._itemName, 'Test User');
+      MockInteractions.tap(deletelBtns[2]);
+      assert.equal(element._itemId, '1000095');
+      assert.equal(element._itemName, 'Gerrit');
+      MockInteractions.tap(deletelBtns[3]);
+      assert.equal(element._itemId, '1000098');
+      assert.equal(element._itemName, '1000098');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
new file mode 100644
index 0000000..c2743b7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.html
@@ -0,0 +1,158 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
+<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<dom-module id="gr-group">
+  <template>
+    <style include="shared-styles">
+      main {
+        margin: 2em 1em;
+      }
+      h3.edited:after {
+        color: #444;
+        content: ' *';
+      }
+      .loading {
+        display: none;
+      }
+      #loading.loading {
+        display: block;
+      }
+      #loading:not(.loading) {
+        display: none;
+      }
+      .inputUpdateBtn {
+        margin-top: .3em;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <main class="gr-form-styles read-only">
+      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">
+        Loading...
+      </div>
+      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+        <h1 id="Title">[[_groupName]]</h1>
+        <h2 id="configurations">General</h2>
+        <div id="form">
+          <fieldset>
+            <h3 id="groupUUID">Group UUID</h3>
+            <fieldset>
+              <gr-copy-clipboard
+                  text="[[_groupConfig.id]]"></gr-copy-clipboard>
+            </fieldset>
+            <h3 id="groupName" class$="[[_computeHeaderClass(_rename)]]">
+              Group Name
+            </h3>
+            <fieldset>
+              <span class="value">
+                <gr-autocomplete
+                    id="groupNameInput"
+                    text="{{_groupConfig.name}}"
+                    disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin)]]"></gr-autocomplete>
+              </span>
+              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin)]]">
+                <gr-button
+                    id="inputUpdateNameBtn"
+                    on-tap="_handleSaveName"
+                    disabled="[[!_rename]]">
+                  Rename Group</gr-button>
+              </span>
+            </fieldset>
+            <h3 class$="[[_computeHeaderClass(_owner)]]">
+              Owners
+            </h3>
+            <fieldset>
+              <span class="value">
+                <gr-autocomplete
+                    id="groupOwnerInput"
+                    text="{{_groupConfig.owner}}"
+                    value="{{_groupConfigOwner}}"
+                    query="[[_query]]"
+                    disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin)]]">
+                </gr-autocomplete>
+              </span>
+              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin)]]">
+                <gr-button
+                    on-tap="_handleSaveOwner"
+                    disabled="[[!_owner]]">
+                  Change Owners</gr-button>
+              </span>
+            </fieldset>
+            <h3 class$="[[_computeHeaderClass(_description)]]">
+              Description
+            </h3>
+            <fieldset>
+              <div>
+                <iron-autogrow-textarea
+                    class="description"
+                    autocomplete="on"
+                    bind-value="{{_groupConfig.description}}"
+                    disabled="[[_computeGroupDisabled(_groupOwner, _isAdmin)]]"></iron-autogrow-textarea>
+              </div>
+              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin)]]">
+                <gr-button
+                    on-tap="_handleSaveDescription"
+                    disabled="[[!_description]]">
+                  Save Description
+                </gr-button>
+              </span>
+            </fieldset>
+            <h3 id="options" class$="[[_computeHeaderClass(_options)]]">
+              Group Options
+            </h3>
+            <fieldset id="visableToAll">
+              <section>
+                <span class="title">
+                  Make group visible to all registered users
+                </span>
+                <span class="value">
+                  <gr-select
+                      id="visibleToAll"
+                      bind-value="{{_groupConfig.options.visible_to_all}}">
+                    <select disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin)]]">
+                      <template is="dom-repeat" items="[[_submitTypes]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <span class="value" disabled$="[[_computeGroupDisabled(_groupOwner, _isAdmin)]]">
+                <gr-button
+                    on-tap="_handleSaveOptions"
+                    disabled="[[!_options]]">
+                  Save Group Options
+                </gr-button>
+              </span>
+            </fieldset>
+          </fieldset>
+        </div>
+      </div>
+    </main>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-group.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group.js b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
new file mode 100644
index 0000000..a5f8757
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group.js
@@ -0,0 +1,221 @@
+// 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.
+(function() {
+  'use strict';
+
+  const OPTIONS = {
+    submitFalse: {
+      value: false,
+      label: 'False',
+    },
+    submitTrue: {
+      value: true,
+      label: 'True',
+    },
+  };
+
+  Polymer({
+    is: 'gr-group',
+
+    /**
+     * Fired when the group name changes.
+     *
+     * @event name-changed
+     */
+
+    properties: {
+      groupId: Number,
+      _rename: {
+        type: Boolean,
+        value: false,
+      },
+      _description: {
+        type: Boolean,
+        value: false,
+      },
+      _owner: {
+        type: Boolean,
+        value: false,
+      },
+      _options: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      /** @type {?} */
+      _groupConfig: Object,
+      _groupConfigOwner: String,
+      _groupName: Object,
+      _groupOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _submitTypes: {
+        type: Array,
+        value() {
+          return Object.values(OPTIONS);
+        },
+      },
+      _query: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
+        },
+      },
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    observers: [
+      '_handleConfigName(_groupConfig.name)',
+      '_handleConfigOwner(_groupConfig.owner, _groupConfigOwner)',
+      '_handleConfigDescription(_groupConfig.description)',
+      '_handleConfigOptions(_groupConfig.options.visible_to_all)',
+    ],
+
+    attached() {
+      this._loadGroup();
+    },
+
+    _loadGroup() {
+      if (!this.groupId) { return; }
+
+      return this.$.restAPI.getGroupConfig(this.groupId).then(
+          config => {
+            this._groupName = config.name;
+
+            this.$.restAPI.getIsAdmin().then(isAdmin => {
+              this._isAdmin = isAdmin ? true : false;
+            });
+
+            this.$.restAPI.getIsGroupOwner(config.name)
+                .then(isOwner => {
+                  this._groupOwner = isOwner ? true : false;
+                });
+
+            // If visible to all is undefined, set to false. If it is defined
+            // as false, setting to false is fine. If any optional values
+            // are added with a default of true, then this would need to be an
+            // undefined check and not a truthy/falsy check.
+            if (!config.options.visible_to_all) {
+              config.options.visible_to_all = false;
+            }
+            this._groupConfig = config;
+
+            this.fire('title-change', {title: config.name});
+
+            this._loading = false;
+          });
+    },
+
+    _computeLoadingClass(loading) {
+      return loading ? 'loading' : '';
+    },
+
+    _isLoading() {
+      return this._loading || this._loading === undefined;
+    },
+
+    _handleSaveName() {
+      return this.$.restAPI.saveGroupName(this.groupId, this._groupConfig.name)
+          .then(config => {
+            if (config.status === 200) {
+              this._groupName = this._groupConfig.name;
+              this.fire('name-changed', {name: this._groupConfig.name});
+              this._rename = false;
+            }
+          });
+    },
+
+    _handleSaveOwner() {
+      let owner = this._groupConfig.owner;
+      if (this._groupConfigOwner) {
+        owner = decodeURIComponent(this._groupConfigOwner);
+      }
+      return this.$.restAPI.saveGroupOwner(this.groupId,
+          owner).then(config => {
+            this._owner = false;
+          });
+    },
+
+    _handleSaveDescription() {
+      return this.$.restAPI.saveGroupDescription(this.groupId,
+          this._groupConfig.description).then(config => {
+            this._description = false;
+          });
+    },
+
+    _handleSaveOptions() {
+      let options;
+      // The value is in string so we have to convert it to a boolean.
+      if (this._groupConfig.options.visible_to_all) {
+        options = {visible_to_all: true};
+      } else if (!this._groupConfig.options.visible_to_all) {
+        options = {visible_to_all: false};
+      }
+      return this.$.restAPI.saveGroupOptions(this.groupId,
+          options).then(config => {
+            this._options = false;
+          });
+    },
+
+    _handleConfigName() {
+      if (this._isLoading()) { return; }
+      this._rename = true;
+    },
+
+    _handleConfigOwner() {
+      if (this._isLoading()) { return; }
+      this._owner = true;
+    },
+
+    _handleConfigDescription() {
+      if (this._isLoading()) { return; }
+      this._description = true;
+    },
+
+    _handleConfigOptions() {
+      if (this._isLoading()) { return; }
+      this._options = true;
+    },
+
+    _computeHeaderClass(configChanged) {
+      return configChanged ? 'edited' : '';
+    },
+
+    _getGroupSuggestions(input) {
+      return this.$.restAPI.getSuggestedGroups(input)
+          .then(response => {
+            const groups = [];
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              groups.push({
+                name: key,
+                value: decodeURIComponent(response[key].id),
+              });
+            }
+            return groups;
+          });
+    },
+
+    _computeGroupDisabled(owner, admin) {
+      return admin || owner ? false : true;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
new file mode 100644
index 0000000..7789cd5
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-group/gr-group_test.html
@@ -0,0 +1,170 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-group</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-group.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-group></gr-group>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-group tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getGroupConfig() {
+          return Promise.resolve({
+            id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+            url: '#/admin/groups/uuid-6a1e70e1a88782771a91808c8af9bbb7a9871389',
+            options: {
+            },
+            description: 'Gerrit Site Administrators',
+            group_id: 1,
+            owner: 'Administrators',
+            owner_id: '6a1e70e1a88782771a91808c8af9bbb7a9871389',
+          });
+        },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('loading displays before group config is loaded', () => {
+      assert.isTrue(element.$.loading.classList.contains('loading'));
+      assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
+      assert.isTrue(element.$.loadedContent.classList.contains('loading'));
+      assert.isTrue(getComputedStyle(element.$.loadedContent)
+          .display === 'none');
+    });
+
+    test('default values are populated', done => {
+      sandbox.stub(element.$.restAPI, 'getIsGroupOwner', () => {
+        return Promise.resolve(true);
+      });
+      element.groupId = 1;
+      element._loadGroup().then(() => {
+        assert.isFalse(element.$.visibleToAll.bindValue);
+        done();
+      });
+    });
+
+    test('rename group', done => {
+      const groupName = 'test-group';
+      const groupName2 = 'test-group2';
+      element.groupId = 1;
+      element._groupConfig = {
+        name: groupName,
+      };
+      element._groupConfigOwner = 'testId';
+      element._groupName = groupName;
+      element._groupOwner = true;
+
+      sandbox.stub(element.$.restAPI, 'getIsGroupOwner', () => {
+        return Promise.resolve(true);
+      });
+
+      sandbox.stub(element.$.restAPI, 'saveGroupName', () => {
+        return Promise.resolve({status: 200});
+      });
+
+      const button = element.$.inputUpdateNameBtn;
+
+      element._loadGroup().then(() => {
+        assert.isTrue(button.hasAttribute('disabled'));
+        assert.isFalse(element.$.Title.classList.contains('edited'));
+
+        element.$.groupNameInput.text = groupName2;
+
+        element.$.groupOwnerInput.text = 'testId2';
+
+        assert.isFalse(button.hasAttribute('disabled'));
+        assert.isTrue(element.$.groupName.classList.contains('edited'));
+
+        element._handleSaveName().then(() => {
+          assert.isTrue(button.hasAttribute('disabled'));
+          assert.isFalse(element.$.Title.classList.contains('edited'));
+          assert.equal(element._groupName, groupName2);
+          done();
+        });
+
+        element._handleSaveOwner().then(() => {
+          assert.isTrue(button.hasAttribute('disabled'));
+          assert.isFalse(element.$.Title.classList.contains('edited'));
+          assert.equal(element._groupConfigOwner, 'testId2');
+          done();
+        });
+      });
+    });
+
+    test('test fire event', done => {
+      element._groupConfig = {
+        name: 'test-group',
+      };
+
+      sandbox.stub(element.$.restAPI, 'saveGroupName')
+          .returns(Promise.resolve({status: 200}));
+
+      const showStub = sandbox.stub(element, 'fire');
+      element._handleSaveName()
+          .then(() => {
+            assert.isTrue(showStub.called);
+            done();
+          });
+    });
+
+    test('_computeGroupDisabled return false for admin', () => {
+      const admin = true;
+      const owner = false;
+      assert.equal(element._computeGroupDisabled(owner, admin), false);
+    });
+
+    test('_computeGroupDisabled return true for admin', () => {
+      const admin = false;
+      const owner = false;
+      assert.equal(element._computeGroupDisabled(owner, admin), true);
+    });
+
+    test('_computeGroupDisabled return false for owner', () => {
+      const admin = false;
+      const owner = true;
+      assert.equal(element._computeGroupDisabled(owner, admin), false);
+    });
+
+    test('_computeGroupDisabled return true for owner', () => {
+      const admin = false;
+      const owner = false;
+      assert.equal(element._computeGroupDisabled(owner, admin), true);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
new file mode 100644
index 0000000..31171f7
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/gr-menu-page-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-rule-editor/gr-rule-editor.html">
+
+<dom-module id="gr-permission">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        margin-bottom: .7em;
+      }
+      .header {
+        align-items: baseline;
+        display: flex;
+        justify-content: space-between;
+        margin: .3em .7em;
+      }
+      #deletedContainer {
+        border: 1px solid #d1d2d3;
+        padding: .7em;
+      }
+      .rules {
+        background: #fafafa;
+        border: 1px solid #d1d2d3;
+        border-bottom: 0;
+      }
+      .editing .rules {
+        border-bottom: 1px solid #d1d2d3;
+      }
+      .title {
+        margin-bottom: .3em;
+      }
+      #addRule,
+      #removeBtn {
+        display: none;
+      }
+      .editing #removeBtn {
+        display: block;
+      }
+      .editing #addRule {
+        display: block;
+        padding: .7em;
+      }
+      #deletedContainer,
+      .deleted #mainContainer {
+        display: none;
+      }
+      .deleted #deletedContainer,
+      #mainContainer {
+        display: block;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <style include="gr-menu-page-styles"></style>
+    <section
+        id="permission"
+        class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
+      <div id="mainContainer">
+        <div class="header">
+          <span class="title">[[name]]</span>
+          <gr-button
+              id="removeBtn"
+              on-tap="_handleRemovePermission">Remove</gr-button>
+        </div><!-- end header -->
+        <div class="rules">
+          <template
+              is="dom-repeat"
+              items="{{_rules}}"
+              as="rule">
+            <gr-rule-editor
+                label="[[_label]]"
+                editing="[[editing]]"
+                group-id="[[rule.id]]"
+                group-name="[[_computeGroupName(groups, rule.id)]]"
+                permission="[[permission.id]]"
+                rule="{{rule}}"
+                section="[[section]]"></gr-rule-editor>
+          </template>
+          <div id="addRule">
+            <gr-autocomplete
+                text="{{_groupFilter}}"
+                query="[[_query]]"
+                placeholder="Add group"
+                on-commit="_handleAddRuleItem">
+            </gr-autocomplete>
+          </div> <!-- end addRule -->
+        </div> <!-- end rules -->
+      </div><!-- end mainContainer -->
+      <div id="deletedContainer">
+        [[name]] was deleted
+        <gr-button
+            id="undoRemoveBtn"
+            on-tap="_handleUndoRemove">Undo</gr-button>
+      </div><!-- end deletedContainer -->
+    </section>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-permission.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
new file mode 100644
index 0000000..2ba1eed
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission.js
@@ -0,0 +1,184 @@
+// 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.
+(function() {
+  'use strict';
+
+  const MAX_AUTOCOMPLETE_RESULTS = 20;
+
+  Polymer({
+    is: 'gr-permission',
+
+    properties: {
+      labels: Object,
+      name: String,
+      /** @type {?} */
+      permission: {
+        type: Object,
+        observer: '_sortPermission',
+        notify: true,
+      },
+      groups: Object,
+      section: String,
+      editing: {
+        type: Boolean,
+        value: false,
+      },
+      _label: {
+        type: Object,
+        computed: '_computeLabel(permission, labels)',
+      },
+      _groupFilter: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getGroupSuggestions.bind(this);
+        },
+      },
+      _rules: Array,
+      _groupsWithRules: Object,
+      _deleted: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    behaviors: [
+      Gerrit.AccessBehavior,
+    ],
+
+    observers: [
+      '_handleRulesChanged(_rules.splices)',
+    ],
+
+    _handleRulesChanged(changeRecord) {
+      // Update the groups to exclude in the autocomplete.
+      this._groupsWithRules = this._computeGroupsWithRules(this._rules);
+    },
+
+    _sortPermission(permission) {
+      this._rules = this.toSortedArray(permission.value.rules);
+    },
+
+    _handleRemovePermission() {
+      this._deleted = true;
+      this.set('permission.value.deleted', true);
+    },
+
+    _computeSectionClass(editing, deleted) {
+      const classList = [];
+      if (editing) {
+        classList.push('editing');
+      }
+      if (deleted) {
+        classList.push('deleted');
+      }
+      return classList.join(' ');
+    },
+
+    _handleUndoRemove() {
+      this._deleted = false;
+      delete this.permission.value.deleted;
+    },
+
+    _computeLabel(permission, labels) {
+      if (!permission.value.label) { return; }
+
+      const labelName = permission.value.label;
+
+      // It is possible to have a label name that is not included in the
+      // 'labels' object. In this case, treat it like anything else.
+      if (!labels[labelName]) { return; }
+      const label = {
+        name: labelName,
+        values: this._computeLabelValues(labels[labelName].values),
+      };
+      return label;
+    },
+
+    _computeLabelValues(values) {
+      const valuesArr = [];
+      const keys = Object.keys(values).sort((a, b) => {
+        return parseInt(a, 10) - parseInt(b, 10);
+      });
+
+      for (const key of keys) {
+        if (!values[key]) { return; }
+        // The value from the server being used to choose which item is
+        // selected is in integer form, so this must be converted.
+        valuesArr.push({value: parseInt(key, 10), text: values[key]});
+      }
+      return valuesArr;
+    },
+
+    /**
+     * @param {!Array} rules
+     * @return {!Object} Object with groups with rues as keys, and true as
+     *    value.
+     */
+    _computeGroupsWithRules(rules) {
+      const groups = {};
+      for (const rule of rules) {
+        groups[rule.id] = true;
+      }
+      return groups;
+    },
+
+    _computeGroupName(groups, groupId) {
+      return groups && groups[groupId] && groups[groupId].name ?
+          groups[groupId].name : groupId;
+    },
+
+    _getGroupSuggestions() {
+      return this.$.restAPI.getSuggestedGroups(
+          this._groupFilter,
+          MAX_AUTOCOMPLETE_RESULTS)
+          .then(response => {
+            const groups = [];
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              groups.push({
+                name: key,
+                value: response[key],
+              });
+            }
+            // Does not return groups in which we already have rules for.
+            return groups.filter(group => {
+              return !this._groupsWithRules[group.value.id];
+            });
+          });
+    },
+
+    /**
+     * Handles adding a skeleton item to the dom-repeat.
+     * gr-rule-editor handles setting the default values.
+     */
+    _handleAddRuleItem(e) {
+      this.set(['permission', 'value', 'rules', e.detail.value.id], {});
+
+      // Purposely don't recompute sorted array so that the newly added rule
+      // is the last item of the array.
+      this.push('_rules', {
+        id: e.detail.value.id,
+      });
+
+      // Wait for new rule to get value populated via gr-rule editor, and then
+      // add to permission values as well, so that the change gets propogated
+      // back to the section. Since the rule is inside a dom-repeat, a flush
+      // is needed.
+      Polymer.dom.flush();
+      this.set(['permission', 'value', 'rules', e.detail.value.id],
+          this._rules[this._rules.length - 1].value);
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
new file mode 100644
index 0000000..179d221
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-permission/gr-permission_test.html
@@ -0,0 +1,331 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-permission</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-permission.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-permission></gr-permission>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-permission tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      sandbox.stub(element.$.restAPI, 'getSuggestedGroups').returns(
+          Promise.resolve({
+            'Administrators': {
+              id: '4c97682e6ce61b7247f3381b6f1789356666de7f',
+            },
+            'Anonymous Users': {
+              id: 'global%3AAnonymous-Users',
+            },
+          }));
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('unit tests', () => {
+      test('_sortPermission', () => {
+        const permission = {
+          id: 'submit',
+          value: {
+            rules: {
+              'global:Project-Owners': {
+                action: 'ALLOW',
+                force: false,
+              },
+              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+                action: 'ALLOW',
+                force: false,
+              },
+            },
+          },
+        };
+
+        const expectedRules = [
+          {
+            id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+            value: {action: 'ALLOW', force: false},
+          },
+          {
+            id: 'global:Project-Owners',
+            value: {action: 'ALLOW', force: false},
+          },
+        ];
+
+        element._sortPermission(permission);
+        assert.deepEqual(element._rules, expectedRules);
+      });
+
+      test('_computeLabel and _computeLabelValues', () => {
+        const labels = {
+          'Code-Review': {
+            default_value: 0,
+            values: {
+              ' 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',
+            },
+          },
+        };
+        let permission = {
+          id: 'label-Code-Review',
+          value: {
+            label: 'Code-Review',
+            rules: {
+              'global:Project-Owners': {
+                action: 'ALLOW',
+                force: false,
+                min: -2,
+                max: 2,
+              },
+              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+                action: 'ALLOW',
+                force: false,
+                min: -2,
+                max: 2,
+              },
+            },
+          },
+        };
+
+        const expectedLabelValues = [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: 0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ];
+
+        const expectedLabel = {
+          name: 'Code-Review',
+          values: expectedLabelValues,
+        };
+
+        assert.deepEqual(element._computeLabelValues(
+            labels['Code-Review'].values), expectedLabelValues);
+
+        assert.deepEqual(element._computeLabel(permission, labels),
+            expectedLabel);
+
+        permission = {
+          id: 'label-reviewDB',
+          value: {
+            label: 'reviewDB',
+            rules: {
+              'global:Project-Owners': {
+                action: 'ALLOW',
+                force: false,
+              },
+              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+                action: 'ALLOW',
+                force: false,
+              },
+            },
+          },
+        };
+
+        assert.isNotOk(element._computeLabel(permission, labels));
+      });
+
+      test('_computeSectionClass', () => {
+        let deleted = true;
+        let editing = false;
+        assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
+
+        deleted = false;
+        assert.equal(element._computeSectionClass(editing, deleted), '');
+
+        editing = true;
+        assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+        deleted = true;
+        assert.equal(element._computeSectionClass(editing, deleted),
+            'editing deleted');
+      });
+
+      test('_computeGroupName', () => {
+        const groups = {
+          abc123: {name: 'test group'},
+          bcd234: {},
+        };
+        assert.equal(element._computeGroupName(groups, 'abc123'), 'test group');
+        assert.equal(element._computeGroupName(groups, 'bcd234'), 'bcd234');
+      });
+
+      test('_computeGroupsWithRules', () => {
+        const rules = [
+          {
+            id: '4c97682e6ce6b7247f3381b6f1789356666de7f',
+            value: {action: 'ALLOW', force: false},
+          },
+          {
+            id: 'global:Project-Owners',
+            value: {action: 'ALLOW', force: false},
+          },
+        ];
+        const groupsWithRules = {
+          '4c97682e6ce6b7247f3381b6f1789356666de7f': true,
+          'global:Project-Owners': true,
+        };
+        assert.deepEqual(element._computeGroupsWithRules(rules),
+            groupsWithRules);
+      });
+
+      test('_getGroupSuggestions without existing rules', done => {
+        element._groupsWithRules = {};
+
+        element._getGroupSuggestions().then(groups => {
+          assert.deepEqual(groups, [
+            {
+              name: 'Administrators',
+              value: {id: '4c97682e6ce61b7247f3381b6f1789356666de7f'},
+            }, {
+              name: 'Anonymous Users',
+              value: {id: 'global%3AAnonymous-Users'},
+            },
+          ]);
+          done();
+        });
+      });
+
+      test('_getGroupSuggestions with existing rules filters them', done => {
+        element._groupsWithRules = {
+          '4c97682e6ce61b7247f3381b6f1789356666de7f': true,
+        };
+
+        element._getGroupSuggestions().then(groups => {
+          assert.deepEqual(groups, [{
+            name: 'Anonymous Users',
+            value: {id: 'global%3AAnonymous-Users'},
+          }]);
+          done();
+        });
+      });
+
+      test('_handleRemovePermission', () => {
+        element.permission = {value: {rules: {}}};
+        element._handleRemovePermission();
+        assert.isTrue(element._deleted);
+        assert.isTrue(element.permission.value.deleted);
+      });
+
+      test('_handleUndoRemove', () => {
+        element.permission = {value: {deleted: true, rules: {}}};
+        element._handleUndoRemove();
+        assert.isFalse(element._deleted);
+        assert.isNotOk(element.permission.value.deleted);
+      });
+    });
+
+    suite('interactions', () => {
+      setup(() => {
+        sandbox.spy(element, '_computeLabel');
+        element.name = 'Priority';
+        element.section = 'refs/*';
+        element.labels = {
+          'Code-Review': {
+            values: {
+              ' 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',
+            },
+            default_value: 0,
+          },
+        };
+        element.permission = {
+          id: 'label-Code-Review',
+          value: {
+            label: 'Code-Review',
+            rules: {
+              'global:Project-Owners': {
+                action: 'ALLOW',
+                force: false,
+                min: -2,
+                max: 2,
+              },
+              '4c97682e6ce6b7247f3381b6f1789356666de7f': {
+                action: 'ALLOW',
+                force: false,
+                min: -2,
+                max: 2,
+              },
+            },
+          },
+        };
+        flushAsynchronousOperations();
+      });
+
+      test('adding a rule', () => {
+        element.name = 'Priority';
+        element.section = 'refs/*';
+        const e = {
+          detail: {
+            value: {
+              id: 'newUserGroupId',
+            },
+          },
+        };
+
+        assert.equal(element._rules.length, 2);
+        assert.equal(Object.keys(element._groupsWithRules).length, 2);
+        element._handleAddRuleItem(e);
+        flushAsynchronousOperations();
+        assert.equal(element._rules.length, 3);
+        assert.equal(Object.keys(element._groupsWithRules).length, 3);
+        assert.deepEqual(element.permission.value.rules['newUserGroupId'],
+            {action: 'ALLOW', min: -2, max: 2});
+      });
+
+      test('removing the permission', () => {
+        element.editing = true;
+        element.name = 'Priority';
+        element.section = 'refs/*';
+
+        assert.isFalse(element.$.permission.classList.contains('deleted'));
+        assert.isFalse(element._deleted);
+        MockInteractions.tap(element.$.removeBtn);
+        assert.isTrue(element.$.permission.classList.contains('deleted'));
+        assert.isTrue(element._deleted);
+        MockInteractions.tap(element.$.undoRemoveBtn);
+        assert.isFalse(element.$.permission.classList.contains('deleted'));
+        assert.isFalse(element._deleted);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
new file mode 100644
index 0000000..7d12e96
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.html
@@ -0,0 +1,60 @@
+<!--
+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.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-plugin-list">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-table-styles"></style>
+    <gr-list-view
+        filter="[[_filter]]"
+        items-per-page="[[_pluginsPerPage]]"
+        items="[[_plugins]]"
+        loading="[[_loading]]"
+        offset="[[_offset]]"
+        path="[[_path]]">
+      <table id="list" class="genericList">
+        <tr class="headerRow">
+          <th class="name topHeader">Plugin Name</th>
+          <th class="version topHeader">Version</th>
+          <th class="status topHeader">Status</th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+        <tbody class$="[[computeLoadingClass(_loading)]]">
+          <template is="dom-repeat" items="[[_shownPlugins]]">
+            <tr class="table">
+              <td class="name">
+                <a href$="[[_computePluginUrl(item.index_url)]]">[[item.id]]</a>
+              </td>
+              <td class="version">[[item.version]]</td>
+              <td class="status">[[_status(item)]]</td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </gr-list-view>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-plugin-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
new file mode 100644
index 0000000..d441407
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list.js
@@ -0,0 +1,105 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-plugin-list',
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: {
+        type: Number,
+        value: 0,
+      },
+      _path: {
+        type: String,
+        readOnly: true,
+        value: '/admin/plugins',
+      },
+      _plugins: Array,
+      /**
+       * Because  we request one more than the pluginsPerPage, _shownPlugins
+       * maybe one less than _plugins.
+       * */
+      _shownPlugins: {
+        type: Array,
+        computed: 'computeShownItems(_plugins)',
+      },
+      _pluginsPerPage: {
+        type: Number,
+        value: 25,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: {
+        type: String,
+        value: '',
+      },
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+    ],
+
+    attached() {
+      this.fire('title-change', {title: 'Plugins'});
+    },
+
+    _paramsChanged(params) {
+      this._loading = true;
+      this._filter = this.getFilterValue(params);
+      this._offset = this.getOffsetValue(params);
+
+      return this._getPlugins(this._filter, this._pluginsPerPage,
+          this._offset);
+    },
+
+    _getPlugins(filter, pluginsPerPage, offset) {
+      return this.$.restAPI.getPlugins(filter, pluginsPerPage, offset)
+          .then(plugins => {
+            if (!plugins) {
+              this._plugins = [];
+              return;
+            }
+            this._plugins = Object.keys(plugins)
+             .map(key => {
+               const plugin = plugins[key];
+               plugin.name = key;
+               return plugin;
+             });
+            this._loading = false;
+          });
+    },
+
+    _status(item) {
+      return item.disabled === true ? 'Disabled' : 'Enabled';
+    },
+
+    _computePluginUrl(id) {
+      return this.getUrl('/', id);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
new file mode 100644
index 0000000..52f1a22
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-plugin-list/gr-plugin-list_test.html
@@ -0,0 +1,140 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-list</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-plugin-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-plugin-list></gr-plugin-list>
+  </template>
+</test-fixture>
+
+<script>
+  let counter;
+  const pluginGenerator = () => {
+    return {
+      id: `test${++counter}`,
+      index_url: `plugins/test${counter}/`,
+      version: '3.0-SNAPSHOT',
+      disabled: false,
+    };
+  };
+
+  suite('gr-plugin-list tests', () => {
+    let element;
+    let plugins;
+    let sandbox;
+    let value;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      counter = 0;
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list with plugins', () => {
+      setup(done => {
+        plugins = _.times(26, pluginGenerator);
+
+        stub('gr-rest-api-interface', {
+          getPlugins(num, offset) {
+            return Promise.resolve(plugins);
+          },
+        });
+
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('plugin in the list is formatted correctly', done => {
+        flush(() => {
+          assert.equal(element._plugins[2].id, 'test3');
+          assert.equal(element._plugins[2].index_url, 'plugins/test3/');
+          assert.equal(element._plugins[2].version, '3.0-SNAPSHOT');
+          assert.equal(element._plugins[2].disabled, false);
+          done();
+        });
+      });
+
+      test('_shownPlugins', () => {
+        assert.equal(element._shownPlugins.length, 25);
+      });
+    });
+
+    suite('list with less then 26 plugins', () => {
+      setup(done => {
+        plugins = _.times(25, pluginGenerator);
+
+        stub('gr-rest-api-interface', {
+          getPlugins(num, offset) {
+            return Promise.resolve(plugins);
+          },
+        });
+
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('_shownPlugins', () => {
+        assert.equal(element._shownPlugins.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getPlugins', () => {
+          return Promise.resolve(plugins);
+        });
+        const value = {
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(value).then(() => {
+          assert.isTrue(element.$.restAPI.getPlugins.lastCall
+              .calledWithExactly('test', 25, 25));
+          done();
+        });
+      });
+    });
+
+    suite('loading', () => {
+      test('correct contents are displayed', () => {
+        assert.isTrue(element._loading);
+        assert.equal(element.computeLoadingClass(element._loading), 'loading');
+        assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+        element._loading = false;
+        element._plugins = _.times(25, pluginGenerator);
+
+        flushAsynchronousOperations();
+        assert.equal(element.computeLoadingClass(element._loading), '');
+        assert.equal(getComputedStyle(element.$.loading).display, 'none');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.html b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.html
new file mode 100644
index 0000000..ca3abc4
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.html
@@ -0,0 +1,69 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+
+<link rel="import" href="../../../styles/gr-menu-page-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-access-section/gr-access-section.html">
+
+<script src="../../../scripts/util.js"></script>
+
+<dom-module id="gr-project-access">
+  <template>
+    <style include="shared-styles">
+      .gwtLink {
+        margin-bottom: 1em;
+      }
+      .gwtLink {
+        display: none;
+      }
+      .admin .gwtLink {
+        display: block;
+      }
+    </style>
+    <style include="gr-menu-page-styles"></style>
+    <main class$="[[_computeAdminClass(_isAdmin)]]">
+      <div class="gwtLink">This is currently in read only mode.  To modify content, go to the
+        <a href$="[[computeGwtUrl(path)]]" rel="external">Old UI</a>
+      </div>
+      <template is="dom-if" if="[[_inheritsFrom]]">
+        <h3 id="inheritsFrom">Rights Inherit From
+          <a href$="[[_computeParentHref(_inheritsFrom.name)]]" rel="noopener">
+              [[_inheritsFrom.name]]</a>
+        </h3>
+      </template>
+      <template
+          is="dom-repeat"
+          items="{{_sections}}"
+          as="section">
+        <gr-access-section
+            capabilities="[[_capabilities]]"
+            section="{{section}}"
+            labels="[[_labels]]"
+            editing="[[_editing]]"
+            groups="[[_groups]]"></gr-access-section>
+      </template>
+    </main>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-project-access.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.js b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.js
new file mode 100644
index 0000000..d736dac
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access.js
@@ -0,0 +1,95 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-project-access',
+
+    properties: {
+      project: {
+        type: String,
+        observer: '_projectChanged',
+      },
+      // The current path
+      path: String,
+
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
+      _capabilities: Object,
+      _groups: Object,
+      /** @type {?} */
+      _inheritsFrom: Object,
+      _labels: Object,
+      _local: Object,
+      _editing: {
+        type: Boolean,
+        value: false,
+      },
+      _sections: Array,
+    },
+
+    behaviors: [
+      Gerrit.AccessBehavior,
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    /**
+     * @param {string} project
+     * @return {!Promise}
+     */
+    _projectChanged(project) {
+      if (!project) { return Promise.resolve(); }
+      const promises = [];
+      // Always reset sections when a project changes.
+      this._sections = [];
+      promises.push(this.$.restAPI.getProjectAccessRights(project).then(res => {
+        this._inheritsFrom = res.inherits_from;
+        this._local = res.local;
+        this._groups = res.groups;
+        return this.toSortedArray(this._local);
+      }));
+
+      promises.push(this.$.restAPI.getCapabilities().then(res => {
+        return res;
+      }));
+
+      promises.push(this.$.restAPI.getProject(project).then(res => {
+        return res.labels;
+      }));
+
+      promises.push(this.$.restAPI.getIsAdmin().then(isAdmin => {
+        this._isAdmin = isAdmin;
+      }));
+
+      return Promise.all(promises).then(([sections, capabilities, labels]) => {
+        this._capabilities = capabilities;
+        this._labels = labels;
+        this._sections = sections;
+      });
+    },
+
+    _computeAdminClass(isAdmin) {
+      return isAdmin ? 'admin' : '';
+    },
+
+    _computeParentHref(projectName) {
+      return this.getBaseUrl() +
+          `/admin/projects/${this.encodeURL(projectName, true)},access`;
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access_test.html b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access_test.html
new file mode 100644
index 0000000..8ea14cf
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-access/gr-project-access_test.html
@@ -0,0 +1,198 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-project-access</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-project-access.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-project-access></gr-project-access>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-project-access tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_projectChanged called when project name changes', () => {
+      sandbox.stub(element, '_projectChanged');
+      element.project = 'New Project';
+      assert.isTrue(element._projectChanged.called);
+    });
+
+    test('_projectChanged', done => {
+      const capabilitiesRes = {
+        accessDatabase: {
+          id: 'accessDatabase',
+          name: 'Access Database',
+        },
+        createAccount: {
+          id: 'createAccount',
+          name: 'Create Account',
+        },
+      };
+      const accessRes = {
+        local: {
+          'refs/*': {
+            permissions: {
+              owner: {
+                rules: {
+                  234: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      const accessRes2 = {
+        local: {
+          GLOBAL_CAPABILITIES: {
+            permissions: {
+              accessDatabase: {
+                rules: {
+                  group1: {
+                    action: 'ALLOW',
+                  },
+                },
+              },
+            },
+          },
+        },
+      };
+      const projectRes = {
+        labels: {
+          'Code-Review': {},
+        },
+      };
+      const accessStub = sandbox.stub(element.$.restAPI,
+          'getProjectAccessRights');
+
+
+      accessStub.withArgs('New Project').returns(Promise.resolve(accessRes));
+      accessStub.withArgs('Another New Project')
+          .returns(Promise.resolve(accessRes2));
+      const capabilitiesStub = sandbox.stub(element.$.restAPI,
+          'getCapabilities');
+      capabilitiesStub.returns(Promise.resolve(capabilitiesRes));
+      const projectStub = sandbox.stub(element.$.restAPI, 'getProject').returns(
+          Promise.resolve(projectRes));
+      const adminStub = sandbox.stub(element.$.restAPI, 'getIsAdmin').returns(
+          Promise.resolve(true));
+
+      element._projectChanged('New Project').then(() => {
+        assert.isTrue(accessStub.called);
+        assert.isTrue(capabilitiesStub.called);
+        assert.isTrue(projectStub.called);
+        assert.isTrue(adminStub.called);
+        assert.isNotOk(element._inheritsFrom);
+        assert.deepEqual(element._local, accessRes.local);
+        assert.deepEqual(element._sections,
+            element.toSortedArray(accessRes.local));
+        assert.deepEqual(element._labels, projectRes.labels);
+        return element._projectChanged('Another New Project');
+      })
+          .then(() => {
+            assert.deepEqual(element._sections,
+                element.toSortedArray(accessRes2.local));
+            done();
+          });
+    });
+
+    test('_projectChanged when project changes to undefined returns', done => {
+      const capabilitiesRes = {
+        accessDatabase: {
+          id: 'accessDatabase',
+          name: 'Access Database',
+        },
+      };
+      const accessRes = {
+        local: {
+          GLOBAL_CAPABILITIES: {
+            permissions: {
+              accessDatabase: {
+                rules: {
+                  123: {},
+                },
+              },
+            },
+          },
+        },
+      };
+      const projectRes = {
+        labels: {
+          'Code-Review': {},
+        },
+      };
+      const accessStub = sandbox.stub(element.$.restAPI,
+          'getProjectAccessRights').returns(Promise.resolve(accessRes));
+      const capabilitiesStub = sandbox.stub(element.$.restAPI,
+          'getCapabilities').returns(Promise.resolve(capabilitiesRes));
+      const projectStub = sandbox.stub(element.$.restAPI, 'getProject').returns(
+          Promise.resolve(projectRes));
+
+      element._projectChanged().then(() => {
+        assert.isFalse(accessStub.called);
+        assert.isFalse(capabilitiesStub.called);
+        assert.isFalse(projectStub.called);
+        done();
+      });
+    });
+
+    test('_computeParentHref', () => {
+      const projectName = 'test-project';
+      assert.equal(element._computeParentHref(projectName),
+          '/admin/projects/test-project,access');
+    });
+
+    test('_computeAdminClass', () => {
+      let isAdmin = true;
+      assert.equal(element._computeAdminClass(isAdmin), 'admin');
+      isAdmin = false;
+      assert.equal(element._computeAdminClass(isAdmin), '');
+    });
+
+    test('inherit section', () => {
+      sandbox.stub(element, '_computeParentHref');
+      assert.isNotOk(Polymer.dom(element.root).querySelector('#inheritsFrom'));
+      assert.isFalse(element._computeParentHref.called);
+      element._inheritsFrom = {
+        name: 'another-project',
+      };
+      flushAsynchronousOperations();
+      assert.isOk(Polymer.dom(element.root).querySelector('#inheritsFrom'));
+      assert.isTrue(element._computeParentHref.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html
new file mode 100644
index 0000000..6c0908a
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.html
@@ -0,0 +1,92 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-create-change-dialog/gr-create-change-dialog.html">
+
+<dom-module id="gr-project-commands">
+  <template>
+    <style include="shared-styles">
+      main {
+        margin: 2em 1em;
+      }
+      .loading {
+        display: none;
+      }
+      #loading.loading {
+        display: block;
+      }
+      #loading:not(.loading) {
+        display: none;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <main class="gr-form-styles read-only">
+      <h1 id="Title">Project Commands</h1>
+      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
+      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+        <h2 id="options">Command</h2>
+        <div id="form">
+          <fieldset>
+            <h3 id="createChange">Create Change</h3>
+            <fieldset>
+              <gr-button id="createNewChange" on-tap="_createNewChange">
+                Create Change
+              </gr-button>
+            </fieldset>
+            <h3 id="runGC" hidden$="[[!_projectConfig.actions.gc.enabled]]">
+                Run GC
+            </h3>
+            <fieldset>
+              <gr-button
+                  on-tap="_handleRunningGC"
+                  hidden$="[[!_projectConfig.actions.gc.enabled]]">
+                Run GC
+              </gr-button>
+            </fieldset>
+          </fieldset>
+        </div>
+      </div>
+    </main>
+    <gr-overlay id="createChangeOverlay" with-backdrop>
+      <gr-confirm-dialog
+          id="createChangeDialog"
+          confirm-label="Create"
+          disabled="[[!_canCreate]]"
+          on-confirm="_handleCreateChange"
+          on-cancel="_handleCloseCreateChange">
+        <div class="header">
+          Create Change
+        </div>
+        <div class="main">
+          <gr-create-change-dialog
+              id="createNewChangeModal"
+              can-create="{{_canCreate}}"
+              project-name="[[project]]"></gr-create-change-dialog>
+        </div>
+      </gr-confirm-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-project-commands.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.js b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.js
new file mode 100644
index 0000000..88cf058
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands.js
@@ -0,0 +1,80 @@
+// 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.
+(function() {
+  'use strict';
+
+  const GC_MESSAGE = 'Garbage collection completed successfully.';
+
+  Polymer({
+    is: 'gr-project-commands',
+
+    properties: {
+      params: Object,
+      project: String,
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      /** @type {?} */
+      _projectConfig: Object,
+      _canCreate: Boolean,
+    },
+
+    attached() {
+      this._loadProject();
+
+      this.fire('title-change', {title: 'Project Commands'});
+    },
+
+    _loadProject() {
+      if (!this.project) { return Promise.resolve(); }
+
+      return this.$.restAPI.getProjectConfig(this.project).then(
+          config => {
+            this._projectConfig = config;
+            this._loading = false;
+          });
+    },
+
+    _computeLoadingClass(loading) {
+      return loading ? 'loading' : '';
+    },
+
+    _isLoading() {
+      return this._loading || this._loading === undefined;
+    },
+
+    _handleRunningGC() {
+      return this.$.restAPI.runProjectGC(this.project).then(response => {
+        if (response.status === 200) {
+          this.dispatchEvent(new CustomEvent('show-alert',
+              {detail: {message: GC_MESSAGE}, bubbles: true}));
+        }
+      });
+    },
+
+    _createNewChange() {
+      this.$.createChangeOverlay.open();
+    },
+
+    _handleCreateChange() {
+      this.$.createNewChangeModal.handleCreateChange();
+      this._handleCloseCreateChange();
+    },
+
+    _handleCloseCreateChange() {
+      this.$.createChangeOverlay.close();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands_test.html b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands_test.html
new file mode 100644
index 0000000..693f07e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-commands/gr-project-commands_test.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-project-commands</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-project-commands.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-project-commands></gr-project-commands>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-project-commands tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('create new change dialog', () => {
+      test('_createNewChange opens modal', () => {
+        const openStub = sandbox.stub(element.$.createChangeOverlay, 'open');
+        element._createNewChange();
+        assert.isTrue(openStub.called);
+      });
+
+      test('_handleCreateChange called when confirm fired', () => {
+        sandbox.stub(element, '_handleCreateChange');
+        element.$.createChangeDialog.fire('confirm');
+        assert.isTrue(element._handleCreateChange.called);
+      });
+
+      test('_handleCloseCreateChange called when cancel fired', () => {
+        sandbox.stub(element, '_handleCloseCreateChange');
+        element.$.createChangeDialog.fire('cancel');
+        assert.isTrue(element._handleCloseCreateChange.called);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.html b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.html
new file mode 100644
index 0000000..2effc20
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.html
@@ -0,0 +1,173 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-create-pointer-dialog/gr-create-pointer-dialog.html">
+<link rel="import" href="../gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog.html">
+
+<dom-module id="gr-project-detail-list">
+  <template>
+    <style include="gr-form-styles"></style>
+    <style include="shared-styles">
+      .editing .editItem {
+        display: inherit;
+      }
+      .editItem,
+      .editing .editBtn,
+      .canEdit .revisionNoEditing,
+      .editing .revisionWithEditing,
+      .revisionEdit {
+        display: none;
+      }
+      .revisionEdit gr-button {
+        margin-left: .6em;
+      }
+      .editBtn {
+        margin-left: 1em;
+      }
+      .canEdit .revisionEdit{
+        align-items: center;
+        display: flex;
+        line-height: 1em;
+      }
+      .deleteButton:not(.show) {
+        display: none;
+      }
+    </style>
+    <style include="gr-table-styles"></style>
+    <gr-list-view
+        create-new="[[_loggedIn]]"
+        filter="[[_filter]]"
+        items-per-page="[[_itemsPerPage]]"
+        items="[[_items]]"
+        loading="[[_loading]]"
+        offset="[[_offset]]"
+        on-create-clicked="_handleCreateClicked"
+        path="[[_getPath(_project, detailType)]]">
+      <table id="list" class="genericList gr-form-styles">
+        <tr class="headerRow">
+          <th class="name topHeader">Name</th>
+          <th class="description topHeader">Revision</th>
+          <th class="repositoryBrowser topHeader">
+            Repository Browser</th>
+          <th class="delete topHeader"></th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+        <tbody class$="[[computeLoadingClass(_loading)]]">
+          <template is="dom-repeat" items="[[_shownItems]]">
+            <tr class="table">
+              <td class="name">[[_stripRefs(item.ref, detailType)]]</td>
+              <td class$="description [[_computeCanEditClass(item.ref, detailType, _isOwner)]]">
+                <span class="revisionNoEditing">
+                  [[item.revision]]
+                </span>
+                <span class$="revisionEdit [[_computeEditingClass(_isEditing)]]">
+                  <span class="revisionWithEditing">
+                    [[item.revision]]
+                  </span>
+                  <gr-button
+                      link
+                      on-tap="_handleEditRevision"
+                      class="editBtn">
+                    edit
+                  </gr-button>
+                  <input
+                      is=iron-input
+                      bind-value="{{_revisedRef}}"
+                      class="editItem">
+                  <gr-button
+                      link
+                      on-tap="_handleCancelRevision"
+                      class="cancelBtn editItem">
+                    Cancel
+                  </gr-button>
+                  <gr-button
+                      link
+                      on-tap="_handleSaveRevision"
+                      class="saveBtn editItem"
+                      disabled="[[!_revisedRef]]">
+                    Save
+                  </gr-button>
+                </span>
+              </td>
+              <td class="repositoryBrowser">
+                <template is="dom-repeat"
+                    items="[[_computeWeblink(item)]]" as="link">
+                  <a href$="[[link.url]]"
+                      class="webLink"
+                      rel="noopener"
+                      target="_blank">
+                    ([[link.name]])
+                  </a>
+                </template>
+              </td>
+              <td class="delete">
+                <gr-button
+                    link
+                    class$="deleteButton [[_computeHideDeleteClass(_isOwner, item.can_delete)]]"
+                    on-tap="_handleDeleteItem">
+                  Delete
+                </gr-button>
+              </td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+      <gr-overlay id="overlay" with-backdrop>
+        <gr-confirm-delete-item-dialog
+            class="confirmDialog"
+            on-confirm="_handleDeleteItemConfirm"
+            on-cancel="_handleConfirmDialogCancel"
+            item="[[_refName]]"
+            item-type="[[detailType]]"></gr-confirm-delete-item-dialog>
+      </gr-overlay>
+    </gr-list-view>
+    <gr-overlay id="createOverlay" with-backdrop>
+      <gr-confirm-dialog
+          id="createDialog"
+          disabled="[[!_hasNewItemName]]"
+          confirm-label="Create"
+          on-confirm="_handleCreateItem"
+          on-cancel="_handleCloseCreate">
+        <div class="header">
+          Create [[_computeItemName(detailType)]]
+        </div>
+        <div class="main">
+          <gr-create-pointer-dialog
+              id="createNewModal"
+              detail-type="[[_computeItemName(detailType)]]"
+              has-new-item-name="{{_hasNewItemName}}"
+              item-detail="[[detailType]]"
+              project-name="[[_project]]"></gr-create-pointer-dialog>
+        </div>
+      </gr-confirm-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-project-detail-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.js b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.js
new file mode 100644
index 0000000..eec277e
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list.js
@@ -0,0 +1,251 @@
+// 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.
+(function() {
+  'use strict';
+
+  const DETAIL_TYPES = {
+    BRANCHES: 'branches',
+    TAGS: 'tags',
+  };
+
+  Polymer({
+    is: 'gr-project-detail-list',
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+      /**
+       * The kind of detail we are displaying, possibilities are determined by
+       * the const DETAIL_TYPES.
+       */
+      detailType: String,
+
+      _editing: {
+        type: Boolean,
+        value: false,
+      },
+      _isOwner: {
+        type: Boolean,
+        value: false,
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+      _project: Object,
+      _items: Array,
+      /**
+       * Because  we request one more than the projectsPerPage, _shownProjects
+       * maybe one less than _projects.
+       * */
+      _shownItems: {
+        type: Array,
+        computed: 'computeShownItems(_items)',
+      },
+      _itemsPerPage: {
+        type: Number,
+        value: 25,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: String,
+      _refName: String,
+      _hasNewItemName: Boolean,
+      _isEditing: Boolean,
+      _revisedRef: String,
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    _determineIfOwner(project) {
+      return this.$.restAPI.getProjectAccess(project)
+          .then(access =>
+                this._isOwner = access && access[project].is_owner);
+    },
+
+    _paramsChanged(params) {
+      if (!params || !params.project) { return; }
+
+      this._project = params.project;
+
+      this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+        if (loggedIn) {
+          this._determineIfOwner(this._project);
+        }
+      });
+
+      this.detailType = params.detailType;
+
+      this._filter = this.getFilterValue(params);
+      this._offset = this.getOffsetValue(params);
+
+      return this._getItems(this._filter, this._project,
+          this._itemsPerPage, this._offset, this.detailType);
+    },
+
+    _getItems(filter, project, itemsPerPage, offset, detailType) {
+      this._loading = true;
+      this._items = [];
+      Polymer.dom.flush();
+      if (detailType === DETAIL_TYPES.BRANCHES) {
+        return this.$.restAPI.getProjectBranches(
+            filter, project, itemsPerPage, offset) .then(items => {
+              if (!items) { return; }
+              this._items = items;
+              this._loading = false;
+            });
+      } else if (detailType === DETAIL_TYPES.TAGS) {
+        return this.$.restAPI.getProjectTags(
+            filter, project, itemsPerPage, offset) .then(items => {
+              if (!items) { return; }
+              this._items = items;
+              this._loading = false;
+            });
+      }
+    },
+
+    _getPath(project) {
+      return `/admin/projects/${this.encodeURL(project, false)},` +
+          `${this.detailType}`;
+    },
+
+    _computeWeblink(project) {
+      if (!project.web_links) { return ''; }
+      const webLinks = project.web_links;
+      return webLinks.length ? webLinks : null;
+    },
+
+    _stripRefs(item, detailType) {
+      if (detailType === DETAIL_TYPES.BRANCHES) {
+        return item.replace('refs/heads/', '');
+      } else if (detailType === DETAIL_TYPES.TAGS) {
+        return item.replace('refs/tags/', '');
+      }
+    },
+
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _computeEditingClass(isEditing) {
+      return isEditing ? 'editing' : '';
+    },
+
+    _computeCanEditClass(ref, detailType, isOwner) {
+      return isOwner && this._stripRefs(ref, detailType) === 'HEAD' ?
+          'canEdit' : '';
+    },
+
+    _handleEditRevision(e) {
+      this._revisedRef = e.model.get('item.revision');
+      this._isEditing = true;
+    },
+
+    _handleCancelRevision() {
+      this._isEditing = false;
+    },
+
+    _handleSaveRevision(e) {
+      this._setProjectHead(this._project, this._revisedRef, e);
+    },
+
+    _setProjectHead(project, ref, e) {
+      return this.$.restAPI.setProjectHead(project, ref).then(res => {
+        if (res.status < 400) {
+          this._isEditing = false;
+          e.model.set('item.revision', ref);
+        }
+      });
+    },
+
+    _computeItemName(detailType) {
+      if (detailType === DETAIL_TYPES.BRANCHES) {
+        return 'Branch';
+      } else if (detailType === DETAIL_TYPES.TAGS) {
+        return 'Tag';
+      }
+    },
+
+    _handleDeleteItemConfirm() {
+      this.$.overlay.close();
+      if (this.detailType === DETAIL_TYPES.BRANCHES) {
+        return this.$.restAPI.deleteProjectBranches(this._project,
+            this._refName)
+            .then(itemDeleted => {
+              if (itemDeleted.status === 204) {
+                this._getItems(
+                    this._filter, this._project, this._itemsPerPage,
+                    this._offset, this.detailType);
+              }
+            });
+      } else if (this.detailType === DETAIL_TYPES.TAGS) {
+        return this.$.restAPI.deleteProjectTags(this._project,
+            this._refName)
+            .then(itemDeleted => {
+              if (itemDeleted.status === 204) {
+                this._getItems(
+                    this._filter, this._project, this._itemsPerPage,
+                    this._offset, this.detailType);
+              }
+            });
+      }
+    },
+
+    _handleConfirmDialogCancel() {
+      this.$.overlay.close();
+    },
+
+    _handleDeleteItem(e) {
+      const name = this._stripRefs(e.model.get('item.ref'), this.detailType);
+      if (!name) { return; }
+      this._refName = name;
+      this.$.overlay.open();
+    },
+
+    _computeHideDeleteClass(owner, deleteRef) {
+      if (owner && !deleteRef || owner && deleteRef || deleteRef || owner) {
+        return 'show';
+      }
+      return '';
+    },
+
+    _handleCreateItem() {
+      this.$.createNewModal.handleCreateItem();
+      this._handleCloseCreate();
+    },
+
+    _handleCloseCreate() {
+      this.$.createOverlay.close();
+    },
+
+    _handleCreateClicked() {
+      this.$.createOverlay.open();
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html
new file mode 100644
index 0000000..62870df
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-detail-list/gr-project-detail-list_test.html
@@ -0,0 +1,439 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-project-detail-list</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-project-detail-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-project-detail-list></gr-project-detail-list>
+  </template>
+</test-fixture>
+
+<script>
+  let counter;
+  const branchGenerator = () => {
+    return {
+      ref: `refs/heads/test${++counter}`,
+      revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+      web_links: [
+        {
+          name: 'diffusion',
+          url: `https://git.example.org/branch/test;refs/heads/test${counter}`,
+        },
+      ],
+    };
+  };
+  const tagGenerator = () => {
+    return {
+      ref: `refs/tags/test${++counter}`,
+      revision: '9c9d08a438e55e52f33b608415e6dddd9b18550d',
+      web_links: [
+        {
+          name: 'diffusion',
+          url: `https://git.example.org/tag/test;refs/tags/test${counter}`,
+        },
+      ],
+    };
+  };
+
+  suite('Branches', () => {
+    let element;
+    let branches;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.detailType = 'branches';
+      counter = 0;
+      sandbox.stub(page, 'show');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list of project branches', () => {
+      setup(done => {
+        branches = [{
+          ref: 'HEAD',
+          revision: 'master',
+        }].concat(_.times(25, branchGenerator));
+
+        stub('gr-rest-api-interface', {
+          getProjectBranches(num, project, offset) {
+            return Promise.resolve(branches);
+          },
+        });
+
+        const params = {
+          project: 'test',
+          detailType: 'branches',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('test for branch in the list', done => {
+        flush(() => {
+          assert.equal(element._items[2].ref, 'refs/heads/test2');
+          done();
+        });
+      });
+
+      test('test for web links in the branches list', done => {
+        flush(() => {
+          assert.equal(element._items[2].web_links[0].url,
+              'https://git.example.org/branch/test;refs/heads/test2');
+          done();
+        });
+      });
+
+      test('test for refs/heads/ being striped from ref', done => {
+        flush(() => {
+          assert.equal(element._stripRefs(element._items[2].ref,
+              element.detailType), 'test2');
+          done();
+        });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+
+      test('Edit HEAD button not admin', done => {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sandbox.stub(element.$.restAPI, 'getProjectAccess').returns(
+            Promise.resolve({
+              test: {is_owner: false},
+            }));
+        element._determineIfOwner('test').then(() => {
+          assert.equal(element._isOwner, false);
+          assert.equal(getComputedStyle(Polymer.dom(element.root)
+              .querySelector('.revisionNoEditing')).display, 'inline');
+          assert.equal(getComputedStyle(Polymer.dom(element.root)
+              .querySelector('.revisionEdit')).display, 'none');
+          done();
+        });
+      });
+
+      test('Edit HEAD button admin', done => {
+        const saveBtn = Polymer.dom(element.root).querySelector('.saveBtn');
+        const cancelBtn = Polymer.dom(element.root).querySelector('.cancelBtn');
+        const editBtn = Polymer.dom(element.root).querySelector('.editBtn');
+        const revisionNoEditing = Polymer.dom(element.root)
+              .querySelector('.revisionNoEditing');
+        const revisionWithEditing = Polymer.dom(element.root)
+              .querySelector('.revisionWithEditing');
+
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sandbox.stub(element.$.restAPI, 'getProjectAccess').returns(
+            Promise.resolve({
+              test: {is_owner: true},
+            }));
+        sandbox.stub(element, '_handleSaveRevision');
+        element._determineIfOwner('test').then(() => {
+          assert.equal(element._isOwner, true);
+          // The revision container for non-editing enabled row is not visible.
+          assert.equal(getComputedStyle(revisionNoEditing).display, 'none');
+
+          // The revision container for editing enabled row is visible.
+          assert.notEqual(getComputedStyle(Polymer.dom(element.root)
+              .querySelector('.revisionEdit')).display, 'none');
+
+          // The revision and edit button are visible.
+          assert.notEqual(getComputedStyle(revisionWithEditing).display,
+              'none');
+          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          const hiddenElements = Polymer.dom(element.root)
+              .querySelectorAll('.canEdit .editItem');
+
+          for (const item of hiddenElements) {
+            assert.equal(getComputedStyle(item).display, 'none');
+          }
+
+          MockInteractions.tap(editBtn);
+          flushAsynchronousOperations();
+          // The revision and edit button are not visible.
+          assert.equal(getComputedStyle(revisionWithEditing).display, 'none');
+          assert.equal(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          for (item of hiddenElements) {
+            assert.notEqual(getComputedStyle(item).display, 'none');
+          }
+
+          // The revised ref was set correctly
+          assert.equal(element._revisedRef, 'master');
+
+          assert.isFalse(saveBtn.disabled);
+
+          // Delete the ref.
+          element._revisedRef = '';
+          assert.isTrue(saveBtn.disabled);
+
+          // Change the ref to something else
+          element._revisedRef = 'newRef';
+          element._project = 'test';
+          assert.isFalse(saveBtn.disabled);
+
+          // Save button calls handleSave. since this is stubbed, the edit
+          // section remains open.
+          MockInteractions.tap(saveBtn);
+          assert.isTrue(element._handleSaveRevision.called);
+
+          // When cancel is tapped, the edit secion closes.
+          MockInteractions.tap(cancelBtn);
+          flushAsynchronousOperations();
+
+          // The revision and edit button are visible.
+          assert.notEqual(getComputedStyle(revisionWithEditing).display,
+              'none');
+          assert.notEqual(getComputedStyle(editBtn).display, 'none');
+
+          // The input, cancel, and save buttons are not visible.
+          for (const item of hiddenElements) {
+            assert.equal(getComputedStyle(item).display, 'none');
+          }
+          done();
+        });
+      });
+
+      test('_handleSaveRevision with invalid rev', done => {
+        const event = {model: {set: sandbox.stub()}};
+        element._isEditing = true;
+        sandbox.stub(element.$.restAPI, 'setProjectHead').returns(
+            Promise.resolve({
+              status: 400,
+            })
+        );
+
+        element._setProjectHead('test', 'newRef', event).then(() => {
+          assert.isTrue(element._isEditing);
+          assert.isFalse(event.model.set.called);
+          done();
+        });
+      });
+
+      test('_handleSaveRevision with valid rev', done => {
+        const event = {model: {set: sandbox.stub()}};
+        element._isEditing = true;
+        sandbox.stub(element.$.restAPI, 'setProjectHead').returns(
+            Promise.resolve({
+              status: 200,
+            })
+        );
+
+        element._setProjectHead('test', 'newRef', event).then(() => {
+          assert.isFalse(element._isEditing);
+          assert.isTrue(event.model.set.called);
+          done();
+        });
+      });
+
+      test('test _computeItemName', () => {
+        assert.deepEqual(element._computeItemName('branches'), 'Branch');
+        assert.deepEqual(element._computeItemName('tags'), 'Tag');
+      });
+    });
+
+    suite('list with less then 25 branches', () => {
+      setup(done => {
+        branches = _.times(25, branchGenerator);
+
+        stub('gr-rest-api-interface', {
+          getProjectBranches(num, project, offset) {
+            return Promise.resolve(branches);
+          },
+        });
+
+        const params = {
+          project: 'test',
+          detailType: 'branches',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('_shownProjectsBranches', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getProjectBranches', () => {
+          return Promise.resolve(branches);
+        });
+        const params = {
+          detailType: 'branches',
+          project: 'test',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params).then(() => {
+          assert.isTrue(element.$.restAPI.getProjectBranches.lastCall
+              .calledWithExactly('test', 'test', 25, 25));
+          done();
+        });
+      });
+    });
+  });
+
+  suite('Tags', () => {
+    let element;
+    let tags;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.detailType = 'tags';
+      counter = 0;
+      sandbox.stub(page, 'show');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list of project tags', () => {
+      setup(done => {
+        tags = _.times(26, tagGenerator);
+
+        stub('gr-rest-api-interface', {
+          getProjectTags(num, project, offset) {
+            return Promise.resolve(tags);
+          },
+        });
+
+        const params = {
+          project: 'test',
+          detailType: 'tags',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('test for tag in the list', done => {
+        flush(() => {
+          assert.equal(element._items[1].ref, 'refs/tags/test2');
+          done();
+        });
+      });
+
+      test('test for web links in the tags list', done => {
+        flush(() => {
+          assert.equal(element._items[1].web_links[0].url,
+              'https://git.example.org/tag/test;refs/tags/test2');
+          done();
+        });
+      });
+
+      test('test for refs/tags/ being striped from ref', done => {
+        flush(() => {
+          assert.equal(element._stripRefs(element._items[1].ref,
+              element.detailType), 'test2');
+          done();
+        });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('list with less then 25 tags', () => {
+      setup(done => {
+        tags = _.times(25, tagGenerator);
+
+        stub('gr-rest-api-interface', {
+          getProjectTags(num, project, offset) {
+            return Promise.resolve(tags);
+          },
+        });
+
+        const params = {
+          project: 'test',
+          detailType: 'tags',
+        };
+
+        element._paramsChanged(params).then(() => { flush(done); });
+      });
+
+      test('_shownItems', () => {
+        assert.equal(element._shownItems.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getProjectTags', () => {
+          return Promise.resolve(tags);
+        });
+        const params = {
+          project: 'test',
+          detailType: 'tags',
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(params).then(() => {
+          assert.isTrue(element.$.restAPI.getProjectTags.lastCall
+              .calledWithExactly('test', 'test', 25, 25));
+          done();
+        });
+      });
+    });
+
+    suite('create new', () => {
+      test('_handleCreateClicked called when create-click fired', () => {
+        sandbox.stub(element, '_handleCreateClicked');
+        element.$$('gr-list-view').fire('create-clicked');
+        assert.isTrue(element._handleCreateClicked.called);
+      });
+
+      test('_handleCreateClicked opens modal', () => {
+        const openStub = sandbox.stub(element.$.createOverlay, 'open');
+        element._handleCreateClicked();
+        assert.isTrue(openStub.called);
+      });
+
+      test('_handleCreateItem called when confirm fired', () => {
+        sandbox.stub(element, '_handleCreateItem');
+        element.$.createDialog.fire('confirm');
+        assert.isTrue(element._handleCreateItem.called);
+      });
+
+      test('_handleCloseCreate called when cancel fired', () => {
+        sandbox.stub(element, '_handleCloseCreate');
+        element.$.createDialog.fire('cancel');
+        assert.isTrue(element._handleCloseCreate.called);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html
new file mode 100644
index 0000000..6c45704
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.html
@@ -0,0 +1,98 @@
+<!--
+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.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/gr-list-view-behavior/gr-list-view-behavior.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../styles/gr-table-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-list-view/gr-list-view.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-create-project-dialog/gr-create-project-dialog.html">
+
+
+<dom-module id="gr-project-list">
+  <template>
+    <style include="shared-styles"></style>
+    <style include="gr-table-styles"></style>
+    <gr-list-view
+        create-new=[[_createNewCapability]]
+        filter="[[_filter]]"
+        items-per-page="[[_projectsPerPage]]"
+        items="[[_projects]]"
+        loading="[[_loading]]"
+        offset="[[_offset]]"
+        on-create-clicked="_handleCreateClicked"
+        path="[[_path]]">
+      <table id="list" class="genericList">
+        <tr class="headerRow">
+          <th class="name topHeader">Project Name</th>
+          <th class="description topHeader">Project Description</th>
+          <th class="repositoryBrowser topHeader">Repository Browser</th>
+          <th class="readOnly topHeader">Read only</th>
+        </tr>
+        <tr id="loading" class$="loadingMsg [[computeLoadingClass(_loading)]]">
+          <td>Loading...</td>
+        </tr>
+        <tbody class$="[[computeLoadingClass(_loading)]]">
+          <template is="dom-repeat" items="[[_shownProjects]]">
+            <tr class="table">
+              <td class="name">
+                <a href$="[[_computeProjectUrl(item.name)]]">[[item.name]]</a>
+              </td>
+              <td class="description">[[item.description]]</td>
+              <td class="repositoryBrowser">
+                <template is="dom-repeat"
+                    items="[[_computeWeblink(item)]]" as="link">
+                  <a href$="[[link.url]]"
+                      class="webLink"
+                      rel="noopener"
+                      target="_blank">
+                    ([[link.name]])
+                  </a>
+                </template>
+              </td>
+              <td class="readOnly">[[_readOnly(item)]]</td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+    </gr-list-view>
+    <gr-overlay id="createOverlay" with-backdrop>
+      <gr-confirm-dialog
+          id="createDialog"
+          class="confirmDialog"
+          disabled="[[!_hasNewProjectName]]"
+          confirm-label="Create"
+          on-confirm="_handleCreateProject"
+          on-cancel="_handleCloseCreate">
+        <div class="header">
+          Create Project
+        </div>
+        <div class="main">
+          <gr-create-project-dialog
+              has-new-project-name="{{_hasNewProjectName}}"
+              params="[[params]]"
+              id="createNewModal"></gr-create-project-dialog>
+        </div>
+      </gr-confirm-dialog>
+    </gr-overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-project-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js
new file mode 100644
index 0000000..070cc2f
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list.js
@@ -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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-project-list',
+
+    properties: {
+      /**
+       * URL params passed from the router.
+       */
+      params: {
+        type: Object,
+        observer: '_paramsChanged',
+      },
+
+      /**
+       * Offset of currently visible query results.
+       */
+      _offset: Number,
+      _path: {
+        type: String,
+        readOnly: true,
+        value: '/admin/projects',
+      },
+      _hasNewProjectName: Boolean,
+      _createNewCapability: {
+        type: Boolean,
+        value: false,
+      },
+      _projects: Array,
+
+      /**
+       * Because  we request one more than the projectsPerPage, _shownProjects
+       * maybe one less than _projects.
+       * */
+      _shownProjects: {
+        type: Array,
+        computed: 'computeShownItems(_projects)',
+      },
+
+      _projectsPerPage: {
+        type: Number,
+        value: 25,
+      },
+
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _filter: String,
+    },
+
+    behaviors: [
+      Gerrit.ListViewBehavior,
+    ],
+
+    attached() {
+      this._getCreateProjectCapability();
+      this.fire('title-change', {title: 'Projects'});
+      this._maybeOpenCreateOverlay(this.params);
+    },
+
+    _paramsChanged(params) {
+      this._loading = true;
+      this._filter = this.getFilterValue(params);
+      this._offset = this.getOffsetValue(params);
+
+      return this._getProjects(this._filter, this._projectsPerPage,
+          this._offset);
+    },
+
+    /**
+     * Opens the create overlay if the route has a hash 'create'
+     * @param {!Object} params
+     */
+    _maybeOpenCreateOverlay(params) {
+      if (params && params.openCreateModal) {
+        this.$.createOverlay.open();
+      }
+    },
+
+    _computeProjectUrl(name) {
+      return this.getUrl(this._path + '/', name);
+    },
+
+    _getCreateProjectCapability() {
+      return this.$.restAPI.getAccount().then(account => {
+        if (!account) { return; }
+        return this.$.restAPI.getAccountCapabilities(['createProject'])
+            .then(capabilities => {
+              if (capabilities.createProject) {
+                this._createNewCapability = true;
+              }
+            });
+      });
+    },
+
+    _getProjects(filter, projectsPerPage, offset) {
+      this._projects = [];
+      return this.$.restAPI.getProjects(filter, projectsPerPage, offset)
+          .then(projects => {
+            if (!projects) {
+              return;
+            }
+            this._projects = Object.keys(projects)
+             .map(key => {
+               const project = projects[key];
+               project.name = key;
+               return project;
+             });
+            this._loading = false;
+          });
+    },
+
+    _handleCreateProject() {
+      this.$.createNewModal.handleCreateProject();
+    },
+
+    _handleCloseCreate() {
+      this.$.createOverlay.close();
+    },
+
+    _handleCreateClicked() {
+      this.$.createOverlay.open();
+    },
+
+    _readOnly(item) {
+      return item.state === 'READ_ONLY' ? 'Y' : '';
+    },
+
+    _computeWeblink(project) {
+      if (!project.web_links) {
+        return '';
+      }
+      const webLinks = project.web_links;
+      return webLinks.length ? webLinks : null;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html
new file mode 100644
index 0000000..87732b8
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project-list/gr-project-list_test.html
@@ -0,0 +1,177 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-project-list</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-project-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-project-list></gr-project-list>
+  </template>
+</test-fixture>
+
+<script>
+  let counter;
+  const projectGenerator = () => {
+    return {
+      id: `test${++counter}`,
+      state: 'ACTIVE',
+      web_links: [
+        {
+          name: 'diffusion',
+          url: `https://phabricator.example.org/r/project/test${counter}`,
+        },
+      ],
+    };
+  };
+
+  suite('gr-project-list tests', () => {
+    let element;
+    let projects;
+    let sandbox;
+    let value;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      counter = 0;
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('list with projects', () => {
+      setup(done => {
+        projects = _.times(26, projectGenerator);
+        stub('gr-rest-api-interface', {
+          getProjects(num, offset) {
+            return Promise.resolve(projects);
+          },
+        });
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('test for test project in the list', done => {
+        flush(() => {
+          assert.equal(element._projects[1].id, 'test2');
+          done();
+        });
+      });
+
+      test('_shownProjects', () => {
+        assert.equal(element._shownProjects.length, 25);
+      });
+
+      test('_maybeOpenCreateOverlay', () => {
+        const overlayOpen = sandbox.stub(element.$.createOverlay, 'open');
+        element._maybeOpenCreateOverlay();
+        assert.isFalse(overlayOpen.called);
+        const params = {};
+        element._maybeOpenCreateOverlay(params);
+        assert.isFalse(overlayOpen.called);
+        params.openCreateModal = true;
+        element._maybeOpenCreateOverlay(params);
+        assert.isTrue(overlayOpen.called);
+      });
+    });
+
+    suite('list with less then 25 projects', () => {
+      setup(done => {
+        projects = _.times(25, projectGenerator);
+
+        stub('gr-rest-api-interface', {
+          getProjects(num, offset) {
+            return Promise.resolve(projects);
+          },
+        });
+
+        element._paramsChanged(value).then(() => { flush(done); });
+      });
+
+      test('_shownProjects', () => {
+        assert.equal(element._shownProjects.length, 25);
+      });
+    });
+
+    suite('filter', () => {
+      test('_paramsChanged', done => {
+        sandbox.stub(element.$.restAPI, 'getProjects', () => {
+          return Promise.resolve(projects);
+        });
+        const value = {
+          filter: 'test',
+          offset: 25,
+        };
+        element._paramsChanged(value).then(() => {
+          assert.isTrue(element.$.restAPI.getProjects.lastCall
+              .calledWithExactly('test', 25, 25));
+          done();
+        });
+      });
+    });
+
+    suite('loading', () => {
+      test('correct contents are displayed', () => {
+        assert.isTrue(element._loading);
+        assert.equal(element.computeLoadingClass(element._loading), 'loading');
+        assert.equal(getComputedStyle(element.$.loading).display, 'block');
+
+        element._loading = false;
+        element._projects = _.times(25, projectGenerator);
+
+        flushAsynchronousOperations();
+        assert.equal(element.computeLoadingClass(element._loading), '');
+        assert.equal(getComputedStyle(element.$.loading).display, 'none');
+      });
+    });
+
+    suite('create new', () => {
+      test('_handleCreateClicked called when create-click fired', () => {
+        sandbox.stub(element, '_handleCreateClicked');
+        element.$$('gr-list-view').fire('create-clicked');
+        assert.isTrue(element._handleCreateClicked.called);
+      });
+
+      test('_handleCreateClicked opens modal', () => {
+        const openStub = sandbox.stub(element.$.createOverlay, 'open');
+        element._handleCreateClicked();
+        assert.isTrue(openStub.called);
+      });
+
+      test('_handleCreateProject called when confirm fired', () => {
+        sandbox.stub(element, '_handleCreateProject');
+        element.$.createDialog.fire('confirm');
+        assert.isTrue(element._handleCreateProject.called);
+      });
+
+      test('_handleCloseCreate called when cancel fired', () => {
+        sandbox.stub(element, '_handleCloseCreate');
+        element.$.createDialog.fire('cancel');
+        assert.isTrue(element._handleCloseCreate.called);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-project/gr-project.html b/polygerrit-ui/app/elements/admin/gr-project/gr-project.html
new file mode 100644
index 0000000..996cf28
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project/gr-project.html
@@ -0,0 +1,333 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+
+<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-project">
+  <template>
+    <style include="shared-styles">
+      main {
+        margin: 2em 1em;
+      }
+      h2.edited:after {
+        color: #444;
+        content: ' *';
+      }
+      .loading,
+      .hideDownload {
+        display: none;
+      }
+      #loading.loading {
+        display: block;
+      }
+      #loading:not(.loading) {
+        display: none;
+      }
+      .projectSettings {
+        display: none;
+      }
+      .projectSettings.showConfig {
+        display: block;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <main class="gr-form-styles read-only">
+      <h1 id="Title">[[project]]</h1>
+      <div id="loading" class$="[[_computeLoadingClass(_loading)]]">Loading...</div>
+      <div id="loadedContent" class$="[[_computeLoadingClass(_loading)]]">
+        <div id="downloadContent" class$="[[_computeDownloadClass(_schemes)]]">
+          <h2 id="download">Download</h2>
+          <fieldset>
+            <gr-download-commands
+                id="downloadCommands"
+                commands="[[_computeCommands(project, _schemesObj, _selectedScheme)]]"
+                schemes="[[_schemes]]"
+                selected-scheme="{{_selectedScheme}}"></gr-download-commands>
+          </fieldset>
+        </div>
+        <h2 id="configurations"
+            class$="[[_computeHeaderClass(_configChanged)]]">Configurations</h2>
+        <div id="form">
+          <fieldset>
+            <h3 id="Description">Description</h3>
+            <fieldset>
+              <iron-autogrow-textarea
+                  id="descriptionInput"
+                  class="description"
+                  autocomplete="on"
+                  placeholder="<Insert project description here>"
+                  bind-value="{{_projectConfig.description}}"
+                  disabled$="[[_readOnly]]"></iron-autogrow-textarea>
+            </fieldset>
+            <h3 id="Options">Project Options</h3>
+            <fieldset id="options">
+              <section>
+                <span class="title">State</span>
+                <span class="value">
+                  <gr-select
+                      id="stateSelect"
+                      bind-value="{{_projectConfig.state}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat" items=[[_states]]>
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Submit type</span>
+                <span class="value">
+                  <gr-select
+                      id="submitTypeSelect"
+                      bind-value="{{_projectConfig.submit_type}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat" items="[[_submitTypes]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Allow content merges</span>
+                <span class="value">
+                  <gr-select
+                      id="contentMergeSelect"
+                      bind-value="{{_projectConfig.use_content_merge.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.use_content_merge)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">
+                  Create a new change for every commit not in the target branch
+                </span>
+                <span class="value">
+                  <gr-select
+                      id="newChangeSelect"
+                      bind-value="{{_projectConfig.create_new_change_for_all_not_in_target.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.create_new_change_for_all_not_in_target)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Require Change-Id in commit message</span>
+                <span class="value">
+                  <gr-select
+                      id="requireChangeIdSelect"
+                      bind-value="{{_projectConfig.require_change_id.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.require_change_id)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section
+                   id="enableSignedPushSettings"
+                   class$="projectSettings [[_computeProjectsClass(_projectConfig.enable_signed_push)]]">
+                <span class="title">Enable signed push</span>
+                <span class="value">
+                  <gr-select
+                      id="enableSignedPush"
+                      bind-value="{{_projectConfig.enable_signed_push.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.enable_signed_push)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section
+                   id="requireSignedPushSettings"
+                   class$="projectSettings [[_computeProjectsClass(_projectConfig.require_signed_push)]]">
+                <span class="title">Require signed push</span>
+                <span class="value">
+                  <gr-select
+                      id="requireSignedPush"
+                      bind-value="{{_projectConfig.require_signed_push.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.require_signed_push)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">
+                  Reject implicit merges when changes are pushed for review</span>
+                <span class="value">
+                  <gr-select
+                      id="rejectImplicitMergesSelect"
+                      bind-value="{{_projectConfig.reject_implicit_merges.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.reject_implicit_merges)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section id="noteDbSettings" class$="projectSettings [[_computeProjectsClass(_noteDbEnabled)]]">
+                <span class="title">
+                  Enable adding unregistered users as reviewers and CCs on changes</span>
+                <span class="value">
+                  <gr-select
+                      id="unRegisteredCcSelect"
+                      bind-value="{{_projectConfig.enable_reviewer_by_email.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.enable_reviewer_by_email)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                  </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">
+                  Set all new changes private by default</span>
+                <span class="value">
+                  <gr-select
+                      id="setAllnewChangesPrivateByDefaultSelect"
+                      bind-value="{{_projectConfig.private_by_default.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.private_by_default)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">
+                  Set new changes to "work in progress" by default</span>
+                <span class="value">
+                  <gr-select
+                      id="setAllNewChangesWorkInProgressByDefaultSelect"
+                      bind-value="{{_projectConfig.work_in_progress_by_default.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.work_in_progress_by_default)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Maximum Git object size limit</span>
+                <span class="value">
+                  <input
+                      id="maxGitObjSizeInput"
+                      bind-value="{{_projectConfig.max_object_size_limit.configured_value}}"
+                      is="iron-input"
+                      type="text"
+                      disabled$="[[_readOnly]]">
+                  <template is="dom-if" if="[[_projectConfig.max_object_size_limit.value]]">
+                    effective: [[_projectConfig.max_object_size_limit.value]] bytes
+                  </template>
+                </span>
+              </section>
+              <section>
+                <span class="title">Match authored date with committer date upon submit</span>
+                <span class="value">
+                  <gr-select
+                      id="matchAuthoredDateWithCommitterDateSelect"
+                      bind-value="{{_projectConfig.match_author_to_committer_date.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.match_author_to_committer_date)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                  </select>
+                  </gr-select>
+                </span>
+              </section>
+            </fieldset>
+            <h3 id="Options">Contributor Agreements</h3>
+            <fieldset id="agreements">
+              <section>
+                <span class="title">
+                  Require a valid contributor agreement to upload</span>
+                <span class="value">
+                  <gr-select
+                      id="contributorAgreementSelect"
+                      bind-value="{{_projectConfig.use_contributor_agreements.configured_value}}">
+                  <select disabled$="[[_readOnly]]">
+                    <template is="dom-repeat"
+                        items="[[_formatBooleanSelect(_projectConfig.use_contributor_agreements)]]">
+                      <option value="[[item.value]]">[[item.label]]</option>
+                    </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+              <section>
+                <span class="title">Require Signed-off-by in commit message</span>
+                <span class="value">
+                  <gr-select
+                        id="useSignedOffBySelect"
+                        bind-value="{{_projectConfig.use_signed_off_by.configured_value}}">
+                    <select disabled$="[[_readOnly]]">
+                      <template is="dom-repeat"
+                          items="[[_formatBooleanSelect(_projectConfig.use_signed_off_by)]]">
+                        <option value="[[item.value]]">[[item.label]]</option>
+                      </template>
+                    </select>
+                  </gr-select>
+                </span>
+              </section>
+            </fieldset>
+            <!-- TODO @beckysiegel add plugin config widgets -->
+            <gr-button
+                on-tap="_handleSaveProjectConfig"
+                disabled$="[[_computeButtonDisabled(_readOnly, _configChanged)]]">Save changes</gr-button>
+          </fieldset>
+        </div>
+      </div>
+    </main>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-project.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-project/gr-project.js b/polygerrit-ui/app/elements/admin/gr-project/gr-project.js
new file mode 100644
index 0000000..bc28df1
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project/gr-project.js
@@ -0,0 +1,262 @@
+// 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.
+(function() {
+  'use strict';
+
+  const STATES = {
+    active: {value: 'ACTIVE', label: 'Active'},
+    readOnly: {value: 'READ_ONLY', label: 'Read Only'},
+    hidden: {value: 'HIDDEN', label: 'Hidden'},
+  };
+
+  const SUBMIT_TYPES = {
+    mergeIfNecessary: {
+      value: 'MERGE_IF_NECESSARY',
+      label: 'Merge if necessary',
+    },
+    fastForwardOnly: {
+      value: 'FAST_FORWARD_ONLY',
+      label: 'Fast forward only',
+    },
+    rebaseAlways: {
+      value: 'REBASE_ALWAYS',
+      label: 'Rebase Always',
+    },
+    rebaseIfNecessary: {
+      value: 'REBASE_IF_NECESSARY',
+      label: 'Rebase if necessary',
+    },
+    mergeAlways: {
+      value: 'MERGE_ALWAYS',
+      label: 'Merge always',
+    },
+    cherryPick: {
+      value: 'CHERRY_PICK',
+      label: 'Cherry pick',
+    },
+  };
+
+  Polymer({
+    is: 'gr-project',
+
+    properties: {
+      params: Object,
+      project: String,
+
+      _configChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _loading: {
+        type: Boolean,
+        value: true,
+      },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+        observer: '_loggedInChanged',
+      },
+      /** @type {?} */
+      _projectConfig: Object,
+      _readOnly: {
+        type: Boolean,
+        value: true,
+      },
+      _states: {
+        type: Array,
+        value() {
+          return Object.values(STATES);
+        },
+      },
+      _submitTypes: {
+        type: Array,
+        value() {
+          return Object.values(SUBMIT_TYPES);
+        },
+      },
+      _schemes: {
+        type: Array,
+        value() { return []; },
+        computed: '_computeSchemes(_schemesObj)',
+        observer: '_schemesChanged',
+      },
+      _selectedCommand: {
+        type: String,
+        value: 'Clone',
+      },
+      _selectedScheme: String,
+      _schemesObj: Object,
+      _noteDbEnabled: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    observers: [
+      '_handleConfigChanged(_projectConfig.*)',
+    ],
+
+    attached() {
+      this._loadProject();
+
+      this.fire('title-change', {title: this.project});
+    },
+
+    _loadProject() {
+      if (!this.project) { return Promise.resolve(); }
+
+      const promises = [];
+      promises.push(this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+        if (loggedIn) {
+          this.$.restAPI.getProjectAccess(this.project).then(access => {
+            // If the user is not an owner, is_owner is not a property.
+            this._readOnly = !access[this.project].is_owner;
+          });
+        }
+      }));
+
+      promises.push(this.$.restAPI.getProjectConfig(this.project).then(
+          config => {
+            if (!config.state) {
+              config.state = STATES.active.value;
+            }
+            this._projectConfig = config;
+            this._loading = false;
+          }));
+
+      promises.push(this.$.restAPI.getConfig().then(config => {
+        this._schemesObj = config.download.schemes;
+        this._noteDbEnabled = !!config.note_db_enabled;
+      }));
+
+      return Promise.all(promises);
+    },
+
+    _computeLoadingClass(loading) {
+      return loading ? 'loading' : '';
+    },
+
+    _computeDownloadClass(schemes) {
+      return !schemes || !schemes.length ? 'hideDownload' : '';
+    },
+
+    _loggedInChanged(_loggedIn) {
+      if (!_loggedIn) { return; }
+      this.$.restAPI.getPreferences().then(prefs => {
+        if (prefs.download_scheme) {
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this._selectedScheme = prefs.download_scheme.toLowerCase();
+        }
+      });
+    },
+
+    _formatBooleanSelect(item) {
+      if (!item) { return; }
+      let inheritLabel = 'Inherit';
+      if (item.inherited_value) {
+        inheritLabel = `Inherit (${item.inherited_value})`;
+      }
+      return [
+        {
+          label: inheritLabel,
+          value: 'INHERIT',
+        },
+        {
+          label: 'True',
+          value: 'TRUE',
+        }, {
+          label: 'False',
+          value: 'FALSE',
+        },
+      ];
+    },
+
+    _isLoading() {
+      return this._loading || this._loading === undefined;
+    },
+
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _formatProjectConfigForSave(p) {
+      const configInputObj = {};
+      for (const key in p) {
+        if (p.hasOwnProperty(key)) {
+          if (typeof p[key] === 'object') {
+            configInputObj[key] = p[key].configured_value;
+          } else {
+            configInputObj[key] = p[key];
+          }
+        }
+      }
+      return configInputObj;
+    },
+
+    _handleSaveProjectConfig() {
+      return this.$.restAPI.saveProjectConfig(this.project,
+          this._formatProjectConfigForSave(this._projectConfig)).then(() => {
+            this._configChanged = false;
+          });
+    },
+
+    _handleConfigChanged() {
+      if (this._isLoading()) { return; }
+      this._configChanged = true;
+    },
+
+    _computeButtonDisabled(readOnly, configChanged) {
+      return readOnly || !configChanged;
+    },
+
+    _computeHeaderClass(configChanged) {
+      return configChanged ? 'edited' : '';
+    },
+
+    _computeSchemes(schemesObj) {
+      return Object.keys(schemesObj);
+    },
+
+    _schemesChanged(schemes) {
+      if (schemes.length === 0) { return; }
+      if (!schemes.includes(this._selectedScheme)) {
+        this._selectedScheme = schemes.sort()[0];
+      }
+    },
+
+    _computeCommands(project, schemesObj, _selectedScheme) {
+      const commands = [];
+      let commandObj;
+      if (schemesObj.hasOwnProperty(_selectedScheme)) {
+        commandObj = schemesObj[_selectedScheme].clone_commands;
+      }
+      for (const title in commandObj) {
+        if (!commandObj.hasOwnProperty(title)) { continue; }
+        commands.push({
+          title,
+          command: commandObj[title]
+              .replace('${project}', project)
+              .replace('${project-base-name}',
+              project.substring(project.lastIndexOf('/') + 1)),
+        });
+      }
+      return commands;
+    },
+
+    _computeProjectsClass(config) {
+      return config ? 'showConfig': '';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/admin/gr-project/gr-project_test.html b/polygerrit-ui/app/elements/admin/gr-project/gr-project_test.html
new file mode 100644
index 0000000..0840076
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-project/gr-project_test.html
@@ -0,0 +1,333 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-project</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-project.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-project></gr-project>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-project tests', () => {
+    let element;
+    let sandbox;
+    const PROJECT = 'test-project';
+    const SCHEMES = {http: {}, repo: {}, ssh: {}};
+
+    function getFormFields() {
+      const selects = Polymer.dom(element.root).querySelectorAll('select');
+      const textareas =
+          Polymer.dom(element.root).querySelectorAll('iron-autogrow-textarea');
+      const inputs = Polymer.dom(element.root).querySelectorAll('input');
+      return inputs.concat(textareas).concat(selects);
+    }
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() {
+          return Promise.resolve({
+            description: 'Access inherited by all other projects.',
+            use_contributor_agreements: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            use_content_merge: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            use_signed_off_by: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            create_new_change_for_all_not_in_target: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            require_change_id: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            enable_signed_push: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            require_signed_push: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            reject_implicit_merges: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            private_by_default: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            work_in_progress_by_default: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            match_author_to_committer_date: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            enable_reviewer_by_email: {
+              value: false,
+              configured_value: 'FALSE',
+            },
+            max_object_size_limit: {},
+            submit_type: 'MERGE_IF_NECESSARY',
+          });
+        },
+        getConfig() {
+          return Promise.resolve({download: {}});
+        },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('loading displays before project config is loaded', () => {
+      assert.isTrue(element.$.loading.classList.contains('loading'));
+      assert.isFalse(getComputedStyle(element.$.loading).display === 'none');
+      assert.isTrue(element.$.loadedContent.classList.contains('loading'));
+      assert.isTrue(getComputedStyle(element.$.loadedContent)
+          .display === 'none');
+    });
+
+    test('download commands visibility', () => {
+      element._loading = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.downloadContent.classList
+          .contains('hideDownload'));
+      assert.isTrue(getComputedStyle(element.$.downloadContent)
+          .display == 'none');
+      element._schemesObj = SCHEMES;
+      flushAsynchronousOperations();
+      assert.isFalse(element.$.downloadContent.classList
+          .contains('hideDownload'));
+      assert.isFalse(getComputedStyle(element.$.downloadContent)
+          .display == 'none');
+    });
+
+    test('form defaults to read only', () => {
+      assert.isTrue(element._readOnly);
+    });
+
+    test('form defaults to read only when not logged in', done => {
+      element.project = PROJECT;
+      element._loadProject().then(() => {
+        assert.isTrue(element._readOnly);
+        done();
+      });
+    });
+
+    test('form defaults to read only when logged in and not admin', done => {
+      element.project = PROJECT;
+      sandbox.stub(element, '_getLoggedIn', () => {
+        return Promise.resolve(true);
+      });
+      sandbox.stub(element.$.restAPI, 'getProjectAccess', () => {
+        return Promise.resolve({'test-project': {}});
+      });
+      element._loadProject().then(() => {
+        assert.isTrue(element._readOnly);
+        done();
+      });
+    });
+
+    test('all form elements are disabled when not admin', done => {
+      element.project = PROJECT;
+      element._loadProject().then(() => {
+        flushAsynchronousOperations();
+        const formFields = getFormFields();
+        for (const field of formFields) {
+          assert.isTrue(field.hasAttribute('disabled'));
+        }
+        done();
+      });
+    });
+
+    test('_formatBooleanSelect', () => {
+      let item = {inherited_value: 'true'};
+      assert.deepEqual(element._formatBooleanSelect(item), [
+        {
+          label: 'Inherit (true)',
+          value: 'INHERIT',
+        },
+        {
+          label: 'True',
+          value: 'TRUE',
+        }, {
+          label: 'False',
+          value: 'FALSE',
+        },
+      ]);
+
+      // For items without inherited values
+      item = {};
+      assert.deepEqual(element._formatBooleanSelect(item), [
+        {
+          label: 'Inherit',
+          value: 'INHERIT',
+        },
+        {
+          label: 'True',
+          value: 'TRUE',
+        }, {
+          label: 'False',
+          value: 'FALSE',
+        },
+      ]);
+    });
+
+    suite('admin', () => {
+      setup(() => {
+        element.project = PROJECT;
+        sandbox.stub(element, '_getLoggedIn', () => {
+          return Promise.resolve(true);
+        });
+        sandbox.stub(element.$.restAPI, 'getProjectAccess', () => {
+          return Promise.resolve({'test-project': {is_owner: true}});
+        });
+      });
+
+      test('all form elements are enabled', done => {
+        element._loadProject().then(() => {
+          flushAsynchronousOperations();
+          const formFields = getFormFields();
+          for (const field of formFields) {
+            assert.isFalse(field.hasAttribute('disabled'));
+          }
+          assert.isFalse(element._loading);
+          done();
+        });
+      });
+
+      test('state gets set correctly', done => {
+        element._loadProject().then(() => {
+          assert.equal(element._projectConfig.state, 'ACTIVE');
+          assert.equal(element.$.stateSelect.bindValue, 'ACTIVE');
+          done();
+        });
+      });
+
+      test('fields update and save correctly', done => {
+        // test notedb
+        element._noteDbEnabled = false;
+
+        assert.equal(
+            element._computeProjectsClass(element._noteDbEnabled), '');
+
+        element._noteDbEnabled = true;
+
+        assert.equal(
+            element._computeProjectsClass(element._noteDbEnabled), 'showConfig');
+
+        const configInputObj = {
+          description: 'new description',
+          use_contributor_agreements: 'TRUE',
+          use_content_merge: 'TRUE',
+          use_signed_off_by: 'TRUE',
+          create_new_change_for_all_not_in_target: 'TRUE',
+          require_change_id: 'TRUE',
+          enable_signed_push: 'TRUE',
+          require_signed_push: 'TRUE',
+          reject_implicit_merges: 'TRUE',
+          private_by_default: 'TRUE',
+          work_in_progress_by_default: 'TRUE',
+          match_author_to_committer_date: 'TRUE',
+          max_object_size_limit: 10,
+          submit_type: 'FAST_FORWARD_ONLY',
+          state: 'READ_ONLY',
+          enable_reviewer_by_email: 'TRUE',
+        };
+
+        const saveStub = sandbox.stub(element.$.restAPI, 'saveProjectConfig'
+            , () => {
+              return Promise.resolve({});
+            });
+
+        const button = Polymer.dom(element.root).querySelector('gr-button');
+
+        element._loadProject().then(() => {
+          assert.isTrue(button.hasAttribute('disabled'));
+          assert.isFalse(element.$.Title.classList.contains('edited'));
+          element.$.descriptionInput.bindValue = configInputObj.description;
+          element.$.stateSelect.bindValue = configInputObj.state;
+          element.$.submitTypeSelect.bindValue = configInputObj.submit_type;
+          element.$.contentMergeSelect.bindValue =
+              configInputObj.use_content_merge;
+          element.$.newChangeSelect.bindValue =
+              configInputObj.create_new_change_for_all_not_in_target;
+          element.$.requireChangeIdSelect.bindValue =
+              configInputObj.require_change_id;
+          element.$.enableSignedPush.bindValue =
+              configInputObj.enable_signed_push;
+          element.$.requireSignedPush.bindValue =
+              configInputObj.require_signed_push;
+          element.$.rejectImplicitMergesSelect.bindValue =
+              configInputObj.reject_implicit_merges;
+          element.$.setAllnewChangesPrivateByDefaultSelect.bindValue =
+              configInputObj.private_by_default;
+          element.$.setAllNewChangesWorkInProgressByDefaultSelect.bindValue =
+              configInputObj.work_in_progress_by_default;
+          element.$.matchAuthoredDateWithCommitterDateSelect.bindValue =
+              configInputObj.match_author_to_committer_date;
+          element.$.maxGitObjSizeInput.bindValue =
+              configInputObj.max_object_size_limit;
+          element.$.contributorAgreementSelect.bindValue =
+              configInputObj.use_contributor_agreements;
+          element.$.useSignedOffBySelect.bindValue =
+              configInputObj.use_signed_off_by;
+          element.$.unRegisteredCcSelect.bindValue =
+              configInputObj.enable_reviewer_by_email;
+
+          assert.isFalse(button.hasAttribute('disabled'));
+          assert.isTrue(element.$.configurations.classList.contains('edited'));
+
+          const formattedObj =
+              element._formatProjectConfigForSave(element._projectConfig);
+          assert.deepEqual(formattedObj, configInputObj);
+
+          element._handleSaveProjectConfig().then(() => {
+            assert.isTrue(button.hasAttribute('disabled'));
+            assert.isFalse(element.$.Title.classList.contains('edited'));
+            assert.isTrue(saveStub.lastCall.calledWithExactly(PROJECT,
+                configInputObj));
+            done();
+          });
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
new file mode 100644
index 0000000..18612c8
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.html
@@ -0,0 +1,142 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-access-behavior/gr-access-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+<dom-module id="gr-rule-editor">
+  <template>
+    <style include="shared-styles">
+      :host {
+        border-bottom: 1px solid #d1d2d3;
+        padding: .7em;
+        display: block;
+      }
+      .buttons {
+        display: none;
+      }
+      .editing .buttons {
+        display: flex;
+      }
+      #options {
+        align-items: baseline;
+        display: flex;
+      }
+      #options > * {
+        margin-right: .5em;
+      }
+      #mainContainer {
+        align-items: baseline;
+        display: flex;
+        flex-wrap: nowrap;
+        justify-content: space-between;
+      }
+      .buttons gr-button {
+        float: left;
+        margin-left: .3em;
+      }
+      #undoBtn,
+      #force,
+      #deletedContainer,
+      #mainContainer.deleted {
+        display: none;
+      }
+      #undoBtn.modified,
+      #force.force,
+      #deletedContainer.deleted {
+        display: block;
+      }
+      .groupPath {
+        color: #666;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <div id="mainContainer"
+        class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
+      <div id="options">
+        <gr-select id="action"
+            bind-value="{{rule.value.action}}"
+            on-change="_handleValueChange">
+          <select disabled$="[[!editing]]">
+            <template is="dom-repeat" items="[[_computeOptions(permission)]]">
+              <option value="[[item]]">[[item]]</option>
+            </template>
+          </select>
+        </gr-select>
+        <template is="dom-if" if="[[label]]">
+          <gr-select
+              id="labelMin"
+              bind-value="{{rule.value.min}}"
+              on-change="_handleValueChange">
+            <select disabled$="[[!editing]]">
+              <template is="dom-repeat" items="[[label.values]]">
+                <option value="[[item.value]]">[[item.value]]</option>
+              </template>
+            </select>
+          </gr-select>
+          <gr-select
+              id="labelMax"
+              bind-value="{{rule.value.max}}"
+              on-change="_handleValueChange">
+            <select disabled$="[[!editing]]">
+              <template is="dom-repeat" items="[[label.values]]">
+                <option value="[[item.value]]">[[item.value]]</option>
+              </template>
+            </select>
+          </gr-select>
+        </template>
+        <a class="groupPath" href$="[[_computeGroupPath(groupId)]]">
+          [[groupName]]
+        </a>
+        <gr-select
+            id="force"
+            class$="[[_computeForceClass(permission)]]"
+            bind-value="{{rule.value.force}}"
+            on-change="_handleValueChange">
+          <select disabled$="[[!editing]]">
+            <template
+                is="dom-repeat"
+                items="[[_computeForceOptions(permission)]]">
+              <option value="[[item.value]]">[[item.name]]</option>
+            </template>
+          </select>
+        </gr-select>
+      </div>
+      <div class="buttons">
+        <gr-button
+            id="undoBtn"
+            on-tap="_handleUndoChange"
+            class$="[[_computeModifiedClass(_modified)]]">Undo</gr-button>
+        <gr-button id="removeBtn" on-tap="_handleRemoveRule">Remove</gr-button>
+      </div>
+    </div>
+    <div
+        id="deletedContainer"
+        class$="gr-form-styles [[_computeSectionClass(editing, _deleted)]]">
+      [[groupName]] was deleted
+      <gr-button id="undoRemoveBtn" on-tap="_handleUndoRemove">Undo</gr-button>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-rule-editor.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
new file mode 100644
index 0000000..7f1a245
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor.js
@@ -0,0 +1,192 @@
+// 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.
+(function() {
+  'use strict';
+
+  const PRIORITY_OPTIONS = [
+    'BATCH',
+    'INTERACTIVE',
+  ];
+
+  const DROPDOWN_OPTIONS = [
+    'ALLOW',
+    'DENY',
+    'BLOCK',
+  ];
+
+  const FORCE_PUSH_OPTIONS = [
+    {
+      name: 'No Force Push',
+      value: false,
+    },
+    {
+      name: 'Force Push',
+      value: true,
+    },
+  ];
+
+  const FORCE_EDIT_OPTIONS = [
+    {
+      name: 'No Force Edit',
+      value: false,
+    },
+    {
+      name: 'Force Edit',
+      value: true,
+    },
+  ];
+
+  Polymer({
+    is: 'gr-rule-editor',
+
+    properties: {
+      /** @type {?} */
+      label: Object,
+      editing: {
+        type: Boolean,
+        value: false,
+      },
+      groupId: String,
+      groupName: String,
+      permission: String,
+      /** @type {?} */
+      rule: {
+        type: Object,
+        notify: true,
+      },
+      section: String,
+      _modified: {
+        type: Boolean,
+        value: false,
+      },
+      _originalRuleValues: Object,
+      _deleted: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    behaviors: [
+      Gerrit.AccessBehavior,
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    observers: [
+      '_handleValueChange(rule.value.*)',
+    ],
+
+    ready() {
+      // Called on ready rather than the observer because when new rules are
+      // added, the observer is triggered prior to being ready.
+      if (!this.rule) { return; } // Check needed for test purposes.
+      this._setupValues(this.rule);
+    },
+
+    _setupValues(rule) {
+      if (!rule.value) {
+        this._setDefaultRuleValues();
+      }
+      this._setOriginalRuleValues(rule.value);
+    },
+
+    _computeForce(permission) {
+      return this.permissionValues.push.id === permission ||
+          this.permissionValues.editTopicName.id === permission;
+    },
+
+    _computeForceClass(permission) {
+      return this._computeForce(permission) ? 'force' : '';
+    },
+
+    _computeGroupPath(group) {
+      return `${this.getBaseUrl()}/admin/groups/${this.encodeURL(group, true)}`;
+    },
+
+    _computeSectionClass(editing, deleted) {
+      const classList = [];
+      if (editing) {
+        classList.push('editing');
+      }
+      if (deleted) {
+        classList.push('deleted');
+      }
+      return classList.join(' ');
+    },
+
+    _computeForceOptions(permission) {
+      if (permission === this.permissionValues.push.id) {
+        return FORCE_PUSH_OPTIONS;
+      } else if (permission === this.permissionValues.editTopicName.id) {
+        return FORCE_EDIT_OPTIONS;
+      }
+      return [];
+    },
+
+    _getDefaultRuleValues(permission, label) {
+      const value = {};
+      if (permission === 'priority') {
+        value.action = PRIORITY_OPTIONS[0];
+        return value;
+      } else if (label) {
+        value.min = label.values[0].value;
+        value.max = label.values[label.values.length - 1].value;
+      } else if (this._computeForce(permission)) {
+        value.force = this._computeForceOptions(permission)[0].value;
+      }
+      value.action = DROPDOWN_OPTIONS[0];
+      return value;
+    },
+
+    _setDefaultRuleValues() {
+      this.set('rule.value',
+          this._getDefaultRuleValues(this.permission, this.label));
+    },
+
+    _computeOptions(permission) {
+      if (permission === 'priority') {
+        return PRIORITY_OPTIONS;
+      }
+      return DROPDOWN_OPTIONS;
+    },
+
+    _handleRemoveRule() {
+      this._deleted = true;
+      this.set('rule.value.deleted', true);
+    },
+
+    _handleUndoRemove() {
+      this._deleted = false;
+      delete this.rule.value.deleted;
+    },
+
+    _handleUndoChange() {
+      this.set('rule.value', Object.assign({}, this._originalRuleValues));
+      this._modified = false;
+    },
+
+    _handleValueChange() {
+      if (!this._originalRuleValues) { return; }
+      this._modified = true;
+    },
+
+    _setOriginalRuleValues(value) {
+      this._originalRuleValues = Object.assign({}, value);
+    },
+
+    _computeModifiedClass(modified) {
+      return modified ? 'modified' : '';
+    },
+  });
+})();
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
new file mode 100644
index 0000000..3594e4b
--- /dev/null
+++ b/polygerrit-ui/app/elements/admin/gr-rule-editor/gr-rule-editor_test.html
@@ -0,0 +1,600 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-rule-editor</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-rule-editor.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-rule-editor></gr-rule-editor>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-rule-editor tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('unit tests', () => {
+      test('_computeForce, _computeForceClass, and _computeForceOptions',
+          () => {
+            const FORCE_PUSH_OPTIONS = [
+              {
+                name: 'No Force Push',
+                value: false,
+              },
+              {
+                name: 'Force Push',
+                value: true,
+              },
+            ];
+
+            const FORCE_EDIT_OPTIONS = [
+              {
+                name: 'No Force Edit',
+                value: false,
+              },
+              {
+                name: 'Force Edit',
+                value: true,
+              },
+            ];
+            let permission = 'push';
+            assert.isTrue(element._computeForce(permission));
+            assert.equal(element._computeForceClass(permission), 'force');
+            assert.deepEqual(element._computeForceOptions(permission),
+                FORCE_PUSH_OPTIONS);
+            permission = 'editTopicName';
+            assert.isTrue(element._computeForce(permission));
+            assert.equal(element._computeForceClass(permission), 'force');
+            assert.deepEqual(element._computeForceOptions(permission),
+                FORCE_EDIT_OPTIONS);
+            permission = 'submit';
+            assert.isFalse(element._computeForce(permission));
+            assert.equal(element._computeForceClass(permission), '');
+            assert.deepEqual(element._computeForceOptions(permission), []);
+          });
+
+      test('_computeSectionClass', () => {
+        let deleted = true;
+        let editing = false;
+        assert.equal(element._computeSectionClass(editing, deleted), 'deleted');
+
+        deleted = false;
+        assert.equal(element._computeSectionClass(editing, deleted), '');
+
+        editing = true;
+        assert.equal(element._computeSectionClass(editing, deleted), 'editing');
+
+        deleted = true;
+        assert.equal(element._computeSectionClass(editing, deleted),
+            'editing deleted');
+      });
+
+      test('_getDefaultRuleValues', () => {
+        let permission = 'priority';
+        let label;
+        assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'BATCH'});
+        permission = 'label-Code-Review';
+        label = {values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ]};
+        assert.deepEqual(element._getDefaultRuleValues(permission, label),
+            {action: 'ALLOW', max: 2, min: -2});
+        permission = 'push';
+        label = undefined;
+        assert.deepEqual(element._getDefaultRuleValues(permission, label),
+          {action: 'ALLOW', force: false});
+        permission = 'submit';
+        assert.deepEqual(element._getDefaultRuleValues(permission, label),
+            {action: 'ALLOW'});
+      });
+
+      test('_setDefaultRuleValues', () => {
+        element.rule = {id: 123};
+        const defaultValue = {action: 'ALLOW'};
+        sandbox.stub(element, '_getDefaultRuleValues').returns(defaultValue);
+        element._setDefaultRuleValues();
+        assert.isTrue(element._getDefaultRuleValues.called);
+        assert.equal(element.rule.value, defaultValue);
+      });
+
+      test('_computeOptions', () => {
+        const PRIORITY_OPTIONS = [
+          'BATCH',
+          'INTERACTIVE',
+        ];
+        const DROPDOWN_OPTIONS = [
+          'ALLOW',
+          'DENY',
+          'BLOCK',
+        ];
+        let permission = 'priority';
+        assert.deepEqual(element._computeOptions(permission), PRIORITY_OPTIONS);
+        permission = 'submit';
+        assert.deepEqual(element._computeOptions(permission), DROPDOWN_OPTIONS);
+      });
+
+      test('_handleValueChange', () => {
+        element._handleValueChange();
+        assert.isFalse(element._modified);
+        element._originalRuleValues = {};
+        element._handleValueChange();
+        assert.isTrue(element._modified);
+      });
+
+      test('_setOriginalRuleValues', () => {
+        const value = {
+          action: 'ALLOW',
+          force: false,
+        };
+        element._setOriginalRuleValues(value);
+        assert.deepEqual(element._originalRuleValues, value);
+      });
+    });
+
+    suite('already existing generic rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'submit';
+        element.rule = {
+          id: '123',
+          value: {
+            action: 'ALLOW',
+            force: false,
+          },
+        };
+        element.section = 'refs/*';
+
+        // Typically called on ready since elements will have properies defined
+        // by the parent element.
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+
+      test('values are set correctly', () => {
+        assert.equal(element.$.action.bindValue, element.rule.value.action);
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
+        assert.isFalse(element.$.force.classList.contains('force'));
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.action.bindValue = 'DENY';
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+        assert.equal(element.$.action.bindValue, 'ALLOW');
+        assert.isFalse(element._modified);
+      });
+
+      test('all selects are disabled when not in edit mode', () => {
+        const selects = Polymer.dom(element.root).querySelectorAll('select');
+        for (select of selects) {
+          assert.isTrue(select.disabled);
+        }
+        element.editing = true;
+        for (select of selects) {
+          assert.isFalse(select.disabled);
+        }
+      });
+
+      test('remove rule and undo remove', () => {
+        element.editing = true;
+        element.rule = {id: 123, value: {action: 'ALLOW'}};
+        assert.isFalse(
+            element.$.deletedContainer.classList.contains('deleted'));
+        MockInteractions.tap(element.$.removeBtn);
+        assert.isTrue(element.$.deletedContainer.classList.contains('deleted'));
+        assert.isTrue(element.rule.value.deleted);
+
+        MockInteractions.tap(element.$.undoRemoveBtn);
+        assert.isNotOk(element.rule.value.deleted);
+      });
+
+      test('_computeGroupPath', () => {
+        const group = '123';
+        assert.equal(element._computeGroupPath(group),
+            `/admin/groups/123`);
+      });
+    });
+
+    suite('new edit rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'editTopicName';
+        element.rule = {
+          id: '123',
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        // Since the element does not already have default values, they should
+        // be set. The original values should be set to those too.
+        assert.isFalse(element._modified);
+        const expectedRuleValue = {
+          action: 'ALLOW',
+          force: false,
+        };
+        assert.deepEqual(element.rule.value, expectedRuleValue);
+        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
+        test('values are set correctly', () => {
+          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+        });
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.force.bindValue = true;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('already existing rule with labels', () => {
+      setup(() => {
+        element.label = {values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ]};
+        element.group = 'Group Name';
+        element.permission = 'label-Code-Review';
+        element.rule = {
+          id: '123',
+          value: {
+            action: 'ALLOW',
+            force: false,
+            max: 2,
+            min: -2,
+          },
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+
+      test('values are set correctly', () => {
+        assert.equal(element.$.action.bindValue, element.rule.value.action);
+        assert.equal(
+            Polymer.dom(element.root).querySelector('#labelMin').bindValue,
+            element.rule.value.min);
+        assert.equal(
+            Polymer.dom(element.root).querySelector('#labelMax').bindValue,
+            element.rule.value.max);
+        assert.isFalse(element.$.force.classList.contains('force'));
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('new rule with labels', () => {
+      setup(() => {
+        sandbox.spy(element, '_setDefaultRuleValues');
+        element.label = {values: [
+          {value: -2, text: 'This shall not be merged'},
+          {value: -1, text: 'I would prefer this is not merged as is'},
+          {value: -0, text: 'No score'},
+          {value: 1, text: 'Looks good to me, but someone else must approve'},
+          {value: 2, text: 'Looks good to me, approved'},
+        ]};
+        element.group = 'Group Name';
+        element.permission = 'label-Code-Review';
+        element.rule = {
+          id: '123',
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        // Since the element does not already have default values, they should
+        // be set. The original values should be set to those too.
+        assert.isFalse(element._modified);
+        assert.isTrue(element._setDefaultRuleValues.called);
+
+        const expectedRuleValue = {
+          max: element.label.values[element.label.values.length - 1].value,
+          min: element.label.values[0].value,
+          action: 'ALLOW',
+        };
+        assert.deepEqual(element.rule.value, expectedRuleValue);
+        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
+        test('values are set correctly', () => {
+          assert.equal(
+              element.$.action.bindValue,
+              expectedRuleValue.action);
+          assert.equal(
+              Polymer.dom(element.root).querySelector('#labelMin').bindValue,
+              expectedRuleValue.min);
+          assert.equal(
+              Polymer.dom(element.root).querySelector('#labelMax').bindValue,
+              expectedRuleValue.max);
+        });
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        Polymer.dom(element.root).querySelector('#labelMin').bindValue = 1;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('already existing push rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'push';
+        element.rule = {
+          id: '123',
+          value: {
+            action: 'ALLOW',
+            force: true,
+          },
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+
+      test('values are set correctly', () => {
+        assert.isTrue(element.$.force.classList.contains('force'));
+        assert.equal(element.$.action.bindValue, element.rule.value.action);
+        assert.equal(
+            Polymer.dom(element.root).querySelector('#force').bindValue,
+            element.rule.value.force);
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.action.bindValue = false;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('new push rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'push';
+        element.rule = {
+          id: '123',
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        // Since the element does not already have default values, they should
+        // be set. The original values should be set to those too.
+        assert.isFalse(element._modified);
+        const expectedRuleValue = {
+          action: 'ALLOW',
+          force: false,
+        };
+        assert.deepEqual(element.rule.value, expectedRuleValue);
+        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
+        test('values are set correctly', () => {
+          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+        });
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.force.bindValue = true;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('already existing edit rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'editTopicName';
+        element.rule = {
+          id: '123',
+          value: {
+            action: 'ALLOW',
+            force: true,
+          },
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+
+      test('values are set correctly', () => {
+        assert.isTrue(element.$.force.classList.contains('force'));
+        assert.equal(element.$.action.bindValue, element.rule.value.action);
+        assert.equal(
+            Polymer.dom(element.root).querySelector('#force').bindValue,
+            element.rule.value.force);
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMin'));
+        assert.isNotOk(Polymer.dom(element.root).querySelector('#labelMax'));
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.action.bindValue = false;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+
+    suite('new edit rule', () => {
+      setup(() => {
+        element.group = 'Group Name';
+        element.permission = 'editTopicName';
+        element.rule = {
+          id: '123',
+        };
+        element.section = 'refs/*';
+        element._setupValues(element.rule);
+        flushAsynchronousOperations();
+      });
+
+      test('_ruleValues and _originalRuleValues are set correctly', () => {
+        // Since the element does not already have default values, they should
+        // be set. The original values should be set to those too.
+        assert.isFalse(element._modified);
+        const expectedRuleValue = {
+          action: 'ALLOW',
+          force: false,
+        };
+        assert.deepEqual(element.rule.value, expectedRuleValue);
+        assert.deepEqual(element._originalRuleValues, expectedRuleValue);
+        test('values are set correctly', () => {
+          assert.equal(element.$.action.bindValue, expectedRuleValue.action);
+          assert.equal(element.$.force.bindValue, expectedRuleValue.action);
+        });
+      });
+
+      test('modify and undo value', () => {
+        assert.isFalse(element._modified);
+        assert.isFalse(element.$.undoBtn.classList.contains('modified'));
+        element.$.force.bindValue = true;
+        flushAsynchronousOperations();
+        assert.isTrue(element._modified);
+        assert.isTrue(element.$.undoBtn.classList.contains('modified'));
+
+        // The original value should now differ from the rule values.
+        assert.notDeepEqual(element._originalRuleValues, element.rule.value);
+
+        // After undoing the change, the original value should get reset.
+        MockInteractions.tap(element.$.undoBtn);
+        assert.deepEqual(element._originalRuleValues, element.rule.value);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
index d50e0b3..0fab4af 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.html
@@ -15,26 +15,33 @@
 -->
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-change-list-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-limited-text/gr-limited-text.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-list-item">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: table-row;
         border-bottom: 1px solid #eee;
       }
+      :host(:hover) {
+        background-color: #f5fafd;
+      }
       :host([selected]) {
         background-color: #ebf5fb;
       }
       :host([needs-review]) {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       :host([assigned]) {
         background-color: #fcfad6;
@@ -68,7 +75,8 @@
       }
       a {
         color: var(--default-text-color);
-        display: block;
+        cursor: pointer;
+        display: inline-block;
         text-decoration: none;
       }
       a:hover {
@@ -130,15 +138,32 @@
         hidden$="[[isColumnHidden('Owner', visibleChangeTableColumns)]]">
       <gr-account-link account="[[change.owner]]"></gr-account-link>
     </td>
+    <td class="cell assignee"
+        hidden$="[[isColumnHidden('Assignee', visibleChangeTableColumns)]]">
+      <template is="dom-if" if="[[change.assignee]]">
+        <gr-account-link account="[[change.assignee]]"></gr-account-link>
+      </template>
+    </td>
     <td class="cell project"
         hidden$="[[isColumnHidden('Project', visibleChangeTableColumns)]]">
-      <a href$="[[_computeProjectURL(change.project)]]">[[change.project]]</a>
+      <a class="fullProject" href$="[[_computeProjectURL(change.project)]]">
+        [[change.project]]
+      </a>
+      <a class="truncatedProject" href$="[[_computeProjectURL(change.project)]]">
+        [[_computeTruncatedProject(change.project)]]
+      </a>
     </td>
     <td class="cell branch"
         hidden$="[[isColumnHidden('Branch', visibleChangeTableColumns)]]">
-      <a href$="[[_computeProjectBranchURL(change.project, change.branch)]]">
+      <a href$="[[_computeProjectBranchURL(change)]]">
         [[change.branch]]
       </a>
+      <template is="dom-if" if="[[change.topic]]">
+        (<a href$="[[_computeTopicURL(change)]]"><!--
+       --><gr-limited-text limit="50" text="[[change.topic]]">
+          </gr-limited-text><!--
+     --></a>)
+      </template>
     </td>
     <td class="cell updated"
         hidden$="[[isColumnHidden('Updated', visibleChangeTableColumns)]]">
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
index 566dfe0..d8fced2 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item.js
@@ -22,33 +22,36 @@
       labelNames: {
         type: Array,
       },
+
+      /** @type {?} */
       change: Object,
       changeURL: {
         type: String,
-        computed: '_computeChangeURL(change._number)',
+        computed: '_computeChangeURL(change)',
       },
       showStar: {
         type: Boolean,
         value: false,
       },
+      showNumber: Boolean,
     },
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
       Gerrit.ChangeTableBehavior,
+      Gerrit.PathListBehavior,
       Gerrit.RESTClientBehavior,
       Gerrit.URLEncodingBehavior,
     ],
 
-    _computeChangeURL: function(changeNum) {
-      if (!changeNum) { return ''; }
-      return this.getBaseUrl() + '/c/' + changeNum + '/';
+    _computeChangeURL(change) {
+      return Gerrit.Nav.getUrlForChange(change);
     },
 
-    _computeLabelTitle: function(change, labelName) {
-      var label = change.labels[labelName];
+    _computeLabelTitle(change, labelName) {
+      const label = change.labels[labelName];
       if (!label) { return 'Label not applicable'; }
-      var significantLabel = label.rejected || label.approved ||
+      const significantLabel = label.rejected || label.approved ||
           label.disliked || label.recommended;
       if (significantLabel && significantLabel.name) {
         return labelName + '\nby ' + significantLabel.name;
@@ -56,12 +59,12 @@
       return labelName;
     },
 
-    _computeLabelClass: function(change, labelName) {
-      var label = change.labels[labelName];
+    _computeLabelClass(change, labelName) {
+      const label = change.labels[labelName];
       // Mimic a Set.
-      var classes = {
-        'cell': true,
-        'label': true,
+      const classes = {
+        cell: true,
+        label: true,
       };
       if (label) {
         if (label.approved) {
@@ -83,8 +86,8 @@
       return Object.keys(classes).sort().join(' ');
     },
 
-    _computeLabelValue: function(change, labelName) {
-      var label = change.labels[labelName];
+    _computeLabelValue(change, labelName) {
+      const label = change.labels[labelName];
       if (!label) { return ''; }
       if (label.approved) {
         return '✓';
@@ -101,15 +104,22 @@
       return '';
     },
 
-    _computeProjectURL: function(project) {
-      return this.getBaseUrl() + '/q/status:open+project:' +
-          this.encodeURL(project, false);
+    _computeProjectURL(project) {
+      return Gerrit.Nav.getUrlForProject(project, true);
     },
 
-    _computeProjectBranchURL: function(project, branch) {
-      // @see Issue 4255.
-      return this._computeProjectURL(project) +
-          '+branch:' + this.encodeURL(branch, false);
+    _computeProjectBranchURL(change) {
+      return Gerrit.Nav.getUrlForBranch(change.branch, change.project);
+    },
+
+    _computeTopicURL(change) {
+      if (!change.topic) { return ''; }
+      return Gerrit.Nav.getUrlForTopic(change.topic);
+    },
+
+    _computeTruncatedProject(project) {
+      if (!project) { return ''; }
+      return this.truncatePath(project, 2);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
index ad76f10..243a8ab 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-item/gr-change-list-item_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-change-list-item.html">
 
 <script>void(0);</script>
@@ -34,32 +34,18 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-list-item tests', function() {
-    var element;
+  suite('gr-change-list-item tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       element = fixture('basic');
     });
 
-    test('change status', function() {
-      var getStatusForChange = function(change) {
-        element.change = change;
-        return element.$$('.cell.status').textContent.trim();
-      };
-
-      assert.equal(getStatusForChange({mergeable: true}), '');
-      assert.equal(getStatusForChange({mergeable: false}), 'Merge Conflict');
-      assert.equal(getStatusForChange({status: 'NEW'}), '');
-      assert.equal(getStatusForChange({status: 'MERGED'}), 'Merged');
-      assert.equal(getStatusForChange({status: 'ABANDONED'}), 'Abandoned');
-      assert.equal(getStatusForChange({status: 'DRAFT'}), 'Draft');
-    });
-
-    test('computed fields', function() {
+    test('computed fields', () => {
       assert.equal(element._computeLabelClass({labels: {}}),
           'cell label u-gray-background');
       assert.equal(element._computeLabelClass(
@@ -99,19 +85,19 @@
           'Code-Review'), 'Code-Review\nby Diffy');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+            rejected: {name: 'Admin'}}}}, 'Code-Review'),
           'Code-Review\nby Admin');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {approved: {name: 'Diffy'},
-          rejected: {name: 'Admin'}}}}, 'Code-Review'),
+            rejected: {name: 'Admin'}}}}, 'Code-Review'),
           'Code-Review\nby Admin');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {recommended: {name: 'Diffy'},
-          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+            disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
           'Code-Review\nby Admin');
       assert.equal(element._computeLabelTitle(
           {labels: {'Code-Review': {approved: {name: 'Diffy'},
-          disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
+            disliked: {name: 'Admin'}, value: -1}}}, 'Code-Review'),
           'Code-Review\nby Diffy');
 
       assert.equal(element._computeLabelValue({labels: {}}), '');
@@ -126,25 +112,14 @@
           {labels: {Verified: {approved: true}}}, 'Verified'), '✓');
       assert.equal(element._computeLabelValue(
           {labels: {Verified: {rejected: true}}}, 'Verified'), '✕');
-
-      assert.equal(element._computeProjectURL('combustible/stuff'),
-          '/q/status:open+project:combustible%252Fstuff');
-
-      assert.equal(element._computeProjectBranchURL(
-          'combustible-stuff', 'le/mons'),
-          '/q/status:open+project:combustible-stuff+branch:le%252Fmons');
-
-      element.change = {_number: 42};
-      assert.equal(element.changeURL, '/c/42/');
-      element.change = {_number: 43};
-      assert.equal(element.changeURL, '/c/43/');
     });
 
-    test('no hidden columns', function() {
+    test('no hidden columns', () => {
       element.visibleChangeTableColumns = [
         'Subject',
         'Status',
         'Owner',
+        'Assignee',
         'Project',
         'Branch',
         'Updated',
@@ -153,17 +128,18 @@
 
       flushAsynchronousOperations();
 
-      element.columnNames.forEach(function(column) {
-        var elementClass = '.' + column.toLowerCase();
+      for (const column of element.columnNames) {
+        const elementClass = '.' + column.toLowerCase();
         assert.isFalse(element.$$(elementClass).hidden);
-      });
+      }
     });
 
-    test('no hidden columns', function() {
+    test('no hidden columns', () => {
       element.visibleChangeTableColumns = [
         'Subject',
         'Status',
         'Owner',
+        'Assignee',
         'Project',
         'Branch',
         'Updated',
@@ -172,17 +148,18 @@
 
       flushAsynchronousOperations();
 
-      element.columnNames.forEach(function(column) {
-        var elementClass = '.' + column.toLowerCase();
+      for (const column of element.columnNames) {
+        const elementClass = '.' + column.toLowerCase();
         assert.isFalse(element.$$(elementClass).hidden);
-      });
+      }
     });
 
-    test('project column hidden', function() {
+    test('project column hidden', () => {
       element.visibleChangeTableColumns = [
         'Subject',
         'Status',
         'Owner',
+        'Assignee',
         'Branch',
         'Updated',
         'Size',
@@ -190,25 +167,35 @@
 
       flushAsynchronousOperations();
 
-      element.columnNames.forEach(function(column) {
-        var elementClass = '.' + column.toLowerCase();
+      for (const column of element.columnNames) {
+        const elementClass = '.' + column.toLowerCase();
         if (column === 'Project') {
           assert.isTrue(element.$$(elementClass).hidden);
         } else {
           assert.isFalse(element.$$(elementClass).hidden);
         }
-      });
+      }
     });
 
-    test('random column does not exist', function() {
+    test('random column does not exist', () => {
       element.visibleChangeTableColumns = [
         'Bad',
       ];
 
       flushAsynchronousOperations();
-      var elementClass = '.bad';
+      const elementClass = '.bad';
       assert.isNotOk(element.$$(elementClass));
     });
 
+    test('assignee only displayed if there is one', () => {
+      assert.isNotOk(element.$$('.assignee gr-account-link'));
+      element.change = {
+        assignee: {
+          name: 'test',
+        },
+      };
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('.assignee gr-account-link'));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
index afe0e38..276febb 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.html
@@ -17,12 +17,15 @@
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-change-list/gr-change-list.html">
+<link rel="import" href="../gr-user-header/gr-user-header.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-list-view">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         background-color: var(--view-background-color);
         display: block;
@@ -44,6 +47,9 @@
       nav a:first-of-type {
         margin-right: .5em;
       }
+      .hide {
+        display: none;
+      }
       @media only screen and (max-width: 50em) {
         .loading,
         .error {
@@ -53,6 +59,11 @@
     </style>
     <div class="loading" hidden$="[[!_loading]]" hidden>Loading...</div>
     <div hidden$="[[_loading]]" hidden>
+      <gr-user-header
+          user-id="[[_userId]]"
+          show-dashboard-link
+          logged-in="[[loggedIn]]"
+          class$="[[_computeUserHeaderClass(_userId)]]"></gr-user-header>
       <gr-change-list
           changes="{{_changes}}"
           selected-index="{{viewState.selectedChangeIndex}}"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
index 82d85ac..c48b860 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view.js
@@ -14,11 +14,15 @@
 (function() {
   'use strict';
 
-  var LookupQueryPatterns = {
+  const LookupQueryPatterns = {
     CHANGE_ID: /^\s*i?[0-9a-f]{8,40}\s*$/i,
     CHANGE_NUM: /^\s*[1-9][0-9]*\s*$/g,
   };
 
+  const USER_QUERY_PATTERN = /^owner:\s?("[^"]+"|[^ ]+)$/;
+
+  const LIMIT_OPERATOR_PATTERN = /\blimit:(\d+)/i;
+
   Polymer({
     is: 'gr-change-list-view',
 
@@ -52,11 +56,16 @@
 
       /**
        * State persisted across restamps of the element.
+       *
+       * Need sub-property declaration since it is used in template before
+       * assignment.
+       * @type {{ selectedChangeIndex: (number|undefined) }}
+       *
        */
       viewState: {
         type: Object,
         notify: true,
-        value: function() { return {}; },
+        value() { return {}; },
       },
 
       _changesPerPage: Number,
@@ -77,7 +86,10 @@
       /**
        * Change objects loaded from the server.
        */
-      _changes: Array,
+      _changes: {
+        type: Array,
+        observer: '_changesChanged',
+      },
 
       /**
        * For showing a "loading..." string during ajax requests.
@@ -86,6 +98,12 @@
         type: Boolean,
         value: true,
       },
+
+      /** @type {?String} */
+      _userId: {
+        type: String,
+        value: null,
+      },
     },
 
     listeners: {
@@ -93,12 +111,12 @@
       'previous-page': '_handlePreviousPage',
     },
 
-    attached: function() {
+    attached() {
       this.fire('title-change', {title: this._query});
     },
 
-    _paramsChanged: function(value) {
-      if (value.view != this.tagName.toLowerCase()) { return; }
+    _paramsChanged(value) {
+      if (value.view !== Gerrit.Nav.View.SEARCH) { return; }
 
       this._loading = true;
       this._query = value.query;
@@ -112,64 +130,92 @@
 
       this.fire('title-change', {title: this._query});
 
-      this._getPreferences().then(function(prefs) {
+      this._getPreferences().then(prefs => {
         this._changesPerPage = prefs.changes_per_page;
         return this._getChanges();
-      }.bind(this)).then(function(changes) {
+      }).then(changes => {
+        changes = changes || [];
         if (this._query && changes.length === 1) {
-          for (var query in LookupQueryPatterns) {
+          for (const query in LookupQueryPatterns) {
             if (LookupQueryPatterns.hasOwnProperty(query) &&
                 this._query.match(LookupQueryPatterns[query])) {
-              page.show('/c/' + changes[0]._number);
+              this._replaceCurrentLocation(
+                  Gerrit.Nav.getUrlForChange(changes[0]));
               return;
             }
           }
         }
         this._changes = changes;
         this._loading = false;
-      }.bind(this));
+      });
     },
 
-    _getChanges: function() {
+    _replaceCurrentLocation(url) {
+      window.location.replace(url);
+    },
+
+    _getChanges() {
       return this.$.restAPI.getChanges(this._changesPerPage, this._query,
           this._offset);
     },
 
-    _getPreferences: function() {
+    _getPreferences() {
       return this.$.restAPI.getPreferences();
     },
 
-    _computeNavLink: function(query, offset, direction, changesPerPage) {
+    _limitFor(query, defaultLimit) {
+      const match = query.match(LIMIT_OPERATOR_PATTERN);
+      if (!match) {
+        return defaultLimit;
+      }
+      return parseInt(match[1], 10);
+    },
+
+    _computeNavLink(query, offset, direction, changesPerPage) {
       // Offset could be a string when passed from the router.
       offset = +(offset || 0);
-      var newOffset = Math.max(0, offset + (changesPerPage * direction));
+      const limit = this._limitFor(query, changesPerPage);
+      const newOffset = Math.max(0, offset + (limit * direction));
       // Double encode URI component.
-      var href = this.getBaseUrl() + '/q/' + this.encodeURL(query, false);
+      let href = this.getBaseUrl() + '/q/' + this.encodeURL(query, false);
       if (newOffset > 0) {
         href += ',' + newOffset;
       }
       return href;
     },
 
-    _hidePrevArrow: function(offset) {
+    _hidePrevArrow(offset) {
       return offset === 0;
     },
 
-    _hideNextArrow: function(loading) {
+    _hideNextArrow(loading) {
       return loading || !this._changes || !this._changes.length ||
           !this._changes[this._changes.length - 1]._more_changes;
     },
 
-    _handleNextPage: function() {
+    _handleNextPage() {
       if (this.$.nextArrow.hidden) { return; }
       page.show(this._computeNavLink(
           this._query, this._offset, 1, this._changesPerPage));
     },
 
-    _handlePreviousPage: function() {
+    _handlePreviousPage() {
       if (this.$.prevArrow.hidden) { return; }
       page.show(this._computeNavLink(
           this._query, this._offset, -1, this._changesPerPage));
     },
+
+    _changesChanged(changes) {
+      if (!changes || !changes.length ||
+          !USER_QUERY_PATTERN.test(this._query)) {
+        this._userId = null;
+        return;
+      }
+      this._userId = changes[0].owner.email;
+    },
+
+    _computeUserHeaderClass(userId) {
+      return userId ? '' : 'hide';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
index 661dd2c..680f835 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list-view/gr-change-list-view_test.html
@@ -21,8 +21,7 @@
 <script src="../../../bower_components/page/page.js"></script>
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-list-view.html">
 
 <script>void(0);</script>
@@ -34,17 +33,17 @@
 </test-fixture>
 
 <script>
-  var CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
-  var COMMIT_HASH = '12345678';
+  const CHANGE_ID = 'IcA3dAB3edAB9f60B8dcdA6ef71A75980e4B7127';
+  const COMMIT_HASH = '12345678';
 
-  suite('gr-change-list-view tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-change-list-view tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
-        getChanges: function(num, query) {
+        getLoggedIn() { return Promise.resolve(false); },
+        getChanges(num, query) {
           return Promise.resolve([]);
         },
       });
@@ -52,14 +51,14 @@
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function(done) {
-      flush(function() {
+    teardown(done => {
+      flush(() => {
         sandbox.restore();
         done();
       });
     });
 
-    test('url is properly encoded', function() {
+    test('url is properly encoded', () => {
       assert.equal(element._computeNavLink(
           'status:open project:platform/frameworks/base', 0, -1, 25),
           '/q/status:open+project:platform%252Fframeworks%252Fbase'
@@ -70,11 +69,20 @@
       );
     });
 
-    test('_computeNavLink', function() {
-      var query = 'status:open';
-      var offset = 0;
-      var direction = 1;
-      var changesPerPage = 5;
+    test('_limitFor', () => {
+      const defaultLimit = 25;
+      const _limitFor = q => element._limitFor(q, defaultLimit);
+      assert.equal(_limitFor(''), defaultLimit);
+      assert.equal(_limitFor('limit:10'), 10);
+      assert.equal(_limitFor('xlimit:10'), defaultLimit);
+      assert.equal(_limitFor('x(limit:10'), 10);
+    });
+
+    test('_computeNavLink', () => {
+      const query = 'status:open';
+      let offset = 0;
+      let direction = 1;
+      const changesPerPage = 5;
       assert.equal(
           element._computeNavLink(query, offset, direction, changesPerPage),
           '/q/status:open,5');
@@ -87,14 +95,19 @@
       assert.equal(
           element._computeNavLink(query, offset, direction, changesPerPage),
           '/q/status:open,10');
+      assert.equal(
+          element._computeNavLink(
+              query + ' limit:10', offset, direction, changesPerPage),
+          '/q/status:open+limit:10,15');
     });
 
-    test('_computeNavLink with path', function() {
+    test('_computeNavLink with path', () => {
+      const oldCanonicalPath = window.CANONICAL_PATH;
       window.CANONICAL_PATH = '/r';
-      var query = 'status:open';
-      var offset = 0;
-      var direction = 1;
-      var changesPerPage = 5;
+      const query = 'status:open';
+      let offset = 0;
+      let direction = 1;
+      const changesPerPage = 5;
       assert.equal(
           element._computeNavLink(query, offset, direction, changesPerPage),
           '/r/q/status:open,5');
@@ -107,36 +120,37 @@
       assert.equal(
           element._computeNavLink(query, offset, direction, changesPerPage),
           '/r/q/status:open,10');
+      window.CANONICAL_PATH = oldCanonicalPath;
     });
 
-    test('_hidePrevArrow', function() {
-      var offset = 0;
+    test('_hidePrevArrow', () => {
+      let offset = 0;
       assert.isTrue(element._hidePrevArrow(offset));
       offset = 5;
       assert.isFalse(element._hidePrevArrow(offset));
     });
 
-    test('_hideNextArrow', function() {
-      var loading = true;
+    test('_hideNextArrow', () => {
+      let loading = true;
       assert.isTrue(element._hideNextArrow(loading));
       loading = false;
       assert.isTrue(element._hideNextArrow(loading));
       element._changes = [];
       assert.isTrue(element._hideNextArrow(loading));
       element._changes =
-          Array.apply(null, Array(5)).map(Object.prototype.valueOf, {});
+          Array(...Array(5)).map(Object.prototype.valueOf, {});
       assert.isTrue(element._hideNextArrow(loading));
       element._changes =
-          Array.apply(null, Array(25)).map(Object.prototype.valueOf,
+          Array(...Array(25)).map(Object.prototype.valueOf,
           {_more_changes: true});
       assert.isFalse(element._hideNextArrow(loading));
       element._changes =
-          Array.apply(null, Array(25)).map(Object.prototype.valueOf, {});
+          Array(...Array(25)).map(Object.prototype.valueOf, {});
       assert.isTrue(element._hideNextArrow(loading));
     });
 
-    test('_handleNextPage', function() {
-      var showStub = sandbox.stub(page, 'show');
+    test('_handleNextPage', () => {
+      const showStub = sandbox.stub(page, 'show');
       element.$.nextArrow.hidden = true;
       element._handleNextPage();
       assert.isFalse(showStub.called);
@@ -145,8 +159,8 @@
       assert.isTrue(showStub.called);
     });
 
-    test('_handlePreviousPage', function() {
-      var showStub = sandbox.stub(page, 'show');
+    test('_handlePreviousPage', () => {
+      const showStub = sandbox.stub(page, 'show');
       element.$.prevArrow.hidden = true;
       element._handlePreviousPage();
       assert.isFalse(showStub.called);
@@ -155,57 +169,83 @@
       assert.isTrue(showStub.called);
     });
 
-    suite('query based navigation', function() {
-      test('Searching for a change ID redirects to change', function(done) {
+    test('_userId query', done => {
+      assert.isNull(element._userId);
+      element._query = 'owner: foo@bar';
+      element._changes = [{owner: {email: 'foo@bar'}}];
+      flush(() => {
+        assert.equal(element._userId, 'foo@bar');
+
+        element._query = 'foo bar baz';
+        element._changes = [{owner: {email: 'foo@bar'}}];
+        assert.isNull(element._userId);
+
+        done();
+      });
+    });
+
+    suite('query based navigation', () => {
+      setup(() => {
+        sandbox.stub(Gerrit.Nav, 'getUrlForChange', () => '/r/c/1');
+      });
+
+      teardown(done => {
+        flush(() => {
+          sandbox.restore();
+          done();
+        });
+      });
+
+      test('Searching for a change ID redirects to change', done => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([{_number: 1}]));
-        sandbox.stub(page, 'show', function(url) {
-          assert.equal(url, '/c/1');
+        sandbox.stub(element, '_replaceCurrentLocation', url => {
+          assert.equal(url, '/r/c/1');
           done();
         });
 
-        element.params = {view: 'gr-change-list-view', query: CHANGE_ID};
+        element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
       });
 
-      test('Searching for a change num redirects to change', function(done) {
+      test('Searching for a change num redirects to change', done => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([{_number: 1}]));
-        sandbox.stub(page, 'show', function(url) {
-          assert.equal(url, '/c/1');
+        sandbox.stub(element, '_replaceCurrentLocation', url => {
+          assert.equal(url, '/r/c/1');
           done();
         });
 
-        element.params = {view: 'gr-change-list-view', query: '1'};
+        element.params = {view: Gerrit.Nav.View.SEARCH, query: '1'};
       });
 
-      test('Commit hash redirects to change', function(done) {
+      test('Commit hash redirects to change', done => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([{_number: 1}]));
-        sandbox.stub(page, 'show', function(url) {
-          assert.equal(url, '/c/1');
+        sandbox.stub(element, '_replaceCurrentLocation', url => {
+          assert.equal(url, '/r/c/1');
           done();
         });
 
-        element.params = {view: 'gr-change-list-view', query: COMMIT_HASH};
+        element.params = {view: Gerrit.Nav.View.SEARCH, query: COMMIT_HASH};
       });
 
-      test('Searching for an invalid change ID searches', function() {
+      test('Searching for an invalid change ID searches', () => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([]));
-        var stub = sandbox.stub(page, 'show');
+        const stub = sandbox.stub(element, '_replaceCurrentLocation');
 
-        element.params = {view: 'gr-change-list-view', query: CHANGE_ID};
+        element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
         flushAsynchronousOperations();
 
         assert.isFalse(stub.called);
       });
 
-      test('Change ID with multiple search results searches', function() {
+      test('Change ID with multiple search results searches', () => {
         sandbox.stub(element, '_getChanges')
             .returns(Promise.resolve([{}, {}]));
-        var stub = sandbox.stub(page, 'show');
+        const stub = sandbox.stub(element, '_replaceCurrentLocation');
 
-        element.params = {view: 'gr-change-list-view', query: CHANGE_ID};
+        element.params = {view: Gerrit.Nav.View.SEARCH, query: CHANGE_ID};
         flushAsynchronousOperations();
 
         assert.isFalse(stub.called);
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
index 8a95fa8..152ef3d 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.html
@@ -14,17 +14,21 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-change-table-behavior/gr-change-table-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../styles/gr-change-list-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-change-list-item/gr-change-list-item.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-list">
   <template>
-    <style>
+    <style include="shared-styles">
       #changeList {
         border-collapse: collapse;
         width: 100%;
@@ -35,6 +39,20 @@
       th {
         text-align: left;
       }
+      .groupHeader {
+        background-color: #eee;
+        border-top: 1em solid #fff;
+      }
+      .groupHeader a {
+        color: #000;
+        text-decoration: none;
+      }
+      .groupHeader a:hover {
+        text-decoration: underline;
+      }
+      .headerRow + tr {
+        border: none;
+      }
     </style>
     <style include="gr-change-list-styles"></style>
     <table id="changeList">
@@ -54,17 +72,19 @@
           </th>
         </template>
       </tr>
-      <template is="dom-repeat" items="[[groups]]" as="changeGroup"
-          index-as="groupIndex">
-        <template is="dom-if" if="[[_groupTitle(groupIndex)]]">
+      <template is="dom-repeat" items="[[sections]]" as="changeSection"
+          index-as="sectionIndex">
+        <template is="dom-if" if="[[changeSection.sectionName]]">
           <tr class="groupHeader">
             <td class="cell"
                 colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
-              [[_groupTitle(groupIndex)]]
+              <a href$="[[_sectionHref(changeSection.query)]]">
+                [[changeSection.sectionName]]
+              </a>
             </td>
           </tr>
         </template>
-        <template is="dom-if" if="[[!changeGroup.length]]">
+        <template is="dom-if" if="[[!changeSection.results.length]]">
           <tr class="noChanges">
             <td class="cell"
                 colspan$="[[_computeColspan(changeTableColumns, labelNames)]]">
@@ -72,9 +92,9 @@
             </td>
           </tr>
         </template>
-        <template is="dom-repeat" items="[[changeGroup]]" as="change">
+        <template is="dom-repeat" items="[[changeSection.results]]" as="change">
           <gr-change-list-item
-              selected$="[[_computeItemSelected(index, groupIndex, selectedIndex)]]"
+              selected$="[[_computeItemSelected(sectionIndex, index, selectedIndex)]]"
               assigned$="[[_computeItemAssigned(account, change)]]"
               needs-review$="[[_computeItemNeedsReview(account, change, showReviewedState)]]"
               change="[[change]]"
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
index 425ea76..769ef99 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var NUMBER_FIXED_COLUMNS = 3;
+  const NUMBER_FIXED_COLUMNS = 3;
 
   Polymer({
     is: 'gr-change-list',
@@ -42,7 +42,7 @@
        */
       account: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       /**
        * An array of ChangeInfo objects to render.
@@ -53,20 +53,22 @@
         observer: '_changesChanged',
       },
       /**
-       * ChangeInfo objects grouped into arrays. The groups and changes
+       * ChangeInfo objects grouped into arrays. The sections and changes
        * properties should not be used together.
+       *
+       * @type {!Array<{
+       *   sectionName: string,
+       *   query: string,
+       *   results: !Array<!Object>
+       * }>}
        */
-      groups: {
+      sections: {
         type: Array,
-        value: function() { return []; },
-      },
-      groupTitles: {
-        type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       labelNames: {
         type: Array,
-        computed: '_computeLabelNames(groups)',
+        computed: '_computeLabelNames(sections)',
       },
       selectedIndex: {
         type: Number,
@@ -83,34 +85,58 @@
       },
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
+      changeTableColumns: Array,
+      visibleChangeTableColumns: Array,
     },
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
       Gerrit.ChangeTableBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.RESTClientBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     keyBindings: {
       'j': '_handleJKey',
       'k': '_handleKKey',
       'n ]': '_handleNKey',
-      'o enter': '_handleEnterKey',
+      'o': '_handleOKey',
       'p [': '_handlePKey',
+      'shift+r': '_handleRKey',
+      's': '_handleSKey',
     },
 
-    attached: function() {
+    listeners: {
+      keydown: '_scopedKeydownHandler',
+    },
+
+    /**
+     * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+     * events must be scoped to a component level (e.g. `enter`) in order to not
+     * override native browser functionality.
+     *
+     * Context: Issue 7294
+     */
+    _scopedKeydownHandler(e) {
+      if (e.keyCode === 13) {
+        // Enter.
+        this._handleOKey(e);
+      }
+    },
+
+    attached() {
       this._loadPreferences();
     },
 
-    _lowerCase: function(column) {
+    _lowerCase(column) {
       return column.toLowerCase();
     },
 
-    _loadPreferences: function() {
-      return this._getLoggedIn().then(function(loggedIn) {
+    _loadPreferences() {
+      return this._getLoggedIn().then(loggedIn => {
         this.changeTableColumns = this.columnNames;
 
         if (!loggedIn) {
@@ -118,99 +144,103 @@
           this.visibleChangeTableColumns = this.columnNames;
           return;
         }
-        return this._getPreferences().then(function(preferences) {
+        return this._getPreferences().then(preferences => {
           this.showNumber = !!(preferences &&
               preferences.legacycid_in_change_table);
           this.visibleChangeTableColumns = preferences.change_table.length > 0 ?
               preferences.change_table : this.columnNames;
-        }.bind(this));
-      }.bind(this));
+        });
+      });
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _getPreferences: function() {
+    _getPreferences() {
       return this.$.restAPI.getPreferences();
     },
 
-    _computeColspan: function(changeTableColumns, labelNames) {
+    _computeColspan(changeTableColumns, labelNames) {
       return changeTableColumns.length + labelNames.length +
           NUMBER_FIXED_COLUMNS;
     },
 
-    _computeLabelNames: function(groups) {
-      if (!groups) { return []; }
-      var labels = [];
-      var nonExistingLabel = function(item) {
-        return labels.indexOf(item) < 0;
+    _computeLabelNames(sections) {
+      if (!sections) { return []; }
+      let labels = [];
+      const nonExistingLabel = function(item) {
+        return !labels.includes(item);
       };
-      for (var i = 0; i < groups.length; i++) {
-        var group = groups[i];
-        for (var j = 0; j < group.length; j++) {
-          var change = group[j];
+      for (const section of sections) {
+        if (!section.results) { continue; }
+        for (const change of section.results) {
           if (!change.labels) { continue; }
-          var currentLabels = Object.keys(change.labels);
+          const currentLabels = Object.keys(change.labels);
           labels = labels.concat(currentLabels.filter(nonExistingLabel));
         }
       }
       return labels.sort();
     },
 
-    _computeLabelShortcut: function(labelName) {
-      return labelName.replace(/[a-z-]/g, '');
+    _computeLabelShortcut(labelName) {
+      return labelName.split('-').reduce((a, i) => {
+        return a + i[0].toUpperCase();
+      }, '');
     },
 
-    _changesChanged: function(changes) {
-      this.groups = changes ? [changes] : [];
+    _changesChanged(changes) {
+      this.sections = changes ? [{results: changes}] : [];
     },
 
-    _groupTitle: function(groupIndex) {
-      if (groupIndex > this.groupTitles.length - 1) { return null; }
-      return this.groupTitles[groupIndex];
+    _sectionHref(query) {
+      return `${this.getBaseUrl()}/q/${this.encodeURL(query, true)}`;
     },
 
-    _computeItemSelected: function(index, groupIndex, selectedIndex) {
-      var idx = 0;
-      for (var i = 0; i < groupIndex; i++) {
-        idx += this.groups[i].length;
+    /**
+     * Maps an index local to a particular section to the absolute index
+     * across all the changes on the page.
+     *
+     * @param sectionIndex {number} index of section
+     * @param localIndex {number} index of row within section
+     * @return {number} absolute index of row in the aggregate dashboard
+     */
+    _computeItemAbsoluteIndex(sectionIndex, localIndex) {
+      let idx = 0;
+      for (let i = 0; i < sectionIndex; i++) {
+        idx += this.sections[i].results.length;
       }
-      idx += index;
+      return idx + localIndex;
+    },
+
+    _computeItemSelected(sectionIndex, index, selectedIndex) {
+      const idx = this._computeItemAbsoluteIndex(sectionIndex, index);
       return idx == selectedIndex;
     },
 
-    _computeItemNeedsReview: function(account, change, showReviewedState) {
+    _computeItemNeedsReview(account, change, showReviewedState) {
       return showReviewedState && !change.reviewed &&
           this.changeIsOpen(change.status) &&
           account._account_id != change.owner._account_id;
     },
 
-    _computeItemAssigned: function(account, change) {
+    _computeItemAssigned(account, change) {
       if (!change.assignee) { return false; }
       return account._account_id === change.assignee._account_id;
     },
 
-    _getAggregateGroupsLen: function(groups) {
-      groups = groups || [];
-      var len = 0;
-      this.groups.forEach(function(group) {
-        len += group.length;
-      });
-      return len;
-    },
-
-    _handleJKey: function(e) {
+    _handleJKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      var len = this._getAggregateGroupsLen(this.groups);
+      // Compute absolute index of item that would come after final item.
+      const len = this._computeItemAbsoluteIndex(this.sections.length, 0);
       if (this.selectedIndex === len - 1) { return; }
       this.selectedIndex += 1;
     },
 
-    _handleKKey: function(e) {
+    _handleKKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -219,37 +249,75 @@
       this.selectedIndex -= 1;
     },
 
-    _handleEnterKey: function(e) {
+    _handleOKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      page.show(this._changeURLForIndex(this.selectedIndex));
+      Gerrit.Nav.navigateToChange(this._changeForIndex(this.selectedIndex));
     },
 
-    _handleNKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    _handleNKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+        return;
+      }
 
       e.preventDefault();
       this.fire('next-page');
     },
 
-    _handlePKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    _handlePKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+        return;
+      }
 
       e.preventDefault();
       this.fire('previous-page');
     },
 
-    _changeURLForIndex: function(index) {
-      var changeEls = this._getListItems();
-      if (index < changeEls.length && changeEls[index]) {
-        return changeEls[index].changeURL;
-      }
-      return '';
+    _handleRKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+
+      e.preventDefault();
+      this._reloadWindow();
     },
 
-    _getListItems: function() {
+    _reloadWindow() {
+      window.location.reload();
+    },
+
+    _handleSKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this._toggleStarForIndex(this.selectedIndex);
+    },
+
+    _toggleStarForIndex(index) {
+      const changeEls = this._getListItems();
+      if (index >= changeEls.length || !changeEls[index]) {
+        return;
+      }
+
+      const changeEl = changeEls[index];
+      const change = changeEl.change;
+      const newVal = !change.starred;
+      changeEl.set('change.starred', newVal);
+      this.$.restAPI.saveChangeStarred(change._number, newVal);
+    },
+
+    _changeForIndex(index) {
+      const changeEls = this._getListItems();
+      if (index < changeEls.length && changeEls[index]) {
+        return changeEls[index].change;
+      }
+      return null;
+    },
+
+    _getListItems() {
       return Polymer.dom(this.root).querySelectorAll('gr-change-list-item');
     },
   });
diff --git a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
index 1a1abff..25153cd 100644
--- a/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-change-list/gr-change-list_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-change-list.html">
 
 <script>void(0);</script>
@@ -40,16 +40,20 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-list basic tests', function() {
-    var element;
+  suite('gr-change-list basic tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
+    teardown(() => { sandbox.restore(); });
+
     function stubRestAPI(preferences) {
-      var loggedInPromise = Promise.resolve(preferences !== null);
-      var preferencesPromise = Promise.resolve(preferences);
+      const loggedInPromise = Promise.resolve(preferences !== null);
+      const preferencesPromise = Promise.resolve(preferences);
       stub('gr-rest-api-interface', {
         getLoggedIn: sinon.stub().returns(loggedInPromise),
         getPreferences: sinon.stub().returns(preferencesPromise),
@@ -57,79 +61,98 @@
       return Promise.all([loggedInPromise, preferencesPromise]);
     }
 
-    suite('test show change number not logged in', function() {
-      setup(function() {
-        return stubRestAPI(null).then(function() {
+    suite('test show change number not logged in', () => {
+      setup(() => {
+        return stubRestAPI(null).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('show number disabled', function() {
+      test('show number disabled', () => {
         assert.isFalse(element.showNumber);
       });
     });
 
-    suite('test show change number preference enabled', function() {
-      setup(function() {
+    suite('test show change number preference enabled', () => {
+      setup(() => {
         return stubRestAPI({legacycid_in_change_table: true,
-           time_format: 'HHMM_12',
-           change_table: [],
-        }).then(function() {
+          time_format: 'HHMM_12',
+          change_table: [],
+        }).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('show number enabled', function() {
+      test('show number enabled', () => {
         assert.isTrue(element.showNumber);
       });
     });
 
-    suite('test show change number preference disabled', function() {
-      setup(function() {
+    suite('test show change number preference disabled', () => {
+      setup(() => {
         // legacycid_in_change_table is not set when false.
         return stubRestAPI({time_format: 'HHMM_12', change_table: []}).then(
-            function() {
-          element = fixture('basic');
-          return element._loadPreferences();
-        });
+            () => {
+              element = fixture('basic');
+              return element._loadPreferences();
+            });
       });
 
-      test('show number disabled', function() {
+      test('show number disabled', () => {
         assert.isFalse(element.showNumber);
       });
     });
 
-    test('computed fields', function() {
+    test('computed fields', () => {
       assert.equal(element._computeLabelNames(
-          [[{_number: 0, labels: {}}]]).length, 0);
-      assert.equal(element._computeLabelNames([[
-            {_number: 0, labels: {Verified: {approved: {}}}},
-            {_number: 1, labels: {
-              Verified: {approved: {}}, 'Code-Review': {approved: {}}}},
-            {_number: 2, labels: {
-              Verified: {approved: {}}, 'Library-Compliance': {approved: {}}}},
-          ]]).length, 3);
+            [{results: [{_number: 0, labels: {}}]}]).length, 0);
+      assert.equal(element._computeLabelNames([
+        {results: [
+          {_number: 0, labels: {Verified: {approved: {}}}},
+          {
+            _number: 1,
+            labels: {
+              'Verified': {approved: {}},
+              'Code-Review': {approved: {}},
+            },
+          },
+          {
+            _number: 2,
+            labels: {
+              'Verified': {approved: {}},
+              'Library-Compliance': {approved: {}},
+            },
+          },
+        ]},
+      ]).length, 3);
 
       assert.equal(element._computeLabelShortcut('Code-Review'), 'CR');
       assert.equal(element._computeLabelShortcut('Verified'), 'V');
       assert.equal(element._computeLabelShortcut('Library-Compliance'), 'LC');
+      assert.equal(element._computeLabelShortcut('PolyGerrit-Review'), 'PR');
+      assert.equal(element._computeLabelShortcut('polygerrit-review'), 'PR');
       assert.equal(element._computeLabelShortcut(
           'Some-Special-Label-7'), 'SSL7');
     });
 
-    test('colspans', function() {
-      var thItemCount = Polymer.dom(element.root).querySelectorAll(
+    test('colspans', () => {
+      const thItemCount = Polymer.dom(element.root).querySelectorAll(
           'th').length;
 
-      var changeTableColumns = [];
-      var labelNames = [];
+      const changeTableColumns = [];
+      const labelNames = [];
       assert.equal(thItemCount, element._computeColspan(
           changeTableColumns, labelNames));
     });
 
-    test('keyboard shortcuts', function(done) {
+    test('keyboard shortcuts', done => {
+      sandbox.stub(element, '_computeLabelNames');
+      element.sections = [
+        {results: new Array(1)},
+        {results: new Array(2)},
+      ];
       element.selectedIndex = 0;
       element.changes = [
         {_number: 0},
@@ -137,26 +160,29 @@
         {_number: 2},
       ];
       flushAsynchronousOperations();
-      var elementItems = Polymer.dom(element.root).querySelectorAll(
+      const elementItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
       assert.equal(elementItems.length, 3);
 
-      flush(function() {
+      flush(() => {
         assert.isTrue(elementItems[0].hasAttribute('selected'));
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
         assert.equal(element.selectedIndex, 1);
+        assert.isTrue(elementItems[1].hasAttribute('selected'));
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+        assert.equal(element.selectedIndex, 2);
+        assert.isTrue(elementItems[2].hasAttribute('selected'));
 
-        var showStub = sinon.stub(page, 'show');
+        const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
         assert.equal(element.selectedIndex, 2);
         MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-        assert(showStub.lastCall.calledWithExactly('/c/2/'),
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
             'Should navigate to /c/2/');
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.selectedIndex, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-        assert(showStub.lastCall.calledWithExactly('/c/1/'),
+        assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
             'Should navigate to /c/1/');
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
@@ -164,12 +190,15 @@
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.selectedIndex, 0);
 
-        showStub.restore();
+        const reloadStub = sandbox.stub(element, '_reloadWindow');
+        MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+        assert.isTrue(reloadStub.called);
+
         done();
       });
     });
 
-    test('changes needing review', function() {
+    test('changes needing review', () => {
       element.changes = [
         {
           _number: 0,
@@ -189,239 +218,243 @@
         },
         {
           _number: 3,
-          status: 'DRAFT',
-          owner: {_account_id: 42},
-        },
-        {
-          _number: 4,
           status: 'ABANDONED',
           owner: {_account_id: 0},
-        }
+        },
       ];
       flushAsynchronousOperations();
-      var elementItems = Polymer.dom(element.root).querySelectorAll(
+      let elementItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
-      assert.equal(elementItems.length, 5);
-      for (var i = 0; i < elementItems.length; i++) {
+      assert.equal(elementItems.length, 4);
+      for (let i = 0; i < elementItems.length; i++) {
         assert.isFalse(elementItems[i].hasAttribute('needs-review'));
       }
 
       element.showReviewedState = true;
       elementItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
-      assert.equal(elementItems.length, 5);
-      assert.isFalse(elementItems[0].hasAttribute('needs-review'));
-      assert.isTrue(elementItems[1].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[2].hasAttribute('needs-review'));
-      assert.isTrue(elementItems[3].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[4].hasAttribute('needs-review'));
-
-      element.account = {_account_id: 42};
-      elementItems = Polymer.dom(element.root).querySelectorAll(
-          'gr-change-list-item');
-      assert.equal(elementItems.length, 5);
+      assert.equal(elementItems.length, 4);
       assert.isFalse(elementItems[0].hasAttribute('needs-review'));
       assert.isTrue(elementItems[1].hasAttribute('needs-review'));
       assert.isFalse(elementItems[2].hasAttribute('needs-review'));
       assert.isFalse(elementItems[3].hasAttribute('needs-review'));
-      assert.isFalse(elementItems[4].hasAttribute('needs-review'));
+
+      element.account = {_account_id: 42};
+      elementItems = Polymer.dom(element.root).querySelectorAll(
+          'gr-change-list-item');
+      assert.equal(elementItems.length, 4);
+      assert.isFalse(elementItems[0].hasAttribute('needs-review'));
+      assert.isTrue(elementItems[1].hasAttribute('needs-review'));
+      assert.isFalse(elementItems[2].hasAttribute('needs-review'));
+      assert.isFalse(elementItems[3].hasAttribute('needs-review'));
     });
 
-    test('no changes', function() {
+    test('no changes', () => {
       element.changes = [];
       flushAsynchronousOperations();
-      var listItems = Polymer.dom(element.root).querySelectorAll(
+      const listItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
       assert.equal(listItems.length, 0);
-      var noChangesMsg = Polymer.dom(element.root).querySelector('.noChanges');
+      const noChangesMsg =
+          Polymer.dom(element.root).querySelector('.noChanges');
       assert.ok(noChangesMsg);
     });
 
-    test('empty groups', function() {
-      element.groups = [[], []];
+    test('empty sections', () => {
+      element.sections = [{results: []}, {results: []}];
       flushAsynchronousOperations();
-      var listItems = Polymer.dom(element.root).querySelectorAll(
+      const listItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
       assert.equal(listItems.length, 0);
-      var noChangesMsg = Polymer.dom(element.root).querySelectorAll(
+      const noChangesMsg = Polymer.dom(element.root).querySelectorAll(
           '.noChanges');
       assert.equal(noChangesMsg.length, 2);
     });
 
-    suite('empty column preference', function() {
-      var element;
+    suite('empty column preference', () => {
+      let element;
 
-      setup(function() {
-        return stubRestAPI({
+      setup(() =>
+        stubRestAPI({
           legacycid_in_change_table: true,
-            time_format: 'HHMM_12',
-            change_table: [],
-          }
-        ).then(function() {
+          time_format: 'HHMM_12',
+          change_table: [],
+        }).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
-        });
-      });
+        })
+      );
 
-      test('show number enabled', function() {
+      test('show number enabled', () => {
         assert.isTrue(element.showNumber);
       });
 
-      test('all columns visible', function() {
-        element.columnNames.forEach(function(column) {
-          var elementClass = '.' + element._lowerCase(column);
+      test('all columns visible', () => {
+        for (const column of element.columnNames) {
+          const elementClass = '.' + element._lowerCase(column);
           assert.isFalse(element.$$(elementClass).hidden);
-        });
+        }
       });
     });
 
-    suite('full column preference', function() {
-      var element;
+    suite('full column preference', () => {
+      let element;
 
-      setup(function() {
+      setup(() => {
         return stubRestAPI({
-            legacycid_in_change_table: true,
-            time_format: 'HHMM_12',
-            change_table: [
-              'Subject',
-              'Status',
-              'Owner',
-              'Project',
-              'Branch',
-              'Updated',
-              'Size',
-            ],
-          }).then(function() {
+          legacycid_in_change_table: true,
+          time_format: 'HHMM_12',
+          change_table: [
+            'Subject',
+            'Status',
+            'Owner',
+            'Assignee',
+            'Project',
+            'Branch',
+            'Updated',
+            'Size',
+          ],
+        }).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('all columns visible', function() {
-        element.changeTableColumns.forEach(function(column) {
-          var elementClass = '.' + element._lowerCase(column);
+      test('all columns visible', () => {
+        for (const column of element.changeTableColumns) {
+          const elementClass = '.' + element._lowerCase(column);
           assert.isFalse(element.$$(elementClass).hidden);
-        });
+        }
       });
     });
 
-    suite('partial column preference', function() {
-      var element;
+    suite('partial column preference', () => {
+      let element;
 
-      setup(function() {
+      setup(() => {
         return stubRestAPI({
-            legacycid_in_change_table: true,
-            time_format: 'HHMM_12',
-            change_table: [
-              'Subject',
-              'Status',
-              'Owner',
-              'Branch',
-              'Updated',
-              'Size',
-            ],
-          }).then(function() {
+          legacycid_in_change_table: true,
+          time_format: 'HHMM_12',
+          change_table: [
+            'Subject',
+            'Status',
+            'Owner',
+            'Assignee',
+            'Branch',
+            'Updated',
+            'Size',
+          ],
+        }).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('all columns except project visible', function() {
-        element.changeTableColumns.forEach(function(column) {
-          var elementClass = '.' + column.toLowerCase();
+      test('all columns except project visible', () => {
+        for (const column of element.changeTableColumns) {
+          const elementClass = '.' + column.toLowerCase();
           if (column === 'Project') {
             assert.isTrue(element.$$(elementClass).hidden);
           } else {
             assert.isFalse(element.$$(elementClass).hidden);
           }
-        });
+        }
       });
     });
 
-    suite('random column does not exist', function() {
-      var element;
+    suite('random column does not exist', () => {
+      let element;
 
       /* This would only exist if somebody manually updated the config
       file. */
-      setup(function() {
+      setup(() => {
         return stubRestAPI({
-            legacycid_in_change_table: true,
-            time_format: 'HHMM_12',
-            change_table: [
-              'Bad',
-            ],
-          }).then(function() {
+          legacycid_in_change_table: true,
+          time_format: 'HHMM_12',
+          change_table: [
+            'Bad',
+          ],
+        }).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('bad column does not exist', function() {
-        var elementClass = '.bad';
+      test('bad column does not exist', () => {
+        const elementClass = '.bad';
         assert.isNotOk(element.$$(elementClass));
       });
     });
   });
 
-  suite('gr-change-list groups', function() {
-    var element;
+  suite('gr-change-list sections', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
-    test('keyboard shortcuts', function() {
+    teardown(() => { sandbox.restore(); });
+
+    test('keyboard shortcuts', () => {
       element.selectedIndex = 0;
-      element.groups = [
-        [
-          {_number: 0},
-          {_number: 1},
-          {_number: 2},
-        ],
-        [
-          {_number: 3},
-          {_number: 4},
-          {_number: 5},
-        ],
-        [
-          {_number: 6},
-          {_number: 7},
-          {_number: 8},
-        ]
+      element.sections = [
+        {
+          results: [
+            {_number: 0},
+            {_number: 1},
+            {_number: 2},
+          ],
+        },
+        {
+          results: [
+            {_number: 3},
+            {_number: 4},
+            {_number: 5},
+          ],
+        },
+        {
+          results: [
+            {_number: 6},
+            {_number: 7},
+            {_number: 8},
+          ],
+        },
       ];
-      element.groupTitles = ['Group 1', 'Group 2', 'Group 3'];
       flushAsynchronousOperations();
-      var elementItems = Polymer.dom(element.root).querySelectorAll(
+      const elementItems = Polymer.dom(element.root).querySelectorAll(
           'gr-change-list-item');
       assert.equal(elementItems.length, 9);
 
-      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
       assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
 
-      var showStub = sinon.stub(page, 'show');
+      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
       assert.equal(element.selectedIndex, 2);
-      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
-      assert(showStub.lastCall.calledWithExactly('/c/2/'),
+
+      MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 2},
           'Should navigate to /c/2/');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 75);  // 'k'
+      MockInteractions.pressAndReleaseKeyOn(element, 75); // 'k'
       assert.equal(element.selectedIndex, 1);
-      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
-      assert(showStub.lastCall.calledWithExactly('/c/1/'),
+      MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 1},
           'Should navigate to /c/1/');
 
-      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
-      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
-      MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'j'
+      MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+      MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
+      MockInteractions.pressAndReleaseKeyOn(element, 74); // 'j'
       assert.equal(element.selectedIndex, 4);
-      MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'enter'
-      assert(showStub.lastCall.calledWithExactly('/c/4/'),
+      MockInteractions.pressAndReleaseKeyOn(element, 13); // 'enter'
+      assert.deepEqual(navStub.lastCall.args[0], {_number: 4},
           'Should navigate to /c/4/');
-      showStub.restore();
     });
 
-    test('assigned attribute set in each item', function() {
+    test('assigned attribute set in each item', () => {
       element.changes = [
         {
           _number: 0,
@@ -430,23 +463,47 @@
         },
         {
           _number: 1,
-          status: 'DRAFT',
-          owner: {_account_id: 42},
-        },
-        {
-          _number: 2,
           status: 'ABANDONED',
           owner: {_account_id: 0},
         },
       ];
       element.account = {_account_id: 42};
       flushAsynchronousOperations();
-      var items = element._getListItems();
-      assert.equal(items.length, 3);
-      for (var i = 0; i < items.length; i++) {
+      const items = element._getListItems();
+      assert.equal(items.length, 2);
+      for (let i = 0; i < items.length; i++) {
         assert.equal(items[i].hasAttribute('assigned'),
             items[i]._account_id === element.account._account_id);
       }
     });
+
+    test('_sectionHref', () => {
+      assert.equal(
+          element._sectionHref('is:open owner:self'),
+          '/q/is:open+owner:self');
+      assert.equal(
+          element._sectionHref(
+              'is:open ((reviewer:self -is:ignored) OR assignee:self)'),
+          '/q/is:open+((reviewer:self+-is:ignored)+OR+assignee:self)');
+    });
+
+    test('_computeItemAbsoluteIndex', () => {
+      sandbox.stub(element, '_computeLabelNames');
+      element.sections = [
+        {results: new Array(1)},
+        {results: new Array(2)},
+        {results: new Array(3)},
+      ];
+
+      assert.equal(element._computeItemAbsoluteIndex(0, 0), 0);
+      // Out of range but no matter.
+      assert.equal(element._computeItemAbsoluteIndex(0, 1), 1);
+
+      assert.equal(element._computeItemAbsoluteIndex(1, 0), 1);
+      assert.equal(element._computeItemAbsoluteIndex(1, 1), 2);
+      assert.equal(element._computeItemAbsoluteIndex(1, 2), 3);
+      assert.equal(element._computeItemAbsoluteIndex(2, 0), 3);
+      assert.equal(element._computeItemAbsoluteIndex(3, 0), 6);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
index ce413ca..f04b7c6 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.html
@@ -14,12 +14,15 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../change-list/gr-change-list/gr-change-list.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-dashboard-view">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         background-color: var(--view-background-color);
         display: block;
@@ -44,8 +47,7 @@
           show-reviewed-state
           account="[[account]]"
           selected-index="{{viewState.selectedChangeIndex}}"
-          groups="{{_results}}"
-          group-titles="[[_groupTitles]]"></gr-change-list>
+          sections="[[_results]]"></gr-change-list>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
index 977552a..6c5bad3 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view.js
@@ -14,6 +14,29 @@
 (function() {
   'use strict';
 
+  const DEFAULT_SECTIONS = [
+    {
+      name: 'Work in progress',
+      query: 'is:open owner:${user} is:wip',
+      selfOnly: true,
+    },
+    {
+      name: 'Outgoing reviews',
+      query: 'is:open owner:${user} -is:wip',
+    },
+    {
+      name: 'Incoming reviews',
+      query: 'is:open ((reviewer:${user} -owner:${user} -is:ignored) OR ' +
+          'assignee:${user}) -is:wip',
+    },
+    {
+      name: 'Recently closed',
+      query: 'is:closed (owner:${user} OR reviewer:${user} OR ' +
+          'assignee:${user})',
+      suffixForDashboard: '-age:4w limit:10',
+    },
+  ];
+
   Polymer({
     is: 'gr-dashboard-view',
 
@@ -26,22 +49,18 @@
     properties: {
       account: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
+      /** @type {{ selectedChangeIndex: number }} */
       viewState: Object,
       params: {
         type: Object,
-        observer: '_paramsChanged',
       },
 
       _results: Array,
-      _groupTitles: {
+      _sectionMetadata: {
         type: Array,
-        value: [
-          'Outgoing reviews',
-          'Incoming reviews',
-          'Recently closed',
-        ],
+        value() { return DEFAULT_SECTIONS; },
       },
 
       /**
@@ -53,26 +72,69 @@
       },
     },
 
-    attached: function() {
-      this.fire('title-change', {title: 'My Reviews'});
+    observers: [
+      '_userChanged(params.user)',
+    ],
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    get options() {
+      return this.listChangesOptionsToHex(
+          this.ListChangesOption.LABELS,
+          this.ListChangesOption.DETAILED_ACCOUNTS,
+          this.ListChangesOption.REVIEWED
+      );
+    },
+
+    _computeTitle(user) {
+      if (user === 'self') {
+        return 'My Reviews';
+      }
+      return 'Dashboard for ' + user;
     },
 
     /**
      * Allows a refresh if menu item is selected again.
      */
-    _paramsChanged: function() {
+    _userChanged(user) {
+      if (!user) { return; }
+
+      // NOTE: This method may be called before attachment. Fire title-change
+      // in an async so that attachment to the DOM can take place first.
+      this.async(
+          () => this.fire('title-change', {title: this._computeTitle(user)}));
+
       this._loading = true;
-      this._getDashboardChanges().then(function(results) {
-        this._results = results;
-        this._loading = false;
-      }.bind(this)).catch(function(err) {
-        this._loading = false;
-        console.warn(err.message);
-      }.bind(this));
+      const sections = this._sectionMetadata.filter(
+          section => (user === 'self' || !section.selfOnly));
+      const queries =
+          sections.map(
+              section => this._dashboardQueryForSection(section, user));
+      this.$.restAPI.getChanges(null, queries, null, this.options)
+          .then(results => {
+            this._results = sections.map((section, i) => {
+              return {
+                sectionName: section.name,
+                query: queries[i],
+                results: results[i],
+              };
+            });
+            this._loading = false;
+          }).catch(err => {
+            this._loading = false;
+            console.warn(err.message);
+          });
     },
 
-    _getDashboardChanges: function() {
-      return this.$.restAPI.getDashboardChanges();
+    _dashboardQueryForSection(section, user) {
+      const query =
+          section.suffixForDashboard ?
+          section.query + ' ' + section.suffixForDashboard :
+          section.query;
+      return query.replace(/\$\{user\}/g, user);
     },
+
   });
 })();
diff --git a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
index 718e59c..2edf26f 100644
--- a/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
+++ b/polygerrit-ui/app/elements/change-list/gr-dashboard-view/gr-dashboard-view_test.html
@@ -20,7 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-dashboard-view.html">
 
 <script>void(0);</script>
@@ -32,24 +32,69 @@
 </test-fixture>
 
 <script>
-  suite('gr-dashboard-view tests', function() {
-    var element;
+  suite('gr-dashboard-view tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      getChangesStub = sandbox.stub(element.$.restAPI, 'getChanges',
+          () => Promise.resolve());
     });
 
-    test('content is refreshed with same dropdown selected twice', function() {
-      var getChangesStub = sinon.stub(element, '_getDashboardChanges',
-          function() {
-        return Promise.resolve();
-      });
+    teardown(() => {
+      sandbox.restore();
+    });
 
-      element.params = {view: 'gr-dashboard-view'};
+    test('nothing happens when user param is falsy', () => {
+      element.params = {};
+      flushAsynchronousOperations();
+      assert.equal(getChangesStub.callCount, 0);
 
+      element.params = {user: ''};
+      flushAsynchronousOperations();
+      assert.equal(getChangesStub.callCount, 0);
+    });
+
+    test('content is refreshed when user param is updated', () => {
+      element.params = {user: 'self'};
+      flushAsynchronousOperations();
       assert.equal(getChangesStub.callCount, 1);
-      element.params = {view: 'gr-dashboard-view'};
-      assert.equal(getChangesStub.callCount, 2);
+    });
+
+    test('viewing another user\'s dashboard omits selfOnly sections', () => {
+      element._sectionMetadata = [
+        {query: '1'},
+        {query: '2', selfOnly: true},
+      ];
+
+      element.params = {user: 'self'};
+      flushAsynchronousOperations();
+      assert.isTrue(
+          getChangesStub.calledWith(null, ['1', '2'], null, element.options));
+
+      element.params = {user: 'user'};
+      flushAsynchronousOperations();
+      assert.isTrue(
+          getChangesStub.calledWith(null, ['1'], null, element.options));
+    });
+
+    test('_dashboardQueryForSection', () => {
+      const query = 'query for ${user}';
+      const suffixForDashboard = 'suffix for ${user}';
+      assert.equal(
+          element._dashboardQueryForSection({query}, 'user'),
+          'query for user');
+      assert.equal(
+          element._dashboardQueryForSection(
+              {query, suffixForDashboard}, 'user'),
+          'query for user suffix for user');
+    });
+
+    test('_computeTitle', () => {
+      assert.equal(element._computeTitle('self'), 'My Reviews');
+      assert.equal(element._computeTitle('not self'), 'Dashboard for not self');
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
new file mode 100644
index 0000000..d3d0736
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.html
@@ -0,0 +1,95 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../shared/gr-avatar/gr-avatar.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-user-header">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        height: 9em;
+        width: 100%;
+      }
+      gr-avatar {
+        display: inline-block;
+        height: 7em;
+        left: 1em;
+        margin: 1em;
+        top: 1em;
+        width: 7em;
+      }
+      .info {
+        display: inline-block;
+        padding: 1em;
+        vertical-align: top;
+      }
+      .info > div > span {
+        display: inline-block;
+        font-weight: bold;
+        text-align: right;
+        width: 4em;
+      }
+      .name {
+        display: inline-block;
+      }
+      .name hr {
+        width: 100%;
+      }
+      .status.hide,
+      .name.hide,
+      .dashboardLink.hide {
+        display: none;
+      }
+    </style>
+    <gr-avatar
+        account="[[_accountDetails]]"
+        image-size="100"
+        aria-label="Account avatar"></gr-avatar>
+    <div class="info">
+      <h1 class="name">
+        [[_computeDetail(_accountDetails, 'name')]]
+      </h1>
+      <hr/>
+      <div class$="status [[_computeStatusClass(_accountDetails)]]">
+        <span>Status:</span> [[_status]]
+      </div>
+      <div>
+        <span>Email:</span>
+        <a href="mailto:[[_computeDetail(_accountDetails, 'email')]]"><!--
+          -->[[_computeDetail(_accountDetails, 'email')]]</a>
+      </div>
+      <div>
+        <span>Joined:</span>
+        <gr-date-formatter
+            date-str="[[_computeDetail(_accountDetails, 'registered_on')]]">
+        </gr-date-formatter>
+      </div>
+    </div>
+    <div class="info">
+      <div class$="[[_computeDashboardLinkClass(showDashboardLink, loggedIn)]]">
+        <a href$="[[_computeDashboardUrl(_accountDetails)]]">View dashboard</a>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-user-header.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
new file mode 100644
index 0000000..d09e865
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header.js
@@ -0,0 +1,88 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-user-header',
+    properties: {
+      /** @type {?String} */
+      userId: {
+        type: String,
+        observer: '_accountChanged',
+      },
+
+      showDashboardLink: {
+        type: Boolean,
+        value: false,
+      },
+
+      loggedIn: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
+       * @type {?{name: ?, email: ?, registered_on: ?}}
+       */
+      _accountDetails: {
+        type: Object,
+        value: null,
+      },
+
+      /** @type {?String} */
+      _status: {
+        type: String,
+        value: null,
+      },
+    },
+
+    _accountChanged(userId) {
+      if (!userId) {
+        this._accountDetails = null;
+        this._status = null;
+        return;
+      }
+
+      this.$.restAPI.getAccountDetails(userId).then(details => {
+        this._accountDetails = details;
+      });
+      this.$.restAPI.getAccountStatus(userId).then(status => {
+        this._status = status;
+      });
+    },
+
+    _computeDisplayClass(status) {
+      return status ? ' ' : 'hide';
+    },
+
+    _computeDetail(accountDetails, name) {
+      return accountDetails ? accountDetails[name] : '';
+    },
+
+    _computeStatusClass(accountDetails) {
+      return this._computeDetail(accountDetails, 'status') ? '' : 'hide';
+    },
+
+    _computeDashboardUrl(accountDetails) {
+      if (!accountDetails || !accountDetails.email) { return null; }
+      return Gerrit.Nav.getUrlForUserDashboard(accountDetails.email);
+    },
+
+    _computeDashboardLinkClass(showDashboardLink, loggedIn) {
+      return showDashboardLink && loggedIn ?
+          'dashboardLink' : 'dashboardLink hide';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
new file mode 100644
index 0000000..4ae8db4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change-list/gr-user-header/gr-user-header_test.html
@@ -0,0 +1,79 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-user-header</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-user-header.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-user-header></gr-user-header>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-user-header tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('loads and clears account info', done => {
+      sandbox.stub(element.$.restAPI, 'getAccountDetails')
+          .returns(Promise.resolve({
+            name: 'foo',
+            email: 'bar',
+            registered_on: '2015-03-12 18:32:08.000000000',
+          }));
+      sandbox.stub(element.$.restAPI, 'getAccountStatus')
+          .returns(Promise.resolve('baz'));
+
+      element.userId = 'foo.bar@baz';
+      flush(() => {
+        assert.isOk(element._accountDetails);
+        assert.isOk(element._status);
+
+        element.userId = null;
+        flush(() => {
+          flushAsynchronousOperations();
+          assert.isNull(element._accountDetails);
+          assert.isNull(element._status);
+
+          done();
+        });
+      });
+    });
+
+    test('_computeDashboardLinkClass', () => {
+      assert.include(element._computeDashboardLinkClass(false, false), 'hide');
+      assert.include(element._computeDashboardLinkClass(true, false), 'hide');
+      assert.include(element._computeDashboardLinkClass(false, true), 'hide');
+      assert.notInclude(element._computeDashboardLinkClass(true, true), 'hide');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
index bb4a520..4931ff1 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.html
@@ -14,13 +14,15 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-account-entry">
   <template>
-    <style>
+    <style include="shared-styles">
       gr-autocomplete {
         display: inline-block;
         flex: 1;
@@ -34,7 +36,8 @@
         threshold="[[suggestFrom]]"
         query="[[query]]"
         on-commit="_handleInputCommit"
-        clear-on-commit>
+        clear-on-commit
+        warn-uncommitted>
     </gr-autocomplete>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
index 46f2ed2..d3f2386 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry.js
@@ -22,10 +22,10 @@
      *
      * @event add
      */
-
     properties: {
       borderless: Boolean,
       change: Object,
+      _config: Object,
       filter: Function,
       placeholder: String,
       /**
@@ -39,44 +39,69 @@
        */
       allowAnyUser: Boolean,
 
+      // suggestFrom = 0 to enable default suggestions.
       suggestFrom: {
         type: Number,
-        value: 3,
+        value: 0,
       },
 
       query: {
         type: Function,
-        value: function() {
+        value() {
           return this._getReviewerSuggestions.bind(this);
         },
       },
     },
 
+    behaviors: [
+      Gerrit.AnonymousNameBehavior,
+    ],
+
+    attached() {
+      this.$.restAPI.getConfig().then(cfg => {
+        this._config = cfg;
+      });
+    },
+
     get focusStart() {
       return this.$.input.focusStart;
     },
 
-    focus: function() {
+    focus() {
       this.$.input.focus();
     },
 
-    clear: function() {
+    clear() {
       this.$.input.clear();
     },
 
-    _handleInputCommit: function(e) {
-      this.fire('add', {value: e.detail.value});
+    setText(text) {
+      this.$.input.setText(text);
     },
 
-    _makeSuggestion: function(reviewer) {
-      var name;
-      var value;
-      var generateStatusStr = function(account) {
+    getText() {
+      return this.$.input.text;
+    },
+
+    _handleInputCommit(e) {
+      this.fire('add', {value: e.detail.value});
+      this.$.input.focus();
+    },
+
+    _accountOrAnon(reviewer) {
+      return this.getUserName(this._config, reviewer, false);
+    },
+
+    _makeSuggestion(reviewer) {
+      let name;
+      let value;
+      const generateStatusStr = function(account) {
         return account.status ? ' (' + account.status + ')' : '';
       };
       if (reviewer.account) {
         // Reviewer is an account suggestion from getChangeSuggestedReviewers.
-        name = reviewer.account.name + ' <' + reviewer.account.email + '>' +
+        const reviewerName = this._accountOrAnon(reviewer.account);
+        name = reviewerName + ' <' + reviewer.account.email + '>' +
             generateStatusStr(reviewer.account);
         value = reviewer;
       } else if (reviewer.group) {
@@ -85,26 +110,31 @@
         value = reviewer;
       } else if (reviewer._account_id) {
         // Reviewer is an account suggestion from getSuggestedAccounts.
-        name = reviewer.name + ' <' + reviewer.email + '>' +
+        const reviewerName = this._accountOrAnon(reviewer);
+        name = reviewerName + ' <' + reviewer.email + '>' +
             generateStatusStr(reviewer);
         value = {account: reviewer, count: 1};
       }
-      return {name: name, value: value};
+      return {name, value};
     },
 
-    _getReviewerSuggestions: function(input) {
-      var api = this.$.restAPI;
-      var xhr = this.allowAnyUser ?
-          api.getSuggestedAccounts(input) :
+    _getReviewerSuggestions(input) {
+      if (!this.change || !this.change._number) { return Promise.resolve([]); }
+
+      const api = this.$.restAPI;
+      const xhr = this.allowAnyUser ?
+          api.getSuggestedAccounts(`cansee:${this.change._number} ${input}`) :
           api.getChangeSuggestedReviewers(this.change._number, input);
 
-      return xhr.then(function(reviewers) {
+      return xhr.then(reviewers => {
         if (!reviewers) { return []; }
-        if (!this.filter) { return reviewers.map(this._makeSuggestion); }
+        if (!this.filter) {
+          return reviewers.map(this._makeSuggestion.bind(this));
+        }
         return reviewers
             .filter(this.filter)
             .map(this._makeSuggestion.bind(this));
-      }.bind(this));
+      });
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
index 2948b4b..a7bf1e4 100644
--- a/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-entry/gr-account-entry_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-account-entry.html">
 
 <script>void(0);</script>
@@ -34,11 +34,11 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-entry tests', function() {
-    var sandbox;
-    var _nextAccountId = 0;
-    var makeAccount = function(opt_status) {
-      var accountId = ++_nextAccountId;
+  suite('gr-account-entry tests', () => {
+    let sandbox;
+    let _nextAccountId = 0;
+    const makeAccount = function(opt_status) {
+      const accountId = ++_nextAccountId;
       return {
         _account_id: accountId,
         name: 'name ' + accountId,
@@ -46,16 +46,25 @@
         status: opt_status,
       };
     };
+    let _nextAccountId2 = 0;
+    const makeAccount2 = function(opt_status) {
+      const accountId2 = ++_nextAccountId2;
+      return {
+        _account_id: accountId2,
+        email: 'email ' + accountId2,
+        status: opt_status,
+      };
+    };
 
-    var owner;
-    var existingReviewer1;
-    var existingReviewer2;
-    var suggestion1;
-    var suggestion2;
-    var suggestion3;
-    var element;
+    let owner;
+    let existingReviewer1;
+    let existingReviewer2;
+    let suggestion1;
+    let suggestion2;
+    let suggestion3;
+    let element;
 
-    setup(function() {
+    setup(() => {
       owner = makeAccount();
       existingReviewer1 = makeAccount();
       existingReviewer2 = makeAccount();
@@ -70,7 +79,8 @@
 
       element = fixture('basic');
       element.change = {
-        owner: owner,
+        _number: 42,
+        owner,
         reviewers: {
           CC: [existingReviewer1],
           REVIEWER: [existingReviewer2],
@@ -79,97 +89,117 @@
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    suite('stubbed values for _getReviewerSuggestions', function() {
-      setup(function() {
+    suite('stubbed values for _getReviewerSuggestions', () => {
+      setup(() => {
         stub('gr-rest-api-interface', {
-          getChangeSuggestedReviewers: function() {
-            var redundantSuggestion1 = {account: existingReviewer1};
-            var redundantSuggestion2 = {account: existingReviewer2};
-            var redundantSuggestion3 = {account: owner};
+          getChangeSuggestedReviewers() {
+            const redundantSuggestion1 = {account: existingReviewer1};
+            const redundantSuggestion2 = {account: existingReviewer2};
+            const redundantSuggestion3 = {account: owner};
             return Promise.resolve([redundantSuggestion1, redundantSuggestion2,
-                redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
+              redundantSuggestion3, suggestion1, suggestion2, suggestion3]);
           },
         });
       });
 
-      test('_makeSuggestion formats account or group accordingly', function() {
-        var account = makeAccount();
-        var suggestion = element._makeSuggestion({account: account});
+      test('_makeSuggestion formats account or group accordingly', () => {
+        let account = makeAccount();
+        const account2 = makeAccount2();
+        let suggestion = element._makeSuggestion({account});
         assert.deepEqual(suggestion, {
           name: account.name + ' <' + account.email + '>',
-          value: {account: account},
+          value: {account},
         });
 
-        var group = {name: 'test'};
-        suggestion = element._makeSuggestion({group: group});
+        const group = {name: 'test'};
+        suggestion = element._makeSuggestion({group});
         assert.deepEqual(suggestion, {
           name: group.name + ' (group)',
-          value: {group: group},
+          value: {group},
         });
 
         suggestion = element._makeSuggestion(account);
         assert.deepEqual(suggestion, {
           name: account.name + ' <' + account.email + '>',
-          value: {account: account, count: 1},
+          value: {account, count: 1},
         });
 
+        element._config = {
+          user: {
+            anonymous_coward_name: 'Anonymous Coward',
+          },
+        };
+        assert.deepEqual(element._accountOrAnon(account2), 'Anonymous');
+
         account = makeAccount('OOO');
 
-        suggestion = element._makeSuggestion({account: account});
+        suggestion = element._makeSuggestion({account});
         assert.deepEqual(suggestion, {
           name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account: account},
+          value: {account},
         });
 
         suggestion = element._makeSuggestion(account);
         assert.deepEqual(suggestion, {
           name: account.name + ' <' + account.email + '> (OOO)',
-          value: {account: account, count: 1},
+          value: {account, count: 1},
         });
       });
 
-      test('_getReviewerSuggestions excludes owner+reviewers', function(done) {
-        element._getReviewerSuggestions().then(function(reviewers) {
+      test('_getReviewerSuggestions excludes owner+reviewers', done => {
+        element._getReviewerSuggestions().then(reviewers => {
           // Default is no filtering.
           assert.equal(reviewers.length, 6);
 
           // Set up filter that only accepts suggestion1.
-          var accountId = suggestion1.account._account_id;
+          const accountId = suggestion1.account._account_id;
           element.filter = function(suggestion) {
             return suggestion.account &&
                 suggestion.account._account_id === accountId;
           };
 
-          element._getReviewerSuggestions().then(function(reviewers) {
+          element._getReviewerSuggestions().then(reviewers => {
             assert.deepEqual(reviewers, [element._makeSuggestion(suggestion1)]);
           }).then(done);
         });
       });
     });
 
-    test('allowAnyUser', function(done) {
-      var suggestReviewerStub =
+    test('allowAnyUser', done => {
+      const suggestReviewerStub =
           sandbox.stub(element.$.restAPI, 'getChangeSuggestedReviewers')
           .returns(Promise.resolve([]));
-      var suggestAccountStub =
+      const suggestAccountStub =
           sandbox.stub(element.$.restAPI, 'getSuggestedAccounts')
           .returns(Promise.resolve([]));
 
-      element._getReviewerSuggestions('').then(function() {
+      element._getReviewerSuggestions('').then(() => {
         assert.isTrue(suggestReviewerStub.calledOnce);
+        assert.isTrue(suggestReviewerStub.calledWith(42, ''));
         assert.isFalse(suggestAccountStub.called);
         element.allowAnyUser = true;
 
-        element._getReviewerSuggestions('').then(function() {
+        element._getReviewerSuggestions('').then(() => {
           assert.isTrue(suggestReviewerStub.calledOnce);
           assert.isTrue(suggestAccountStub.calledOnce);
+          assert.isTrue(suggestAccountStub.calledWith('cansee:42 '));
           done();
         });
       });
     });
+
+    test('setText', () => {
+      // Spy on query, as that is called when _updateSuggestions proceeds.
+      const suggestSpy = sandbox.spy(element.$.input, 'query');
+      element.setText('test text');
+      flushAsynchronousOperations();
+
+      assert.equal(element.$.input.$.input.value, 'test text');
+      assert.isFalse(suggestSpy.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
index 810658c..1c78774 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.html
@@ -17,13 +17,14 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../gr-account-entry/gr-account-entry.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-account-list">
   <template>
-    <style>
+    <style include="shared-styles">
       gr-account-chip {
         display: inline-block;
-        margin: 0 .2em .2em 0;
+        margin: .2em .2em .2em 0;
       }
       gr-account-entry {
         display: flex;
@@ -36,17 +37,29 @@
       .pending-add {
         font-style: italic;
       }
+      .list {
+        align-items: center;
+        display: flex;
+        flex-wrap: wrap;
+        @apply --account-list-style;
+      }
     </style>
-    <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
-      <gr-account-chip
-          account="[[account]]"
-          class$="[[_computeChipClass(account)]]"
-          data-account-id$="[[account._account_id]]"
-          removable="[[_computeRemovable(account)]]"
-          on-keydown="_handleChipKeydown"
-          tabindex$="[[index]]">
-      </gr-account-chip>
-    </template>
+    <!--
+      NOTE(Issue 6419): Nest the inner dom-repeat template in a div rather than
+      as a direct child of the dom-module's template.
+    -->
+    <div class="list">
+      <template id="chips" is="dom-repeat" items="[[accounts]]" as="account">
+        <gr-account-chip
+            account="[[account]]"
+            class$="[[_computeChipClass(account)]]"
+            data-account-id$="[[account._account_id]]"
+            removable="[[_computeRemovable(account)]]"
+            on-keydown="_handleChipKeydown"
+            tabindex="-1">
+        </gr-account-chip>
+      </template>
+    </div>
     <gr-account-entry
         borderless
         hidden$="[[_computeEntryHidden(maxCount, accounts.*, readonly)]]"
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
index 35311f9..a8c3e2b 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list.js
@@ -14,18 +14,29 @@
 (function() {
   'use strict';
 
+  const VALID_EMAIL_ALERT = 'Please input a valid email.';
+
   Polymer({
     is: 'gr-account-list',
 
+    /**
+     * Fired when user inputs an invalid email address.
+     *
+     * @event show-alert
+     */
+
     properties: {
       accounts: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
         notify: true,
       },
       change: Object,
       filter: Function,
       placeholder: String,
+      /**
+       * Needed for template checking since value is initially set to null.
+       * @type {?Object} */
       pendingConfirmation: {
         type: Object,
         value: null,
@@ -35,6 +46,7 @@
         type: Boolean,
         value: false,
       },
+
       /**
        * When true, the account-entry autocomplete uses the account suggest API
        * endpoint, which suggests any account in that Gerrit instance (and does
@@ -48,6 +60,15 @@
         type: Boolean,
         value: false,
       },
+
+      /**
+       * When true, allows for non-suggested inputs to be added.
+       */
+      allowAnyInput: {
+        type: Boolean,
+        value: false,
+      },
+
       /**
        * Array of values (groups/accounts) that are removable. When this prop is
        * undefined, all values are removable.
@@ -60,7 +81,7 @@
     },
 
     listeners: {
-      'remove': '_handleRemove',
+      remove: '_handleRemove',
     },
 
     get accountChips() {
@@ -71,36 +92,52 @@
       return this.$.entry.focusStart;
     },
 
-    _handleAdd: function(e) {
-      var reviewer = e.detail.value;
+    _handleAdd(e) {
+      this._addReviewer(e.detail.value);
+    },
+
+    _addReviewer(reviewer) {
       // Append new account or group to the accounts property. We add our own
       // internal properties to the account/group here, so we clone the object
       // to avoid cluttering up the shared change object.
-      // TODO(logan): Polyfill for Object.assign in IE.
       if (reviewer.account) {
-        var account = Object.assign({}, reviewer.account, {_pendingAdd: true});
+        const account =
+            Object.assign({}, reviewer.account, {_pendingAdd: true});
         this.push('accounts', account);
       } else if (reviewer.group) {
         if (reviewer.confirm) {
           this.pendingConfirmation = reviewer;
           return;
         }
-        var group = Object.assign({}, reviewer.group,
+        const group = Object.assign({}, reviewer.group,
             {_pendingAdd: true, _group: true});
         this.push('accounts', group);
+      } else if (this.allowAnyInput) {
+        if (!reviewer.includes('@')) {
+          // Repopulate the input with what the user tried to enter and have
+          // a toast tell them why they can't enter it.
+          this.$.entry.setText(reviewer);
+          this.dispatchEvent(new CustomEvent('show-alert',
+            {detail: {message: VALID_EMAIL_ALERT}, bubbles: true}));
+          return false;
+        } else {
+          const account = {email: reviewer, _pendingAdd: true};
+          this.push('accounts', account);
+        }
       }
       this.pendingConfirmation = null;
+      return true;
     },
 
-    confirmGroup: function(group) {
+    confirmGroup(group) {
       group = Object.assign(
           {}, group, {confirmed: true, _pendingAdd: true, _group: true});
       this.push('accounts', group);
       this.pendingConfirmation = null;
     },
 
-    _computeChipClass: function(account) {
-      var classes = [];
+    _computeChipClass(account) {
+      const classes = [];
       if (account._group) {
         classes.push('group');
       }
@@ -110,11 +147,23 @@
       return classes.join(' ');
     },
 
-    _computeRemovable: function(account) {
+    _accountMatches(a, b) {
+      if (a && b) {
+        if (a._account_id) {
+          return a._account_id === b._account_id;
+        }
+        if (a.email) {
+          return a.email === b.email;
+        }
+      }
+      return a === b;
+    },
+
+    _computeRemovable(account) {
       if (this.readonly) { return false; }
       if (this.removableValues) {
-        for (var i = 0; i < this.removableValues.length; i++) {
-          if (this.removableValues[i]._account_id === account._account_id) {
+        for (let i = 0; i < this.removableValues.length; i++) {
+          if (this._accountMatches(this.removableValues[i], account)) {
             return true;
           }
         }
@@ -123,21 +172,21 @@
       return true;
     },
 
-    _handleRemove: function(e) {
-      var toRemove = e.detail.account;
+    _handleRemove(e) {
+      const toRemove = e.detail.account;
       this._removeAccount(toRemove);
       this.$.entry.focus();
     },
 
-    _removeAccount: function(toRemove) {
+    _removeAccount(toRemove) {
       if (!toRemove || !this._computeRemovable(toRemove)) { return; }
-      for (var i = 0; i < this.accounts.length; i++) {
-        var matches;
-        var account = this.accounts[i];
+      for (let i = 0; i < this.accounts.length; i++) {
+        let matches;
+        const account = this.accounts[i];
         if (toRemove._group) {
           matches = toRemove.id === account.id;
         } else {
-          matches = toRemove._account_id === account._account_id;
+          matches = this._accountMatches(toRemove, account);
         }
         if (matches) {
           this.splice('accounts', i, 1);
@@ -147,8 +196,8 @@
       console.warn('received remove event for missing account', toRemove);
     },
 
-    _handleInputKeydown: function(e) {
-      var input = e.detail.input;
+    _handleInputKeydown(e) {
+      const input = e.detail.input.inputElement;
       if (input.selectionStart !== input.selectionEnd ||
           input.selectionStart !== 0) {
         return;
@@ -158,18 +207,17 @@
           this._removeAccount(this.accounts[this.accounts.length - 1]);
           break;
         case 37: // Left arrow
-          var chips = this.accountChips;
-          if (chips[chips.length - 1]) {
-            chips[chips.length - 1].focus();
+          if (this.accountChips[this.accountChips.length - 1]) {
+            this.accountChips[this.accountChips.length - 1].focus();
           }
           break;
       }
     },
 
-    _handleChipKeydown: function(e) {
-      var chip = e.target;
-      var chips = this.accountChips;
-      var index = chips.indexOf(chip);
+    _handleChipKeydown(e) {
+      const chip = e.target;
+      const chips = this.accountChips;
+      const index = chips.indexOf(chip);
       switch (e.keyCode) {
         case 8: // Backspace
         case 13: // Enter
@@ -204,19 +252,35 @@
       }
     },
 
-    additions: function() {
-      return this.accounts.filter(function(account) {
+    /**
+     * Submit the text of the entry as a reviewer value, if it exists. If it is
+     * a successful submit of the text, clear the entry value.
+     *
+     * @return {boolean} If there is text in the entry, return true if the
+     *     submission was successful and false if not. If there is no text,
+     *     return true.
+     */
+    submitEntryText() {
+      const text = this.$.entry.getText();
+      if (!text.length) { return true; }
+      const wasSubmitted = this._addReviewer(text);
+      if (wasSubmitted) { this.$.entry.clear(); }
+      return wasSubmitted;
+    },
+
+    additions() {
+      return this.accounts.filter(account => {
         return account._pendingAdd;
-      }).map(function(account) {
+      }).map(account => {
         if (account._group) {
           return {group: account};
         } else {
-          return {account: account};
+          return {account};
         }
       });
     },
 
-    _computeEntryHidden: function(maxCount, accountsRecord, readonly) {
+    _computeEntryHidden(maxCount, accountsRecord, readonly) {
       return (maxCount && maxCount <= accountsRecord.base.length) || readonly;
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
index b3c9e9e..7097b86 100644
--- a/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-account-list/gr-account-list_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-account-list.html">
 
 <script>void(0);</script>
@@ -33,64 +32,65 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-list tests', function() {
-    var _nextAccountId = 0;
-    var makeAccount = function() {
-      var accountId = ++_nextAccountId;
+  suite('gr-account-list tests', () => {
+    let _nextAccountId = 0;
+    const makeAccount = function() {
+      const accountId = ++_nextAccountId;
       return {
         _account_id: accountId,
       };
     };
-    var makeGroup = function() {
-      var groupId = 'group' + (++_nextAccountId);
+    const makeGroup = function() {
+      const groupId = 'group' + (++_nextAccountId);
       return {
         id: groupId,
+        _group: true,
       };
     };
 
-    var existingReviewer1;
-    var existingReviewer2;
-    var sandbox;
-    var element;
+    let existingReviewer1;
+    let existingReviewer2;
+    let sandbox;
+    let element;
 
     function getChips() {
       return Polymer.dom(element.root).querySelectorAll('gr-account-chip');
     }
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       existingReviewer1 = makeAccount();
       existingReviewer2 = makeAccount();
 
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
       element.accounts = [existingReviewer1, existingReviewer2];
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('account entry only appears when editable', function() {
+    test('account entry only appears when editable', () => {
       element.readonly = false;
       assert.isFalse(element.$.entry.hasAttribute('hidden'));
       element.readonly = true;
       assert.isTrue(element.$.entry.hasAttribute('hidden'));
     });
 
-    test('addition and removal of account/group chips', function() {
+    test('addition and removal of account/group chips', () => {
       flushAsynchronousOperations();
       sandbox.stub(element, '_computeRemovable').returns(true);
       // Existing accounts are listed.
-      var chips = getChips();
+      let chips = getChips();
       assert.equal(chips.length, 2);
       assert.isFalse(chips[0].classList.contains('pendingAdd'));
       assert.isFalse(chips[1].classList.contains('pendingAdd'));
 
       // New accounts are added to end with pendingAdd class.
-      var newAccount = makeAccount();
+      const newAccount = makeAccount();
       element._handleAdd({
         detail: {
           value: {
@@ -122,7 +122,7 @@
       assert.isFalse(chips[0].classList.contains('pendingAdd'));
 
       // New groups are added to end with pendingAdd and group classes.
-      var newGroup = makeGroup();
+      const newGroup = makeGroup();
       element._handleAdd({
         detail: {
           value: {
@@ -144,8 +144,8 @@
       assert.isFalse(chips[0].classList.contains('pendingAdd'));
     });
 
-    test('_computeChipClass', function() {
-      var account = makeAccount();
+    test('_computeChipClass', () => {
+      const account = makeAccount();
       assert.equal(element._computeChipClass(account), '');
       account._pendingAdd = true;
       assert.equal(element._computeChipClass(account), 'pendingAdd');
@@ -155,8 +155,8 @@
       assert.equal(element._computeChipClass(account), 'group');
     });
 
-    test('_computeRemovable', function() {
-      var newAccount = makeAccount();
+    test('_computeRemovable', () => {
+      const newAccount = makeAccount();
       newAccount._pendingAdd = true;
       element.readonly = false;
       element.removableValues = [];
@@ -174,10 +174,34 @@
       assert.isFalse(element._computeRemovable(newAccount));
     });
 
-    test('additions returns sanitized new accounts and groups', function() {
+    test('submitEntryText', () => {
+      element.allowAnyInput = true;
+      flushAsynchronousOperations();
+
+      const getTextStub = sandbox.stub(element.$.entry, 'getText');
+      getTextStub.onFirstCall().returns('');
+      getTextStub.onSecondCall().returns('test');
+      getTextStub.onThirdCall().returns('test@test');
+
+      // When entry is empty, return true.
+      const clearStub = sandbox.stub(element.$.entry, 'clear');
+      assert.isTrue(element.submitEntryText());
+      assert.isFalse(clearStub.called);
+
+      // When entry is invalid, return false.
+      assert.isFalse(element.submitEntryText());
+      assert.isFalse(clearStub.called);
+
+      // When entry is valid, return true and clear text.
+      assert.isTrue(element.submitEntryText());
+      assert.isTrue(clearStub.called);
+      assert.equal(element.additions()[0].account.email, 'test@test');
+    });
+
+    test('additions returns sanitized new accounts and groups', () => {
       assert.equal(element.additions().length, 0);
 
-      var newAccount = makeAccount();
+      const newAccount = makeAccount();
       element._handleAdd({
         detail: {
           value: {
@@ -185,7 +209,7 @@
           },
         },
       });
-      var newGroup = makeGroup();
+      const newGroup = makeGroup();
       element._handleAdd({
         detail: {
           value: {
@@ -211,13 +235,13 @@
       ]);
     });
 
-    test('large group confirmations', function() {
+    test('large group confirmations', () => {
       assert.isNull(element.pendingConfirmation);
       assert.deepEqual(element.additions(), []);
 
-      var group = makeGroup();
-      var reviewer = {
-        group: group,
+      const group = makeGroup();
+      const reviewer = {
+        group,
         count: 10,
         confirm: true,
       };
@@ -244,17 +268,17 @@
       ]);
     });
 
-    test('removeAccount fails if account is not removable', function() {
+    test('removeAccount fails if account is not removable', () => {
       element.readonly = true;
-      var acct = makeAccount();
+      const acct = makeAccount();
       element.accounts = [acct];
       element._removeAccount(acct);
       assert.equal(element.accounts.length, 1);
     });
 
-    test('max-count', function() {
+    test('max-count', () => {
       element.maxCount = 1;
-      var acct = makeAccount();
+      const acct = makeAccount();
       element._handleAdd({
         detail: {
           value: {
@@ -266,38 +290,77 @@
       assert.isTrue(element.$.entry.hasAttribute('hidden'));
     });
 
-    suite('keyboard interactions', function() {
+    suite('allowAnyInput', () => {
+      let entry;
 
-      test('backspace at text input start removes last account', function() {
-        var input = element.$.entry.$.input;
+      setup(() => {
+        entry = element.$.entry;
+        sandbox.stub(entry, '_getReviewerSuggestions');
+        sandbox.stub(entry.$.input, '_updateSuggestions');
+        element.allowAnyInput = true;
+      });
+
+      test('adds emails', () => {
+        const accountLen = element.accounts.length;
+        element._handleAdd({detail: {value: 'test@test'}});
+        assert.equal(element.accounts.length, accountLen + 1);
+        assert.equal(element.accounts[accountLen].email, 'test@test');
+      });
+
+      test('toasts on invalid email', () => {
+        const toastHandler = sandbox.stub();
+        element.addEventListener('show-alert', toastHandler);
+        element._handleAdd({detail: {value: 'test'}});
+        assert.isTrue(toastHandler.called);
+      });
+    });
+
+    test('_accountMatches', () => {
+      const acct = makeAccount();
+
+      assert.isTrue(element._accountMatches(acct, acct));
+      acct.email = 'test';
+      assert.isTrue(element._accountMatches(acct, acct));
+      assert.isTrue(element._accountMatches({email: 'test'}, acct));
+
+      assert.isFalse(element._accountMatches({}, acct));
+      assert.isFalse(element._accountMatches({email: 'test2'}, acct));
+      assert.isFalse(element._accountMatches({_account_id: -1}, acct));
+    });
+
+    suite('keyboard interactions', () => {
+      test('backspace at text input start removes last account', () => {
+        const input = element.$.entry.$.input;
         sandbox.stub(element.$.entry, '_getReviewerSuggestions');
         sandbox.stub(input, '_updateSuggestions');
         sandbox.stub(element, '_computeRemovable').returns(true);
         // Next line is a workaround for Firefix not moving cursor
         // on input field update
-        assert.equal(input.$.input.selectionStart, 0);
+        assert.equal(input.$.input.inputElement.selectionStart, 0);
         input.text = 'test';
         MockInteractions.focus(input.$.input);
         flushAsynchronousOperations();
         assert.equal(element.accounts.length, 2);
-        MockInteractions.pressAndReleaseKeyOn(input.$.input, 8); // Backspace
+        MockInteractions.pressAndReleaseKeyOn(
+            input.$.input.inputElement, 8); // Backspace
         assert.equal(element.accounts.length, 2);
         input.text = '';
-        MockInteractions.pressAndReleaseKeyOn(input.$.input, 8); // Backspace
+        MockInteractions.pressAndReleaseKeyOn(
+            input.$.input.inputElement, 8); // Backspace
         assert.equal(element.accounts.length, 1);
       });
 
-      test('arrow key navigation', function() {
-        var input = element.$.entry.$.input;
+      test('arrow key navigation', () => {
+        const input = element.$.entry.$.input;
         input.text = '';
         element.accounts = [makeAccount(), makeAccount()];
         MockInteractions.focus(input.$.input);
         flushAsynchronousOperations();
-        var chips = element.accountChips;
-        var chipsOneSpy = sandbox.spy(chips[1], 'focus');
+        const chips = element.accountChips;
+        const chipsOneSpy = sandbox.spy(chips[1], 'focus');
         MockInteractions.pressAndReleaseKeyOn(input.$.input, 37); // Left
         assert.isTrue(chipsOneSpy.called);
-        var chipsZeroSpy = sandbox.spy(chips[0], 'focus');
+        const chipsZeroSpy = sandbox.spy(chips[0], 'focus');
         MockInteractions.pressAndReleaseKeyOn(chips[1], 37); // Left
         assert.isTrue(chipsZeroSpy.called);
         MockInteractions.pressAndReleaseKeyOn(chips[0], 37); // Left
@@ -306,12 +369,11 @@
         assert.isTrue(chipsOneSpy.calledTwice);
       });
 
-      test('delete', function(done) {
+      test('delete', done => {
         element.accounts = [makeAccount(), makeAccount()];
-        flush(function() {
-          var chips = element.accountChips;
-          var focusSpy = sandbox.spy(element.accountChips[1], 'focus');
-          var removeSpy = sandbox.spy(element, '_removeAccount');
+        flush(() => {
+          const focusSpy = sandbox.spy(element.accountChips[1], 'focus');
+          const removeSpy = sandbox.spy(element, '_removeAccount');
           MockInteractions.pressAndReleaseKeyOn(
               element.accountChips[0], 8); // Backspace
           assert.isTrue(focusSpy.called);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index 71ccb04..eb170ae 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -14,10 +14,13 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
@@ -26,12 +29,14 @@
 
 <link rel="import" href="../gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html">
 <link rel="import" href="../gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html">
+<link rel="import" href="../gr-confirm-move-dialog/gr-confirm-move-dialog.html">
 <link rel="import" href="../gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html">
 <link rel="import" href="../gr-confirm-revert-dialog/gr-confirm-revert-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-actions">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: inline-block;
         font-family: var(--font-family);
@@ -43,9 +48,6 @@
       gr-dropdown {
         margin-left: .5em;
       }
-      gr-button:before {
-        content: attr(data-label);
-      }
       #actionLoadingMessage {
         color: #777;
       }
@@ -69,20 +71,23 @@
           margin: .5em;
           text-align: center;
         }
+        #mainContent.mobileOverlayOpened {
+          display: none;
+        }
       }
     </style>
-    <div>
+    <div id="mainContent">
       <span
           id="actionLoadingMessage"
           hidden$="[[!_actionLoadingMessage]]">
         [[_actionLoadingMessage]]</span>
       <gr-dropdown
           id="moreActions"
+          tabindex="0"
           down-arrow
           vertical-offset="32"
           horizontal-align="right"
-          on-tap-item-cherrypick="_handleCherrypickTap"
-          on-tap-item-delete="_handleDeleteTap"
+          on-tap-item="_handleOveflowItemTap"
           hidden$="[[_shouldHideActions(_menuActions.*, _loading)]]"
           disabled-ids="[[_disabledMenuActions]]"
           items="[[_menuActions]]">More</gr-dropdown>
@@ -97,7 +102,7 @@
               data-action-type$="[[action.__type]]"
               data-label$="[[action.label]]"
               disabled$="[[_calculateDisabled(action, _hasKnownChainState)]]"
-              on-tap="_handleActionTap"></gr-button>
+              on-tap="_handleActionTap">[[action.label]]</gr-button>
         </template>
       </section>
       <gr-button hidden$="[[!_loading]]" disabled>Loading actions...</gr-button>
@@ -118,7 +123,14 @@
           commit-num="[[commitNum]]"
           on-confirm="_handleCherrypickConfirm"
           on-cancel="_handleConfirmDialogCancel"
+          project="[[change.project]]"
           hidden></gr-confirm-cherrypick-dialog>
+      <gr-confirm-move-dialog id="confirmMove"
+          class="confirmDialog"
+          on-confirm="_handleMoveConfirm"
+          on-cancel="_handleConfirmDialogCancel"
+          project="[[change.project]]"
+          hidden></gr-confirm-move-dialog>
       <gr-confirm-revert-dialog id="confirmRevertDialog"
           class="confirmDialog"
           on-confirm="_handleRevertDialogConfirm"
@@ -142,6 +154,19 @@
           Do you really want to delete the change?
         </div>
       </gr-confirm-dialog>
+      <gr-confirm-dialog
+          id="confirmDeleteEditDialog"
+          class="confirmDialog"
+          confirm-label="Delete"
+          on-cancel="_handleConfirmDialogCancel"
+          on-confirm="_handleDeleteEditConfirm">
+        <div class="header">
+          Delete Change Edit
+        </div>
+        <div class="main">
+          Do you really want to delete the edit?
+        </div>
+      </gr-confirm-dialog>
     </gr-overlay>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 2b0916d..e8c2d03 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -14,10 +14,13 @@
 (function() {
   'use strict';
 
+  const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
+  const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
+  const ERR_REVISION_ACTIONS = 'Couldn’t load revision actions.';
   /**
-   * @enum {number}
+   * @enum {string}
    */
-  var LabelStatus = {
+  const LabelStatus = {
     /**
      * This label provides what is necessary for submission.
      */
@@ -41,44 +44,55 @@
      * project owner or site administrator.
      */
     IMPOSSIBLE: 'IMPOSSIBLE',
+    OPTIONAL: 'OPTIONAL',
   };
 
   // TODO(davido): Add the rest of the change actions.
-  var ChangeActions = {
+  const ChangeActions = {
     ABANDON: 'abandon',
     DELETE: '/',
+    DELETE_EDIT: 'deleteEdit',
+    IGNORE: 'ignore',
+    MOVE: 'move',
+    PRIVATE: 'private',
+    PRIVATE_DELETE: 'private.delete',
+    PUBLISH_EDIT: 'publishEdit',
+    REBASE_EDIT: 'rebaseEdit',
     RESTORE: 'restore',
     REVERT: 'revert',
+    REVIEWED: 'reviewed',
+    UNIGNORE: 'unignore',
+    UNREVIEWED: 'unreviewed',
+    WIP: 'wip',
   };
 
   // TODO(andybons): Add the rest of the revision actions.
-  var RevisionActions = {
+  const RevisionActions = {
     CHERRYPICK: 'cherrypick',
-    DELETE: '/',
-    PUBLISH: 'publish',
     REBASE: 'rebase',
     SUBMIT: 'submit',
+    DOWNLOAD: 'download',
   };
 
-  var ActionLoadingLabels = {
-    'abandon': 'Abandoning...',
-    'cherrypick': 'Cherry-Picking...',
-    'delete': 'Deleting...',
-    'publish': 'Publishing...',
-    'rebase': 'Rebasing...',
-    'restore': 'Restoring...',
-    'revert': 'Reverting...',
-    'submit': 'Submitting...',
+  const ActionLoadingLabels = {
+    abandon: 'Abandoning...',
+    cherrypick: 'Cherry-Picking...',
+    delete: 'Deleting...',
+    move: 'Moving..',
+    rebase: 'Rebasing...',
+    restore: 'Restoring...',
+    revert: 'Reverting...',
+    submit: 'Submitting...',
   };
 
-  var ActionType = {
+  const ActionType = {
     CHANGE: 'change',
     REVISION: 'revision',
   };
 
-  var ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
+  const ADDITIONAL_ACTION_KEY_PREFIX = '__additionalAction_';
 
-  var QUICK_APPROVE_ACTION = {
+  const QUICK_APPROVE_ACTION = {
     __key: 'review',
     __type: 'change',
     enabled: true,
@@ -87,14 +101,55 @@
     method: 'POST',
   };
 
-  /**
-   * Keys for actions to appear in the overflow menu rather than the top-level
-   * set of action buttons.
-   */
-  var MENU_ACTION_KEYS = [
-    'cherrypick',
-    '/', // '/' is the key for the delete action.
-  ];
+  const ActionPriority = {
+    CHANGE: 2,
+    DEFAULT: 0,
+    PRIMARY: 3,
+    REVIEW: -3,
+    REVISION: 1,
+  };
+
+  const DOWNLOAD_ACTION = {
+    enabled: true,
+    label: 'Download patch',
+    title: 'Open download dialog',
+    __key: 'download',
+    __primary: false,
+    __type: 'revision',
+  };
+
+  const REBASE_EDIT = {
+    enabled: true,
+    label: 'Rebase Edit',
+    title: 'Rebase change edit',
+    __key: 'rebaseEdit',
+    __primary: false,
+    __type: 'change',
+    method: 'POST',
+  };
+
+  const PUBLISH_EDIT = {
+    enabled: true,
+    label: 'Publish Edit',
+    title: 'Publish change edit',
+    __key: 'publishEdit',
+    __primary: false,
+    __type: 'change',
+    method: 'POST',
+  };
+
+  const DELETE_EDIT = {
+    enabled: true,
+    label: 'Delete Edit',
+    title: 'Delete change edit',
+    __key: 'deleteEdit',
+    __primary: false,
+    __type: 'change',
+    method: 'DELETE',
+  };
+
+  const AWAIT_CHANGE_ATTEMPTS = 5;
+  const AWAIT_CHANGE_TIMEOUT_MS = 1000;
 
   Polymer({
     is: 'gr-change-actions',
@@ -111,17 +166,23 @@
      * @event <action key>-tap
      */
 
+    /**
+     * Fires to show an alert when a send is attempted on the non-latest patch.
+     *
+     * @event show-alert
+     */
+
     properties: {
+      /** @type {{ branch: string, project: string }} */
       change: Object,
       actions: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       primaryActionKeys: {
         type: Array,
-        value: function() {
+        value() {
           return [
-            RevisionActions.PUBLISH,
             RevisionActions.SUBMIT,
           ];
         },
@@ -142,9 +203,10 @@
         type: String,
         value: '',
       },
+      /** @type {?} */
       revisionActions: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
 
       _loading: {
@@ -153,95 +215,173 @@
       },
       _actionLoadingMessage: {
         type: String,
-        value: null,
+        value: '',
       },
       _allActionValues: {
         type: Array,
         computed: '_computeAllActions(actions.*, revisionActions.*,' +
-            'primaryActionKeys.*, _additionalActions.*, change)',
+            'primaryActionKeys.*, _additionalActions.*, change, ' +
+            '_actionPriorityOverrides.*)',
       },
       _topLevelActions: {
         type: Array,
         computed: '_computeTopLevelActions(_allActionValues.*, ' +
-            '_hiddenActions.*)',
+            '_hiddenActions.*, _overflowActions.*)',
       },
       _menuActions: {
         type: Array,
-        computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*)',
+        computed: '_computeMenuActions(_allActionValues.*, _hiddenActions.*, ' +
+            '_overflowActions.*)',
+      },
+      _overflowActions: {
+        type: Array,
+        value() {
+          const value = [
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.WIP,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.DELETE,
+            },
+            {
+              type: ActionType.REVISION,
+              key: RevisionActions.CHERRYPICK,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.MOVE,
+            },
+            {
+              type: ActionType.REVISION,
+              key: RevisionActions.DOWNLOAD,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.IGNORE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.UNIGNORE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.REVIEWED,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.UNREVIEWED,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.PRIVATE,
+            },
+            {
+              type: ActionType.CHANGE,
+              key: ChangeActions.PRIVATE_DELETE,
+            },
+          ];
+          return value;
+        },
+      },
+      _actionPriorityOverrides: {
+        type: Array,
+        value() { return []; },
       },
       _additionalActions: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _hiddenActions: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _disabledMenuActions: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
+      },
+      editLoaded: {
+        type: Boolean,
+        value: false,
+      },
+      editBasedOnCurrentPatchSet: {
+        type: Boolean,
+        value: true,
       },
     },
 
-    ActionType: ActionType,
-    ChangeActions: ChangeActions,
-    RevisionActions: RevisionActions,
+    ActionType,
+    ChangeActions,
+    RevisionActions,
 
     behaviors: [
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
     observers: [
-      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*)',
+      '_actionsChanged(actions.*, revisionActions.*, _additionalActions.*, ' +
+          'editLoaded, editBasedOnCurrentPatchSet, change)',
+      '_changeChanged(change)',
     ],
 
-    ready: function() {
+    listeners: {
+      'fullscreen-overlay-opened': '_handleHideBackgroundContent',
+      'fullscreen-overlay-closed': '_handleShowBackgroundContent',
+    },
+
+    ready() {
       this.$.jsAPI.addElement(this.$.jsAPI.Element.CHANGE_ACTIONS, this);
       this._loading = false;
     },
 
-    reload: function() {
+    reload() {
       if (!this.changeNum || !this.patchNum) {
         return Promise.resolve();
       }
 
       this._loading = true;
-      return this._getRevisionActions().then(function(revisionActions) {
+      return this._getRevisionActions().then(revisionActions => {
         if (!revisionActions) { return; }
 
         this.revisionActions = revisionActions;
         this._loading = false;
-      }.bind(this)).catch(function(err) {
-        alert('Couldn’t load revision actions. Check the console ' +
-            'and contact the PolyGerrit team for assistance.');
+      }).catch(err => {
+        this.fire('show-alert', {message: ERR_REVISION_ACTIONS});
         this._loading = false;
         throw err;
-      }.bind(this));
+      });
     },
 
-    addActionButton: function(type, label) {
+    _changeChanged() {
+      this.reload();
+    },
+
+    addActionButton(type, label) {
       if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error('Invalid action type: ' + type);
+        throw Error(`Invalid action type: ${type}`);
       }
-      var action = {
+      const action = {
         enabled: true,
-        label: label,
+        label,
         __type: type,
-        __key: ADDITIONAL_ACTION_KEY_PREFIX + Math.random().toString(36),
+        __key: ADDITIONAL_ACTION_KEY_PREFIX +
+            Math.random().toString(36).substr(2),
       };
       this.push('_additionalActions', action);
       return action.__key;
     },
 
-    removeActionButton: function(key) {
-      var idx = this._indexOfActionButtonWithKey(key);
+    removeActionButton(key) {
+      const idx = this._indexOfActionButtonWithKey(key);
       if (idx === -1) {
         return;
       }
       this.splice('_additionalActions', idx, 1);
     },
 
-    setActionButtonProp: function(key, prop, value) {
+    setActionButtonProp(key, prop, value) {
       this.set([
         '_additionalActions',
         this._indexOfActionButtonWithKey(key),
@@ -249,12 +389,48 @@
       ], value);
     },
 
-    setActionHidden: function(type, key, hidden) {
+    setActionOverflow(type, key, overflow) {
       if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
-        throw Error('Invalid action type given: ' + type);
+        throw Error(`Invalid action type given: ${type}`);
+      }
+      const index = this._getActionOverflowIndex(type, key);
+      const action = {
+        type,
+        key,
+        overflow,
+      };
+      if (!overflow && index !== -1) {
+        this.splice('_overflowActions', index, 1);
+      } else if (overflow) {
+        this.push('_overflowActions', action);
+      }
+    },
+
+    setActionPriority(type, key, priority) {
+      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+        throw Error(`Invalid action type given: ${type}`);
+      }
+      const index = this._actionPriorityOverrides.findIndex(action => {
+        return action.type === type && action.key === key;
+      });
+      const action = {
+        type,
+        key,
+        priority,
+      };
+      if (index !== -1) {
+        this.set('_actionPriorityOverrides', index, action);
+      } else {
+        this.push('_actionPriorityOverrides', action);
+      }
+    },
+
+    setActionHidden(type, key, hidden) {
+      if (type !== ActionType.CHANGE && type !== ActionType.REVISION) {
+        throw Error(`Invalid action type given: ${type}`);
       }
 
-      var idx = this._hiddenActions.indexOf(key);
+      const idx = this._hiddenActions.indexOf(key);
       if (hidden && idx === -1) {
         this.push('_hiddenActions', key);
       } else if (!hidden && idx !== -1) {
@@ -262,8 +438,16 @@
       }
     },
 
-    _indexOfActionButtonWithKey: function(key) {
-      for (var i = 0; i < this._additionalActions.length; i++) {
+    getActionDetails(action) {
+      if (this.revisionActions[action]) {
+        return this.revisionActions[action];
+      } else if (this.actions[action]) {
+        return this.actions[action];
+      }
+    },
+
+    _indexOfActionButtonWithKey(key) {
+      for (let i = 0; i < this._additionalActions.length; i++) {
         if (this._additionalActions[i].__key === key) {
           return i;
         }
@@ -271,37 +455,85 @@
       return -1;
     },
 
-    _getRevisionActions: function() {
+    _getRevisionActions() {
       return this.$.restAPI.getChangeRevisionActions(this.changeNum,
           this.patchNum);
     },
 
-    _shouldHideActions: function(actions, loading) {
+    _shouldHideActions(actions, loading) {
       return loading || !actions || !actions.base || !actions.base.length;
     },
 
-    _keyCount: function(changeRecord) {
+    _keyCount(changeRecord) {
       return Object.keys((changeRecord && changeRecord.base) || {}).length;
     },
 
-    _actionsChanged: function(actionsChangeRecord, revisionActionsChangeRecord,
-        additionalActionsChangeRecord) {
-      var additionalActions = (additionalActionsChangeRecord &&
+    _actionsChanged(actionsChangeRecord, revisionActionsChangeRecord,
+        additionalActionsChangeRecord, editLoaded, editBasedOnCurrentPatchSet,
+        change) {
+      const additionalActions = (additionalActionsChangeRecord &&
           additionalActionsChangeRecord.base) || [];
       this.hidden = this._keyCount(actionsChangeRecord) === 0 &&
           this._keyCount(revisionActionsChangeRecord) === 0 &&
               additionalActions.length === 0;
-      this._actionLoadingMessage = null;
+      this._actionLoadingMessage = '';
       this._disabledMenuActions = [];
+
+      const revisionActions = revisionActionsChangeRecord.base || {};
+      if (Object.keys(revisionActions).length !== 0 &&
+          !revisionActions.download) {
+        this.set('revisionActions.download', DOWNLOAD_ACTION);
+      }
+
+      const changeActions = actionsChangeRecord.base || {};
+      if (Object.keys(changeActions).length !== 0) {
+        if (editLoaded) {
+          if (this.changeIsOpen(change.status)) {
+            if (editBasedOnCurrentPatchSet) {
+              if (!changeActions.publishEdit) {
+                this.set('actions.publishEdit', PUBLISH_EDIT);
+              }
+              if (changeActions.rebaseEdit) {
+                delete this.actions.rebaseEdit;
+                this.notifyPath('actions.rebaseEdit');
+              }
+            } else {
+              if (!changeActions.rebasEdit) {
+                this.set('actions.rebaseEdit', REBASE_EDIT);
+              }
+              if (changeActions.publishEdit) {
+                delete this.actions.publishEdit;
+                this.notifyPath('actions.publishEdit');
+              }
+            }
+          }
+          if (!changeActions.deleteEdit) {
+            this.set('actions.deleteEdit', DELETE_EDIT);
+          }
+        } else {
+          if (changeActions.publishEdit) {
+            delete this.actions.publishEdit;
+            this.notifyPath('actions.publishEdit');
+          }
+          if (changeActions.rebaseEdit) {
+            delete this.actions.rebaseEdit;
+            this.notifyPath('actions.rebaseEdit');
+          }
+          if (changeActions.deleteEdit) {
+            delete this.actions.deleteEdit;
+            this.notifyPath('actions.deleteEdit');
+          }
+        }
+      }
     },
 
-    _getValuesFor: function(obj) {
-      return Object.keys(obj).map(function(key) {
+    _getValuesFor(obj) {
+      return Object.keys(obj).map(key => {
         return obj[key];
       });
     },
 
-    _getLabelStatus: function(label) {
+    _getLabelStatus(label) {
       if (label.approved) {
         return LabelStatus.OK;
       } else if (label.rejected) {
@@ -317,23 +549,23 @@
      * Get highest score for last missing permitted label for current change.
      * Returns null if no labels permitted or more than one label missing.
      *
-     * @return {{label: string, score: string}}
+     * @return {{label: string, score: string}|null}
      */
-    _getTopMissingApproval: function() {
+    _getTopMissingApproval() {
       if (!this.change ||
           !this.change.labels ||
           !this.change.permitted_labels) {
         return null;
       }
-      var result;
-      for (var label in this.change.labels) {
+      let result;
+      for (const label in this.change.labels) {
         if (!(label in this.change.permitted_labels)) {
           continue;
         }
         if (this.change.permitted_labels[label].length === 0) {
           continue;
         }
-        var status = this._getLabelStatus(this.change.labels[label]);
+        const status = this._getLabelStatus(this.change.labels[label]);
         if (status === LabelStatus.NEED) {
           if (result) {
             // More than one label is missing, so it's unclear which to quick
@@ -342,33 +574,33 @@
           }
           result = label;
         } else if (status === LabelStatus.REJECT ||
-                   status === LabelStatus.IMPOSSIBLE) {
+            status === LabelStatus.IMPOSSIBLE) {
           return null;
         }
       }
       if (result) {
-        var score = this.change.permitted_labels[result].slice(-1)[0];
-        var maxScore =
+        const score = this.change.permitted_labels[result].slice(-1)[0];
+        const maxScore =
             Object.keys(this.change.labels[result].values).slice(-1)[0];
         if (score === maxScore) {
           // Allow quick approve only for maximal score.
           return {
             label: result,
-            score: score,
+            score,
           };
         }
       }
       return null;
     },
 
-    _getQuickApproveAction: function() {
-      var approval = this._getTopMissingApproval();
+    _getQuickApproveAction() {
+      const approval = this._getTopMissingApproval();
       if (!approval) {
         return null;
       }
-      var action = Object.assign({}, QUICK_APPROVE_ACTION);
+      const action = Object.assign({}, QUICK_APPROVE_ACTION);
       action.label = approval.label + approval.score;
-      var review = {
+      const review = {
         drafts: 'PUBLISH_ALL_REVISIONS',
         labels: {},
       };
@@ -377,171 +609,240 @@
       return action;
     },
 
-    _getActionValues: function(actionsChangeRecord, primariesChangeRecord,
+    _getActionValues(actionsChangeRecord, primariesChangeRecord,
         additionalActionsChangeRecord, type) {
       if (!actionsChangeRecord || !primariesChangeRecord) { return []; }
 
-      var actions = actionsChangeRecord.base || {};
-      var primaryActionKeys = primariesChangeRecord.base || [];
-      var result = [];
-      var values = this._getValuesFor(
+      const actions = actionsChangeRecord.base || {};
+      const primaryActionKeys = primariesChangeRecord.base || [];
+      const result = [];
+      const values = this._getValuesFor(
           type === ActionType.CHANGE ? ChangeActions : RevisionActions);
-      for (var a in actions) {
-        if (values.indexOf(a) === -1) { continue; }
+      const pluginActions = [];
+      Object.keys(actions).forEach(a => {
         actions[a].__key = a;
         actions[a].__type = type;
-        actions[a].__primary = primaryActionKeys.indexOf(a) !== -1;
+        actions[a].__primary = primaryActionKeys.includes(a);
+        // Plugin actions always contain ~ in the key.
+        if (a.indexOf('~') !== -1) {
+          this._populateActionUrl(actions[a]);
+          pluginActions.push(actions[a]);
+          // Add server-side provided plugin actions to overflow menu.
+          this._overflowActions.push({
+            type,
+            key: a,
+          });
+          return;
+        } else if (!values.includes(a)) {
+          return;
+        }
         if (actions[a].label === 'Delete') {
           // This label is common within change and revision actions. Make it
           // more explicit to the user.
           if (type === ActionType.CHANGE) {
             actions[a].label += ' Change';
-          } else if (type === ActionType.REVISION) {
-            actions[a].label += ' Revision';
           }
         }
         // Triggers a re-render by ensuring object inequality.
-        // TODO(andybons): Polyfill for Object.assign.
         result.push(Object.assign({}, actions[a]));
-      }
+      });
 
-      var additionalActions = (additionalActionsChangeRecord &&
+      let additionalActions = (additionalActionsChangeRecord &&
       additionalActionsChangeRecord.base) || [];
-      additionalActions = additionalActions.filter(function(a) {
+      additionalActions = additionalActions.filter(a => {
         return a.__type === type;
-      }).map(function(a) {
-        a.__primary = primaryActionKeys.indexOf(a.__key) !== -1;
+      }).map(a => {
+        a.__primary = primaryActionKeys.includes(a.__key);
         // Triggers a re-render by ensuring object inequality.
-        // TODO(andybons): Polyfill for Object.assign.
         return Object.assign({}, a);
       });
-      return result.concat(additionalActions);
+      return result.concat(additionalActions).concat(pluginActions);
     },
 
-    _computeLoadingLabel: function(action) {
+    _populateActionUrl(action) {
+      const patchNum =
+            action.__type === ActionType.REVISION ? this.patchNum : null;
+      this.$.restAPI.getChangeActionURL(
+          this.changeNum, patchNum, '/' + action.__key)
+          .then(url => action.__url = url);
+    },
+
+    _computeLoadingLabel(action) {
       return ActionLoadingLabels[action] || 'Working...';
     },
 
-    _canSubmitChange: function() {
+    _canSubmitChange() {
       return this.$.jsAPI.canSubmitChange(this.change,
           this._getRevision(this.change, this.patchNum));
     },
 
-    _getRevision: function(change, patchNum) {
-      var num = window.parseInt(patchNum, 10);
-      for (var hash in change.revisions) {
-        var rev = change.revisions[hash];
-        if (rev._number === num) {
+    _getRevision(change, patchNum) {
+      for (const rev of Object.values(change.revisions)) {
+        if (this.patchNumEquals(rev._number, patchNum)) {
           return rev;
         }
       }
       return null;
     },
 
-    _modifyRevertMsg: function() {
+    _modifyRevertMsg() {
       return this.$.jsAPI.modifyRevertMsg(this.change,
           this.$.confirmRevertDialog.message, this.commitMessage);
     },
 
-    showRevertDialog: function() {
+    showRevertDialog() {
       this.$.confirmRevertDialog.populateRevertMessage(
           this.commitMessage, this.change.current_revision);
       this.$.confirmRevertDialog.message = this._modifyRevertMsg();
       this._showActionDialog(this.$.confirmRevertDialog);
     },
 
-    _handleActionTap: function(e) {
+    _handleActionTap(e) {
       e.preventDefault();
-      var el = Polymer.dom(e).rootTarget;
-      var key = el.getAttribute('data-action-key');
-      if (key.indexOf(ADDITIONAL_ACTION_KEY_PREFIX) === 0) {
-        this.fire(key + '-tap', {node: el});
+      const el = Polymer.dom(e).localTarget;
+      const key = el.getAttribute('data-action-key');
+      if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+          key.indexOf('~') !== -1) {
+        this.fire(`${key}-tap`, {node: el});
         return;
       }
-      var type = el.getAttribute('data-action-type');
-      if (type === ActionType.REVISION) {
-        this._handleRevisionAction(key);
-      } else if (key === ChangeActions.REVERT) {
-        this.showRevertDialog();
-      } else if (key === ChangeActions.ABANDON) {
-        this._showActionDialog(this.$.confirmAbandonDialog);
-      } else if (key === QUICK_APPROVE_ACTION.key) {
-        var action = this._allActionValues.find(function(o) {
-          return o.key === key;
-        });
-        this._fireAction(
-            this._prependSlash(key), action, true, action.payload);
-      } else {
-        this._fireAction(this._prependSlash(key), this.actions[key], false);
+      const type = el.getAttribute('data-action-type');
+      this._handleAction(type, key);
+    },
+
+    _handleOveflowItemTap(e) {
+      e.preventDefault();
+      const el = Polymer.dom(e).localTarget;
+      const key = e.detail.action.__key;
+      if (key.startsWith(ADDITIONAL_ACTION_KEY_PREFIX) ||
+          key.indexOf('~') !== -1) {
+        this.fire(`${key}-tap`, {node: el});
+        return;
+      }
+      this._handleAction(e.detail.action.__type, e.detail.action.__key);
+    },
+
+    _handleAction(type, key) {
+      switch (type) {
+        case ActionType.REVISION:
+          this._handleRevisionAction(key);
+          break;
+        case ActionType.CHANGE:
+          this._handleChangeAction(key);
+          break;
+        default:
+          this._fireAction(this._prependSlash(key), this.actions[key], false);
       }
     },
 
-    _handleRevisionAction: function(key) {
+    _handleChangeAction(key) {
+      let action;
+      switch (key) {
+        case ChangeActions.REVERT:
+          this.showRevertDialog();
+          break;
+        case ChangeActions.ABANDON:
+          this._showActionDialog(this.$.confirmAbandonDialog);
+          break;
+        case QUICK_APPROVE_ACTION.key:
+          action = this._allActionValues.find(o => {
+            return o.key === key;
+          });
+          this._fireAction(
+              this._prependSlash(key), action, true, action.payload);
+          break;
+        case ChangeActions.DELETE:
+          this._handleDeleteTap();
+          break;
+        case ChangeActions.DELETE_EDIT:
+          this._handleDeleteEditTap();
+          break;
+        case ChangeActions.WIP:
+          this._handleWipTap();
+          break;
+        case ChangeActions.MOVE:
+          this._handleMoveTap();
+          break;
+        case ChangeActions.PUBLISH_EDIT:
+          this._handlePublishEditTap();
+          break;
+        case ChangeActions.REBASE_EDIT:
+          this._handleRebaseEditTap();
+          break;
+        default:
+          this._fireAction(this._prependSlash(key), this.actions[key], false);
+      }
+    },
+
+    _handleRevisionAction(key) {
       switch (key) {
         case RevisionActions.REBASE:
           this._showActionDialog(this.$.confirmRebase);
           break;
+        case RevisionActions.CHERRYPICK:
+          this._handleCherrypickTap();
+          break;
+        case RevisionActions.DOWNLOAD:
+          this._handleDownloadTap();
+          break;
         case RevisionActions.SUBMIT:
           if (!this._canSubmitChange()) {
             return;
           }
-        /* falls through */ // required by JSHint
+        // eslint-disable-next-line no-fallthrough
         default:
           this._fireAction(this._prependSlash(key),
               this.revisionActions[key], true);
       }
     },
 
-    _prependSlash: function(key) {
-      return key === '/' ? key : '/' + key;
+    _prependSlash(key) {
+      return key === '/' ? key : `/${key}`;
     },
 
     /**
-     * Returns true if hasParent is defined (can be either true or false).
-     * returns false otherwise.
-     * @return {boolean} hasParent
+     * _hasKnownChainState set to true true if hasParent is defined (can be
+     * either true or false). set to false otherwise.
      */
-    _computeChainState: function(hasParent) {
+    _computeChainState(hasParent) {
       this._hasKnownChainState = true;
     },
 
-    _calculateDisabled: function(action, hasKnownChainState) {
+    _calculateDisabled(action, hasKnownChainState) {
       if (action.__key === 'rebase' && hasKnownChainState === false) {
         return true;
       }
       return !action.enabled;
     },
 
-    _handleConfirmDialogCancel: function() {
+    _handleConfirmDialogCancel() {
       this._hideAllDialogs();
     },
 
-    _hideAllDialogs: function() {
-      var dialogEls =
+    _hideAllDialogs() {
+      const dialogEls =
           Polymer.dom(this.root).querySelectorAll('.confirmDialog');
-      for (var i = 0; i < dialogEls.length; i++) {
-        dialogEls[i].hidden = true;
-      }
+      for (const dialogEl of dialogEls) { dialogEl.hidden = true; }
       this.$.overlay.close();
     },
 
-    _handleRebaseConfirm: function() {
-      var el = this.$.confirmRebase;
-      var payload = {base: el.base};
+    _handleRebaseConfirm() {
+      const el = this.$.confirmRebase;
+      const payload = {base: el.base};
       this.$.overlay.close();
       el.hidden = true;
       this._fireAction('/rebase', this.revisionActions.rebase, true, payload);
     },
 
-    _handleCherrypickConfirm: function() {
-      var el = this.$.confirmCherrypick;
+    _handleCherrypickConfirm() {
+      const el = this.$.confirmCherrypick;
       if (!el.branch) {
         // TODO(davido): Fix error handling
-        alert('The destination branch can’t be empty.');
+        this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
         return;
       }
       if (!el.message) {
-        alert('The commit message can’t be empty.');
+        this.fire('show-alert', {message: ERR_COMMIT_EMPTY});
         return;
       }
       this.$.overlay.close();
@@ -557,61 +858,98 @@
       );
     },
 
-    _handleRevertDialogConfirm: function() {
-      var el = this.$.confirmRevertDialog;
+    _handleMoveConfirm() {
+      const el = this.$.confirmMove;
+      if (!el.branch) {
+        this.fire('show-alert', {message: ERR_BRANCH_EMPTY});
+        return;
+      }
+      this.$.overlay.close();
+      el.hidden = true;
+      this._fireAction(
+          '/move',
+          this.actions.move,
+          false,
+          {
+            destination_branch: el.branch,
+            message: el.message,
+          }
+      );
+    },
+
+    _handleRevertDialogConfirm() {
+      const el = this.$.confirmRevertDialog;
       this.$.overlay.close();
       el.hidden = true;
       this._fireAction('/revert', this.actions.revert, false,
           {message: el.message});
     },
 
-    _handleAbandonDialogConfirm: function() {
-      var el = this.$.confirmAbandonDialog;
+    _handleAbandonDialogConfirm() {
+      const el = this.$.confirmAbandonDialog;
       this.$.overlay.close();
       el.hidden = true;
       this._fireAction('/abandon', this.actions.abandon, false,
           {message: el.message});
     },
 
-    _handleDeleteConfirm: function() {
+    _handleDeleteConfirm() {
       this._fireAction('/', this.actions[ChangeActions.DELETE], false);
     },
 
-    _setLoadingOnButtonWithKey: function(key) {
+    _handleDeleteEditConfirm() {
+      this._hideAllDialogs();
+
+      this._fireAction('/edit', this.actions.deleteEdit, false);
+    },
+
+    _getActionOverflowIndex(type, key) {
+      return this._overflowActions.findIndex(action => {
+        return action.type === type && action.key === key;
+      });
+    },
+
+    _setLoadingOnButtonWithKey(type, key) {
       this._actionLoadingMessage = this._computeLoadingLabel(key);
 
       // If the action appears in the overflow menu.
-      if (MENU_ACTION_KEYS.indexOf(key) !== -1) {
+      if (this._getActionOverflowIndex(type, key) !== -1) {
         this.push('_disabledMenuActions', key === '/' ? 'delete' : key);
         return function() {
-          this._actionLoadingMessage = null;
+          this._actionLoadingMessage = '';
           this._disabledMenuActions = [];
         }.bind(this);
       }
 
       // Otherwise it's a top-level action.
-      var buttonEl = this.$$('[data-action-key="' + key + '"]');
+      const buttonEl = this.$$(`[data-action-key="${key}"]`);
       buttonEl.setAttribute('loading', true);
       buttonEl.disabled = true;
       return function() {
-        this._actionLoadingMessage = null;
+        this._actionLoadingMessage = '';
         buttonEl.removeAttribute('loading');
         buttonEl.disabled = false;
       }.bind(this);
     },
 
-    _fireAction: function(endpoint, action, revAction, opt_payload) {
-      var cleanupFn = this._setLoadingOnButtonWithKey(action.__key);
-
+    /**
+     * @param {string} endpoint
+     * @param {!Object|undefined} action
+     * @param {boolean} revAction
+     * @param {!Object|string=} opt_payload
+     */
+    _fireAction(endpoint, action, revAction, opt_payload) {
+      const cleanupFn =
+          this._setLoadingOnButtonWithKey(action.__type, action.__key);
       this._send(action.method, opt_payload, endpoint, revAction, cleanupFn)
           .then(this._handleResponse.bind(this, action));
     },
 
-    _showActionDialog: function(dialog) {
+    _showActionDialog(dialog) {
       this._hideAllDialogs();
 
       dialog.hidden = false;
-      this.$.overlay.open().then(function() {
+      this.$.overlay.open().then(() => {
         if (dialog.resetFocus) {
           dialog.resetFocus();
         }
@@ -620,148 +958,266 @@
 
     // TODO(rmistry): Redo this after
     // https://bugs.chromium.org/p/gerrit/issues/detail?id=4671 is resolved.
-    _setLabelValuesOnRevert: function(newChangeId) {
-      var labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
-      if (labels) {
-        var url = '/changes/' + newChangeId + '/revisions/current/review';
-        this.$.restAPI.send(this.actions.revert.method, url, {labels: labels});
-      }
+    _setLabelValuesOnRevert(newChangeId) {
+      const labels = this.$.jsAPI.getLabelValuesPostRevert(this.change);
+      if (!labels) { return Promise.resolve(); }
+      return this.$.restAPI.getChangeURLAndSend(newChangeId,
+          this.actions.revert.method, 'current', '/review', {labels});
     },
 
-    _handleResponse: function(action, response) {
+    _handleResponse(action, response) {
       if (!response) { return; }
-      return this.$.restAPI.getResponseObject(response).then(function(obj) {
-          switch (action.__key) {
-            case ChangeActions.REVERT:
-              this._setLabelValuesOnRevert(obj.change_id);
-              /* falls through */
-            case RevisionActions.CHERRYPICK:
-              page.show(this.changePath(obj._number));
-              break;
-            case ChangeActions.DELETE:
-            case RevisionActions.DELETE:
-              if (action.__type === ActionType.CHANGE) {
-                page.show('/');
-              } else {
-                page.show(this.changePath(this.changeNum));
-              }
-              break;
-            default:
-              this.dispatchEvent(new CustomEvent('reload-change',
-                  {detail: {action: action.__key}, bubbles: false}));
-              break;
-          }
-      }.bind(this));
+      return this.$.restAPI.getResponseObject(response).then(obj => {
+        switch (action.__key) {
+          case ChangeActions.REVERT:
+            this._waitForChangeReachable(obj._number)
+                .then(() => this._setLabelValuesOnRevert(obj._number))
+                .then(() => {
+                  Gerrit.Nav.navigateToChange(obj);
+                });
+            break;
+          case RevisionActions.CHERRYPICK:
+            this._waitForChangeReachable(obj._number).then(() => {
+              Gerrit.Nav.navigateToChange(obj);
+            });
+            break;
+          case ChangeActions.DELETE:
+            if (action.__type === ActionType.CHANGE) {
+              page.show('/');
+            }
+            break;
+          case ChangeActions.WIP:
+          case ChangeActions.DELETE_EDIT:
+          case ChangeActions.PUBLISH_EDIT:
+          case ChangeActions.REBASE_EDIT:
+            page.show(this.changePath(this.changeNum));
+            break;
+          default:
+            this.dispatchEvent(new CustomEvent('reload-change',
+                {detail: {action: action.__key}, bubbles: false}));
+            break;
+        }
+      });
     },
 
-    _handleResponseError: function(response) {
-      return response.text().then(function(errText) {
+    _handleResponseError(response) {
+      return response.text().then(errText => {
         this.fire('show-alert',
-            { message: 'Could not perform action: ' + errText });
-        if (errText.indexOf('Change is already up to date') !== 0) {
+            {message: `Could not perform action: ${errText}`});
+        if (!errText.startsWith('Change is already up to date')) {
           throw Error(errText);
         }
-      }.bind(this));
+      });
     },
 
-    _send: function(method, payload, actionEndpoint, revisionAction,
-        cleanupFn, opt_errorFn) {
-      var url = this.$.restAPI.getChangeActionURL(this.changeNum,
-          revisionAction ? this.patchNum : null, actionEndpoint);
-      return this.$.restAPI.send(method, url, payload,
-          this._handleResponseError, this).then(function(response) {
-            cleanupFn.call(this);
-            return response;
-      }.bind(this));
+    /**
+     * @param {string} method
+     * @param {string|!Object|undefined} payload
+     * @param {string} actionEndpoint
+     * @param {boolean} revisionAction
+     * @param {?Function} cleanupFn
+     * @param {?Function=} opt_errorFn
+     */
+    _send(method, payload, actionEndpoint, revisionAction, cleanupFn,
+        opt_errorFn) {
+      const handleError = response => {
+        cleanupFn.call(this);
+        this._handleResponseError(response);
+      };
+
+      return this.fetchIsLatestKnown(this.change, this.$.restAPI)
+          .then(isLatest => {
+            if (!isLatest) {
+              this.fire('show-alert', {
+                message: 'Cannot set label: a newer patch has been ' +
+                    'uploaded to this change.',
+                action: 'Reload',
+                callback: () => {
+                  // Load the current change without any patch range.
+                  Gerrit.Nav.navigateToChange(this.change);
+                },
+              });
+
+              // Because this is not a network error, call the cleanup function
+              // but not the error handler.
+              cleanupFn();
+
+              return Promise.resolve();
+            }
+            const patchNum = revisionAction ? this.patchNum : null;
+            return this.$.restAPI.getChangeURLAndSend(this.changeNum, method,
+                patchNum, actionEndpoint, payload, handleError, this)
+                .then(response => {
+                  cleanupFn.call(this);
+                  return response;
+                });
+          });
     },
 
-    _handleAbandonTap: function() {
+    _handleAbandonTap() {
       this._showActionDialog(this.$.confirmAbandonDialog);
     },
 
-    _handleCherrypickTap: function() {
+    _handleCherrypickTap() {
       this.$.confirmCherrypick.branch = '';
       this._showActionDialog(this.$.confirmCherrypick);
     },
 
-    _handleDeleteTap: function() {
+    _handleMoveTap() {
+      this.$.confirmMove.branch = '';
+      this.$.confirmMove.message = '';
+      this._showActionDialog(this.$.confirmMove);
+    },
+
+    _handleDownloadTap() {
+      this.fire('download-tap', null, {bubbles: false});
+    },
+
+    _handleDeleteTap() {
       this._showActionDialog(this.$.confirmDeleteDialog);
     },
 
+    _handleDeleteEditTap() {
+      this._showActionDialog(this.$.confirmDeleteEditDialog);
+    },
+
+    _handleWipTap() {
+      this._fireAction('/wip', this.actions.wip, false);
+    },
+
+    _handlePublishEditTap() {
+      this._fireAction('/edit:publish', this.actions.publishEdit, false);
+    },
+
+    _handleRebaseEditTap() {
+      this._fireAction('/edit:rebase', this.actions.rebaseEdit, false);
+    },
+
+    _handleHideBackgroundContent() {
+      this.$.mainContent.classList.add('overlayOpen');
+    },
+
+    _handleShowBackgroundContent() {
+      this.$.mainContent.classList.remove('overlayOpen');
+    },
+
     /**
      * Merge sources of change actions into a single ordered array of action
      * values.
-     * @param {splices} changeActionsRecord
-     * @param {splices} revisionActionsRecord
-     * @param {splices} primariesRecord
-     * @param {splices} additionalActionsRecord
-     * @param {Object} change The change object.
-     * @return {Array}
+     * @param {!Array} changeActionsRecord
+     * @param {!Array} revisionActionsRecord
+     * @param {!Array} primariesRecord
+     * @param {!Array} additionalActionsRecord
+     * @param {!Object} change The change object.
+     * @return {!Array}
      */
-    _computeAllActions: function(changeActionsRecord, revisionActionsRecord,
+    _computeAllActions(changeActionsRecord, revisionActionsRecord,
         primariesRecord, additionalActionsRecord, change) {
-      var revisionActionValues = this._getActionValues(revisionActionsRecord,
+      const revisionActionValues = this._getActionValues(revisionActionsRecord,
           primariesRecord, additionalActionsRecord, ActionType.REVISION);
-      var changeActionValues = this._getActionValues(changeActionsRecord,
-          primariesRecord, additionalActionsRecord, ActionType.CHANGE, change);
-      var quickApprove = this._getQuickApproveAction();
+      const changeActionValues = this._getActionValues(changeActionsRecord,
+          primariesRecord, additionalActionsRecord, ActionType.CHANGE);
+      const quickApprove = this._getQuickApproveAction();
       if (quickApprove) {
         changeActionValues.unshift(quickApprove);
       }
       return revisionActionValues
           .concat(changeActionValues)
-          .sort(this._actionComparator);
+          .sort(this._actionComparator.bind(this));
+    },
+
+    _getActionPriority(action) {
+      if (action.__type && action.__key) {
+        const overrideAction = this._actionPriorityOverrides.find(i => {
+          return i.type === action.__type && i.key === action.__key;
+        });
+
+        if (overrideAction !== undefined) {
+          return overrideAction.priority;
+        }
+      }
+      if (action.__key === 'review') {
+        return ActionPriority.REVIEW;
+      } else if (action.__primary) {
+        return ActionPriority.PRIMARY;
+      } else if (action.__type === ActionType.CHANGE) {
+        return ActionPriority.CHANGE;
+      } else if (action.__type === ActionType.REVISION) {
+        return ActionPriority.REVISION;
+      }
+      return ActionPriority.DEFAULT;
     },
 
     /**
      * Sort comparator to define the order of change actions.
      */
-    _actionComparator: function(actionA, actionB) {
-      // The code review action always appears first.
-      if (actionA.__key === 'review') {
-        return -1;
-      } else if (actionB.__key === 'review') {
-        return 1;
+    _actionComparator(actionA, actionB) {
+      const priorityDelta = this._getActionPriority(actionA) -
+          this._getActionPriority(actionB);
+      // Sort by the button label if same priority.
+      if (priorityDelta === 0) {
+        return actionA.label > actionB.label ? 1 : -1;
+      } else {
+        return priorityDelta;
       }
-
-      // Primary actions always appear last.
-      if (actionA.__primary) {
-        return 1;
-      } else if (actionB.__primary) {
-        return -1;
-      }
-
-      // Change actions appear before revision actions.
-     if (actionA.__type === 'change' && actionB.__type === 'revision') {
-        return 1;
-      } else if (actionA.__type === 'revision' && actionB.__type === 'change') {
-        return -1;
-      }
-
-      // Otherwise, sort by the button label.
-      return actionA.label > actionB.label ? 1 : -1;
     },
 
-    _computeTopLevelActions: function(actionRecord, hiddenActionsRecord) {
-      var hiddenActions = hiddenActionsRecord.base || [];
-      return actionRecord.base.filter(function(a) {
-        return MENU_ACTION_KEYS.indexOf(a.__key) === -1 &&
-                hiddenActions.indexOf(a.__key) === -1;
+    _computeTopLevelActions(actionRecord, hiddenActionsRecord) {
+      const hiddenActions = hiddenActionsRecord.base || [];
+      return actionRecord.base.filter(a => {
+        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+        return !(overflow || hiddenActions.includes(a.__key));
       });
     },
 
-    _computeMenuActions: function(actionRecord, hiddenActionsRecord) {
-      var hiddenActions = hiddenActionsRecord.base || [];
-      return actionRecord.base
-          .filter(function(a) {
-            return MENU_ACTION_KEYS.indexOf(a.__key) !== -1 &&
-                hiddenActions.indexOf(a.__key) === -1;
-          })
-          .map(function(action) {
-            var key = action.__key;
-            if (key === '/') { key = 'delete'; }
-            return {name: action.label, id: key, };
+    _computeMenuActions(actionRecord, hiddenActionsRecord) {
+      const hiddenActions = hiddenActionsRecord.base || [];
+      return actionRecord.base.filter(a => {
+        const overflow = this._getActionOverflowIndex(a.__type, a.__key) !== -1;
+        return overflow && !hiddenActions.includes(a.__key);
+      }).map(action => {
+        let key = action.__key;
+        if (key === '/') { key = 'delete'; }
+        return {
+          name: action.label,
+          id: `${key}-${action.__type}`,
+          action,
+        };
+      });
+    },
+
+    /**
+     * Occasionally, a change created by a change action is not yet knwon to the
+     * API for a brief time. Wait for the given change number to be recognized.
+     *
+     * Returns a promise that resolves with true if a request is recognized, or
+     * false if the change was never recognized after all attempts.
+     *
+     * @param  {number} changeNum
+     * @return {Promise<boolean>}
+     */
+    _waitForChangeReachable(changeNum) {
+      let attempsRemaining = AWAIT_CHANGE_ATTEMPTS;
+      return new Promise(resolve => {
+        const check = () => {
+          attempsRemaining--;
+          // Pass a no-op error handler to avoid the "not found" error toast.
+          this.$.restAPI.getChange(changeNum, () => {}).then(response => {
+            // If the response is 404, the response will be undefined.
+            if (response) {
+              resolve(true);
+              return;
+            }
+
+            if (attempsRemaining) {
+              this.async(check, AWAIT_CHANGE_TIMEOUT_MS);
+            } else {
+              resolve(false);
+            }
           });
+        };
+        check();
+      });
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 83a1c7e..b188aca 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-change-actions.html">
 
 <script>void(0);</script>
@@ -34,18 +34,14 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-actions tests', function() {
-    var element;
-    setup(function() {
+  suite('gr-change-actions tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getChangeRevisionActions: function() {
+        getChangeRevisionActions() {
           return Promise.resolve({
-            '/': {
-              method: 'DELETE',
-              label: 'Delete',
-              title: 'Delete draft revision 2',
-              enabled: true,
-            },
             cherrypick: {
               method: 'POST',
               label: 'Cherry Pick',
@@ -66,18 +62,18 @@
             },
           });
         },
-        send: function(method, url, payload) {
+        send(method, url, payload) {
           if (method !== 'POST') { return Promise.reject('bad method'); }
 
-          if (url === '/changes/42/revisions/2/submit') {
+          if (url === '/changes/test~42/revisions/2/submit') {
             return Promise.resolve({
               ok: true,
-              text: function() { return Promise.resolve(')]}\'\n{}'); },
+              text() { return Promise.resolve(')]}\'\n{}'); },
             });
-          } else if (url === '/changes/42/revisions/2/rebase') {
+          } else if (url === '/changes/test~42/revisions/2/rebase') {
             return Promise.resolve({
               ok: true,
-              text: function() { return Promise.resolve(')]}\'\n{}'); },
+              text() { return Promise.resolve(')]}\'\n{}'); },
             });
           }
 
@@ -92,23 +88,76 @@
       element.actions = {
         '/': {
           method: 'DELETE',
-          label: 'Delete',
-          title: 'Delete draft change 42',
+          label: 'Delete Change',
+          title: 'Delete change X_X',
           enabled: true,
         },
       };
+      sandbox = sinon.sandbox.create();
+
       return element.reload();
     });
 
-    test('_shouldHideActions', function() {
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_shouldHideActions', () => {
       assert.isTrue(element._shouldHideActions(undefined, true));
       assert.isTrue(element._shouldHideActions({base: {}}, false));
       assert.isFalse(element._shouldHideActions({base: ['test']}, false));
     });
 
-    test('hide revision action', function(done) {
-      flush(function() {
-        var buttonEl = element.$$('[data-action-key="submit"]');
+    test('plugin revision actions', () => {
+      sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
+          Promise.resolve('the-url'));
+      element.revisionActions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.revisionActions['plugin~action']);
+      flush(() => {
+        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+            element.changeNum, element.patchNum, '/plugin~action'));
+        assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
+      });
+    });
+
+    test('plugin change actions', () => {
+      sandbox.stub(element.$.restAPI, 'getChangeActionURL').returns(
+          Promise.resolve('the-url'));
+      element.actions = {
+        'plugin~action': {},
+      };
+      assert.isOk(element.actions['plugin~action']);
+      flush(() => {
+        assert.isTrue(element.$.restAPI.getChangeActionURL.calledWith(
+            element.changeNum, null, '/plugin~action'));
+        assert.equal(element.revisionActions['plugin~action'].__url, 'the-url');
+      });
+    });
+
+    test('not supported actions are filtered out', () => {
+      element.revisionActions = {
+        followup: {
+        },
+      };
+      assert.equal(element.querySelectorAll('section gr-button').length, 0);
+    });
+
+    test('getActionDetails', () => {
+      element.revisionActions = Object.assign({
+        'plugin~action': {},
+      }, element.revisionActions);
+      assert.isUndefined(element.getActionDetails('rubbish'));
+      assert.strictEqual(element.revisionActions['plugin~action'],
+          element.getActionDetails('plugin~action'));
+      assert.strictEqual(element.revisionActions['rebase'],
+          element.getActionDetails('rebase'));
+    });
+
+    test('hide revision action', done => {
+      flush(() => {
+        const buttonEl = element.$$('[data-action-key="submit"]');
         assert.isOk(buttonEl);
         assert.throws(element.setActionHidden.bind(element, 'invalid type'));
         element.setActionHidden(element.ActionType.REVISION,
@@ -117,14 +166,14 @@
         element.setActionHidden(element.ActionType.REVISION,
             element.RevisionActions.SUBMIT, true);
         assert.lengthOf(element._hiddenActions, 1);
-        flush(function() {
-          var buttonEl = element.$$('[data-action-key="submit"]');
+        flush(() => {
+          const buttonEl = element.$$('[data-action-key="submit"]');
           assert.isNotOk(buttonEl);
 
           element.setActionHidden(element.ActionType.REVISION,
-            element.RevisionActions.SUBMIT, false);
-          flush(function() {
-            var buttonEl = element.$$('[data-action-key="submit"]');
+              element.RevisionActions.SUBMIT, false);
+          flush(() => {
+            const buttonEl = element.$$('[data-action-key="submit"]');
             assert.isOk(buttonEl);
             assert.isFalse(buttonEl.hasAttribute('hidden'));
             done();
@@ -133,66 +182,35 @@
       });
     });
 
-    test('hide menu action', function(done) {
-      flush(function() {
-        var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
-        assert.isOk(buttonEl);
-        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
-        element.setActionHidden(element.ActionType.CHANGE,
-            element.ChangeActions.DELETE, true);
-        assert.lengthOf(element._hiddenActions, 1);
-        element.setActionHidden(element.ActionType.CHANGE,
-            element.ChangeActions.DELETE, true);
-        assert.lengthOf(element._hiddenActions, 1);
-        flush(function() {
-          var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
-          assert.isNotOk(buttonEl);
-
-          element.setActionHidden(element.ActionType.CHANGE,
-            element.RevisionActions.DELETE, false);
-          flush(function() {
-            var buttonEl = element.$.moreActions.$$('span[data-id="delete"]');
-            assert.isOk(buttonEl);
-            done();
-          });
-        });
-      });
-    });
-
-    test('buttons exist', function(done) {
+    test('buttons exist', done => {
       element._loading = false;
-      flush(function() {
-        var buttonEls = Polymer.dom(element.root)
+      flush(() => {
+        const buttonEls = Polymer.dom(element.root)
             .querySelectorAll('gr-button');
-        var menuItems = element.$.moreActions.items;
+        const menuItems = element.$.moreActions.items;
         assert.equal(buttonEls.length + menuItems.length, 6);
         assert.isFalse(element.hidden);
         done();
       });
     });
 
-    test('delete buttons have explicit labels', function(done) {
-      flush(function() {
-        var deleteItems = element.$.moreActions.items.filter(function(item) {
-          return item.id === 'delete';
+    test('delete buttons have explicit labels', done => {
+      flush(() => {
+        const deleteItems = element.$.moreActions.items.filter(item => {
+          return item.id.startsWith('delete');
         });
-        assert.equal(deleteItems.length, 2);
-        assert.notEqual(deleteItems[0].name, deleteItems[1].name);
+        assert.equal(deleteItems.length, 1);
+        assert.notEqual(deleteItems[0].name);
         assert.isTrue(
-            deleteItems[0].name === 'Delete Revision' ||
             deleteItems[0].name === 'Delete Change'
         );
-        assert.isTrue(
-            deleteItems[1].name === 'Delete Revision' ||
-            deleteItems[1].name === 'Delete Change'
-        );
         done();
       });
     });
 
-    test('get revision object from change', function() {
-      var revObj = {_number: 2, foo: 'bar'};
-      var change = {
+    test('get revision object from change', () => {
+      const revObj = {_number: 2, foo: 'bar'};
+      const change = {
         revisions: {
           rev1: {_number: 1},
           rev2: revObj,
@@ -201,23 +219,26 @@
       assert.deepEqual(element._getRevision(change, '2'), revObj);
     });
 
-    test('_actionComparator sort order', function() {
-      var actions = [
+    test('_actionComparator sort order', () => {
+      const actions = [
         {label: '123', __type: 'change', __key: 'review'},
-        {label: 'abc', __type: 'revision'},
+        {label: 'abc-ro', __type: 'revision'},
         {label: 'abc', __type: 'change'},
         {label: 'def', __type: 'change'},
-        {label: 'def', __type: 'change', __primary: true},
+        {label: 'def-p', __type: 'change', __primary: true},
       ];
 
-      var result = actions.slice();
+      const result = actions.slice();
       result.reverse();
-      result.sort(element._actionComparator);
-
+      result.sort(element._actionComparator.bind(element));
       assert.deepEqual(result, actions);
     });
 
-    test('submit change', function(done) {
+    test('submit change', done => {
+      sandbox.stub(element.$.restAPI, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      sandbox.stub(element, 'fetchIsLatestKnown',
+          () => { return Promise.resolve(true); });
       element.change = {
         revisions: {
           rev1: {_number: 1},
@@ -226,44 +247,42 @@
       };
       element.patchNum = '2';
 
-      flush(function() {
-        var submitButton = element.$$('gr-button[data-action-key="submit"]');
+      flush(() => {
+        const submitButton = element.$$('gr-button[data-action-key="submit"]');
         assert.ok(submitButton);
         MockInteractions.tap(submitButton);
 
         // Upon success it should fire the reload-change event.
-        element.addEventListener('reload-change', function(e) {
+        element.addEventListener('reload-change', () => {
           done();
         });
       });
     });
 
-    test('submit change with plugin hook', function(done) {
-      var canSubmitStub = sinon.stub(element, '_canSubmitChange',
-          function() { return false; });
-      var fireActionStub = sinon.stub(element, '_fireAction');
-      flush(function() {
-        var submitButton = element.$$('gr-button[data-action-key="submit"]');
+    test('submit change with plugin hook', done => {
+      sandbox.stub(element, '_canSubmitChange',
+          () => { return false; });
+      const fireActionStub = sandbox.stub(element, '_fireAction');
+      flush(() => {
+        const submitButton = element.$$('gr-button[data-action-key="submit"]');
         assert.ok(submitButton);
         MockInteractions.tap(submitButton);
         assert.equal(fireActionStub.callCount, 0);
 
-        canSubmitStub.restore();
-        fireActionStub.restore();
         done();
       });
     });
 
-    test('chain state', function() {
+    test('chain state', () => {
       assert.equal(element._hasKnownChainState, false);
       element.hasParent = true;
       assert.equal(element._hasKnownChainState, true);
       element.hasParent = false;
     });
 
-    test('_calculateDisabled', function() {
-      var hasKnownChainState = false;
-      var action = {__key: 'rebase', enabled: true};
+    test('_calculateDisabled', () => {
+      let hasKnownChainState = false;
+      const action = {__key: 'rebase', enabled: true};
       assert.equal(
           element._calculateDisabled(action, hasKnownChainState), true);
 
@@ -281,12 +300,12 @@
           element._calculateDisabled(action, hasKnownChainState), true);
     });
 
-    test('rebase change', function(done) {
-      var fireActionStub = sinon.stub(element, '_fireAction');
-      flush(function() {
-        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+    test('rebase change', done => {
+      const fireActionStub = sandbox.stub(element, '_fireAction');
+      flush(() => {
+        const rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
         MockInteractions.tap(rebaseButton);
-        var rebaseAction = {
+        const rebaseAction = {
           __key: 'rebase',
           __type: 'revision',
           __primary: false,
@@ -313,15 +332,14 @@
         assert.deepEqual(fireActionStub.lastCall.args,
           ['/rebase', rebaseAction, true, {base: ''}]);
 
-        fireActionStub.restore();
         done();
       });
     });
 
-    test('two dialogs are not shown at the same time', function(done) {
+    test('two dialogs are not shown at the same time', done => {
       element._hasKnownChainState = true;
-      flush(function() {
-        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
+      flush(() => {
+        const rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
         assert.ok(rebaseButton);
         MockInteractions.tap(rebaseButton);
         flushAsynchronousOperations();
@@ -335,23 +353,162 @@
       });
     });
 
-    suite('cherry-pick', function() {
-      var fireActionStub;
-      var alertStub;
+    test('fullscreen-overlay-opened hides content', () => {
+      sandbox.spy(element, '_handleHideBackgroundContent');
+      element.$.overlay.fire('fullscreen-overlay-opened');
+      assert.isTrue(element._handleHideBackgroundContent.called);
+      assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+    });
 
-      setup(function() {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        alertStub = sinon.stub(window, 'alert');
+    test('fullscreen-overlay-closed shows content', () => {
+      sandbox.spy(element, '_handleShowBackgroundContent');
+      element.$.overlay.fire('fullscreen-overlay-closed');
+      assert.isTrue(element._handleShowBackgroundContent.called);
+      assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+    });
+
+    suite('change edits', () => {
+      let fireActionStub;
+      const deleteEditAction = {
+        enabled: true,
+        label: 'Delete Edit',
+        title: 'Delete change edit',
+        __key: 'deleteEdit',
+        __primary: false,
+        __type: 'change',
+        method: 'DELETE',
+      };
+      const publishEditAction = {
+        enabled: true,
+        label: 'Publish Edit',
+        title: 'Publish change edit',
+        __key: 'publishEdit',
+        __primary: false,
+        __type: 'change',
+        method: 'POST',
+      };
+      const rebaseEditAction = {
+        enabled: true,
+        label: 'Rebase Edit',
+        title: 'Rebase change edit',
+        __key: 'rebaseEdit',
+        __primary: false,
+        __type: 'change',
+        method: 'POST',
+      };
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        element.patchNum = 'edit';
+        element.editLoaded = true;
       });
 
-      teardown(function() {
-        alertStub.restore();
-        fireActionStub.restore();
+      test('does not delete edit on action', () => {
+        element._handleDeleteEditTap();
+        assert.isFalse(fireActionStub.called);
       });
 
-      test('works', function() {
+      test('shows confirm dialog for delete edit', () => {
+        element._handleDeleteEditTap();
+        assert.isFalse(element.$$('#confirmDeleteEditDialog').hidden);
+        assert.ok(element.$$('gr-button[data-action-key="deleteEdit"]'));
+        MockInteractions.tap(
+            element.$$('#confirmDeleteEditDialog').$$('gr-button[primary]'));
+        flushAsynchronousOperations();
+        assert.isTrue(
+            fireActionStub.calledWith('/edit', deleteEditAction, false));
+      });
+
+      test('show publish edit but rebaseEdit is hidden', () => {
+        element.change = {
+          status: 'NEW',
+        };
+        const rebaseEditButton =
+            element.$$('gr-button[data-action-key="rebaseEdit"]');
+        assert.isNotOk(rebaseEditButton);
+
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.ok(publishEditButton);
+        MockInteractions.tap(publishEditButton);
+        element._handlePublishEditTap();
+        flushAsynchronousOperations();
+
+        assert.isTrue(
+            fireActionStub.calledWith('/edit:publish', publishEditAction, false));
+      });
+
+      test('show rebase edit but publishEdit is hidden', () => {
+        element.change = {
+          status: 'NEW',
+        };
+        element.editBasedOnCurrentPatchSet = false;
+
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.isNotOk(publishEditButton);
+
+        const rebaseEditButton =
+            element.$$('gr-button[data-action-key="rebaseEdit"]');
+        assert.ok(rebaseEditButton);
+        MockInteractions.tap(rebaseEditButton);
+        element._handleRebaseEditTap();
+        flushAsynchronousOperations();
+
+        assert.isTrue(
+            fireActionStub.calledWith('/edit:rebase', rebaseEditAction, false));
+      });
+
+      test('hide publishEdit and rebaseEdit if change is not open', () => {
+        element.change = {
+          status: 'MERGED',
+        };
+        flushAsynchronousOperations();
+
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.isNotOk(publishEditButton);
+
+        const rebaseEditButton =
+            element.$$('gr-button[data-action-key="rebaseEdit"]');
+        assert.isNotOk(rebaseEditButton);
+
+        const deleteEditButton =
+            element.$$('gr-button[data-action-key="deleteEdit"]');
+        assert.ok(deleteEditButton);
+      });
+
+      test('do not show delete edit on a non change edit', () => {
+        element.editLoaded = false;
+        flushAsynchronousOperations();
+        const deleteEditButton =
+            element.$$('gr-button[data-action-key="deleteEdit"]');
+        assert.isNotOk(deleteEditButton);
+      });
+
+      test('do not show publish edit on a non change edit', () => {
+        element.change = {
+          status: 'NEW',
+        };
+        element.editLoaded = false;
+        flushAsynchronousOperations();
+        const publishEditButton =
+            element.$$('gr-button[data-action-key="publishEdit"]');
+        assert.isNotOk(publishEditButton);
+      });
+    });
+
+    suite('cherry-pick', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        sandbox.stub(window, 'alert');
+      });
+
+      test('works', () => {
         element._handleCherrypickTap();
-        var action = {
+        const action = {
           __key: 'cherrypick',
           __type: 'revision',
           __primary: false,
@@ -366,7 +523,7 @@
 
         element.$.confirmCherrypick.branch = 'master';
         element._handleCherrypickConfirm();
-        assert.equal(fireActionStub.callCount, 0);  // Still needs a message.
+        assert.equal(fireActionStub.callCount, 0); // Still needs a message.
 
         // Add attributes that are used to determine the message.
         element.$.confirmCherrypick.commitMessage = 'foo message';
@@ -386,8 +543,8 @@
         ]);
       });
 
-      test('branch name cleared when re-open cherrypick', function() {
-        var emptyBranchName = '';
+      test('branch name cleared when re-open cherrypick', () => {
+        const emptyBranchName = '';
         element.$.confirmCherrypick.branch = 'master';
 
         element._handleCherrypickTap();
@@ -395,29 +552,58 @@
       });
     });
 
-    test('custom actions', function(done) {
+    suite('move change', () => {
+      let fireActionStub;
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        sandbox.stub(window, 'alert');
+      });
+
+      test('works', () => {
+        element._handleMoveTap();
+
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 0);
+
+        element.$.confirmMove.branch = 'master';
+        element._handleMoveConfirm();
+        assert.equal(fireActionStub.callCount, 1);
+      });
+
+      test('branch name cleared when re-open move', () => {
+        const emptyBranchName = '';
+        element.$.confirmMove.branch = 'master';
+
+        element._handleMoveTap();
+        assert.equal(element.$.confirmMove.branch, emptyBranchName);
+      });
+    });
+
+    test('custom actions', done => {
       // Add a button with the same key as a server-based one to ensure
       // collisions are taken care of.
-      var key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
-      element.addEventListener(key + '-tap', function(e) {
+      const key = element.addActionButton(element.ActionType.REVISION, 'Bork!');
+      element.addEventListener(key + '-tap', e => {
         assert.equal(e.detail.node.getAttribute('data-action-key'), key);
         element.removeActionButton(key);
-        flush(function() {
+        flush(() => {
           assert.notOk(element.$$('[data-action-key="' + key + '"]'));
           done();
         });
       });
-      flush(function() {
+      flush(() => {
         MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
       });
     });
 
-    test('_setLoadingOnButtonWithKey top-level', function() {
-      var key = 'rebase';
-      var cleanup = element._setLoadingOnButtonWithKey(key);
+    test('_setLoadingOnButtonWithKey top-level', () => {
+      const key = 'rebase';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
       assert.equal(element._actionLoadingMessage, 'Rebasing...');
 
-      var button = element.$$('[data-action-key="' + key + '"]');
+      const button = element.$$('[data-action-key="' + key + '"]');
       assert.isTrue(button.hasAttribute('loading'));
       assert.isTrue(button.disabled);
 
@@ -430,9 +616,10 @@
       assert.isNotOk(element._actionLoadingMessage);
     });
 
-    test('_setLoadingOnButtonWithKey overflow menu', function() {
-      var key = 'cherrypick';
-      var cleanup = element._setLoadingOnButtonWithKey(key);
+    test('_setLoadingOnButtonWithKey overflow menu', () => {
+      const key = 'cherrypick';
+      const type = 'revision';
+      const cleanup = element._setLoadingOnButtonWithKey(type, key);
       assert.equal(element._actionLoadingMessage, 'Cherry-Picking...');
       assert.include(element._disabledMenuActions, 'cherrypick');
       assert.isFunction(cleanup);
@@ -443,13 +630,13 @@
       assert.notInclude(element._disabledMenuActions, 'cherrypick');
     });
 
-    suite('revert change', function() {
-      var alertStub;
-      var fireActionStub;
+    suite('revert change', () => {
+      let alertStub;
+      let fireActionStub;
 
-      setup(function() {
-        fireActionStub = sinon.stub(element, '_fireAction');
-        alertStub = sinon.stub(window, 'alert');
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
+        alertStub = sandbox.stub(window, 'alert');
         element.actions = {
           revert: {
             method: 'POST',
@@ -461,48 +648,39 @@
         return element.reload();
       });
 
-      teardown(function() {
-        alertStub.restore();
-        fireActionStub.restore();
-      });
-
-      test('revert change with plugin hook', function(done) {
+      test('revert change with plugin hook', done => {
         element.change = {
           current_revision: 'abc1234',
         };
-        var newRevertMsg = 'Modified revert msg';
-        var modifyRevertMsgStub = sinon.stub(element, '_modifyRevertMsg',
-            function() { return newRevertMsg; });
-        var populateRevertMsgStub = sinon.stub(
-            element.$.confirmRevertDialog, 'populateRevertMessage',
-            function() { return 'original msg'; });
-        flush(function() {
-          var revertButton = element.$$('gr-button[data-action-key="revert"]');
+        const newRevertMsg = 'Modified revert msg';
+        sandbox.stub(element, '_modifyRevertMsg',
+            () => { return newRevertMsg; });
+        sandbox.stub(element.$.confirmRevertDialog, 'populateRevertMessage',
+            () => { return 'original msg'; });
+        flush(() => {
+          const revertButton =
+              element.$$('gr-button[data-action-key="revert"]');
           MockInteractions.tap(revertButton);
 
           assert.equal(element.$.confirmRevertDialog.message, newRevertMsg);
-
-          populateRevertMsgStub.restore();
-          modifyRevertMsgStub.restore();
           done();
         });
       });
 
-      test('works', function() {
+      test('works', () => {
         element.change = {
           current_revision: 'abc1234',
         };
-        var populateRevertMsgStub = sinon.stub(
-            element.$.confirmRevertDialog, 'populateRevertMessage',
-            function() { return 'original msg'; });
-        var revertButton = element.$$('gr-button[data-action-key="revert"]');
+        sandbox.stub(element.$.confirmRevertDialog, 'populateRevertMessage',
+            () => { return 'original msg'; });
+        const revertButton = element.$$('gr-button[data-action-key="revert"]');
         MockInteractions.tap(revertButton);
 
         element.$.confirmRevertDialog.message = 'foo message';
         element._handleRevertDialogConfirm();
         assert.notOk(alertStub.called);
 
-        var action = {
+        const action = {
           __key: 'revert',
           __type: 'change',
           __primary: false,
@@ -515,16 +693,109 @@
           '/revert', action, false, {
             message: 'foo message',
           }]);
-        populateRevertMsgStub.restore();
       });
     });
 
-    suite('delete change', function() {
-      var fireActionStub;
-      var deleteAction;
+    suite('mark change private', () => {
+      setup(() => {
+        const privateAction = {
+          __key: 'private',
+          __type: 'change',
+          __primary: false,
+          method: 'POST',
+          label: 'Mark private',
+          title: 'Working...',
+          enabled: true,
+        };
 
-      setup(function() {
-        fireActionStub = sinon.stub(element, '_fireAction');
+        element.actions = {
+          private: privateAction,
+        };
+
+        element.change.is_private = false;
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        return element.reload();
+      });
+
+      test('make sure the mark private change button is not outside of the ' +
+           'overflow menu', done => {
+        flush(() => {
+          assert.isNotOk(element.$$('[data-action-key="private"]'));
+          done();
+        });
+      });
+
+      test('private change', done => {
+        flush(() => {
+          assert.isOk(
+              element.$.moreActions.$$('span[data-id="private-change"]'));
+          element.setActionOverflow('change', 'private', false);
+          flushAsynchronousOperations();
+          assert.isOk(element.$$('[data-action-key="private"]'));
+          assert.isNotOk(
+              element.$.moreActions.$$('span[data-id="private-change"]'));
+          done();
+        });
+      });
+    });
+
+    suite('unmark private change', () => {
+      setup(() => {
+        const unmarkPrivateAction = {
+          __key: 'private.delete',
+          __type: 'change',
+          __primary: false,
+          method: 'POST',
+          label: 'Unmark private',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          'private.delete': unmarkPrivateAction,
+        };
+
+        element.change.is_private = true;
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        return element.reload();
+      });
+
+      test('make sure the unmark private change button is not outside of the ' +
+           'overflow menu', done => {
+        flush(() => {
+          assert.isNotOk(element.$$('[data-action-key="private.delete"]'));
+          done();
+        });
+      });
+
+      test('unmark the private change', done => {
+        flush(() => {
+          assert.isOk(
+              element.$.moreActions.$$('span[data-id="private.delete-change"]')
+          );
+          element.setActionOverflow('change', 'private.delete', false);
+          flushAsynchronousOperations();
+          assert.isOk(element.$$('[data-action-key="private.delete"]'));
+          assert.isNotOk(
+              element.$.moreActions.$$('span[data-id="private.delete-change"]')
+          );
+          done();
+        });
+      });
+    });
+
+    suite('delete change', () => {
+      let fireActionStub;
+      let deleteAction;
+
+      setup(() => {
+        fireActionStub = sandbox.stub(element, '_fireAction');
         element.change = {
           current_revision: 'abc1234',
         };
@@ -539,16 +810,12 @@
         };
       });
 
-      teardown(function() {
-        fireActionStub.restore();
-      });
-
-      test('does not delete on action', function() {
+      test('does not delete on action', () => {
         element._handleDeleteTap();
         assert.isFalse(fireActionStub.called);
       });
 
-      test('shows confirm dialog', function() {
+      test('shows confirm dialog', () => {
         element._handleDeleteTap();
         assert.isFalse(element.$$('#confirmDeleteDialog').hidden);
         MockInteractions.tap(
@@ -557,7 +824,7 @@
         assert.isTrue(fireActionStub.calledWith('/', deleteAction, false));
       });
 
-      test('hides delete confirm on cancel', function() {
+      test('hides delete confirm on cancel', () => {
         element._handleDeleteTap();
         MockInteractions.tap(
             element.$$('#confirmDeleteDialog').$$('gr-button:not([primary])'));
@@ -567,8 +834,167 @@
       });
     });
 
-    suite('quick approve', function() {
-      setup(function() {
+    suite('ignore change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const IgnoreAction = {
+          __key: 'ignore',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Ignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          ignore: IgnoreAction,
+        };
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('make sure the ignore button is not outside of the overflow menu',
+          () => {
+            assert.isNotOk(element.$$('[data-action-key="ignore"]'));
+          });
+
+      test('ignoring change', () => {
+        assert.isOk(element.$.moreActions.$$('span[data-id="ignore-change"]'));
+        element.setActionOverflow('change', 'ignore', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="ignore"]'));
+        assert.isNotOk(
+            element.$.moreActions.$$('span[data-id="ignore-change"]'));
+      });
+    });
+
+    suite('unignore change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const UnignoreAction = {
+          __key: 'unignore',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Unignore',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unignore: UnignoreAction,
+        };
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+
+      test('unignore button is not outside of the overflow menu', () => {
+        assert.isNotOk(element.$$('[data-action-key="unignore"]'));
+      });
+
+      test('unignoring change', () => {
+        assert.isOk(
+            element.$.moreActions.$$('span[data-id="unignore-change"]'));
+        element.setActionOverflow('change', 'unignore', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="unignore"]'));
+        assert.isNotOk(
+            element.$.moreActions.$$('span[data-id="unignore-change"]'));
+      });
+    });
+
+    suite('reviewed change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const ReviewedAction = {
+          __key: 'reviewed',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Mark reviewed',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          reviewed: ReviewedAction,
+        };
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+      test('make sure the reviewed button is not outside of the overflow menu',
+          () => {
+            assert.isNotOk(element.$$('[data-action-key="reviewed"]'));
+          });
+
+      test('reviewing change', () => {
+        assert.isOk(
+            element.$.moreActions.$$('span[data-id="reviewed-change"]'));
+        element.setActionOverflow('change', 'reviewed', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="reviewed"]'));
+        assert.isNotOk(
+            element.$.moreActions.$$('span[data-id="reviewed-change"]'));
+      });
+    });
+
+    suite('unreviewed change', () => {
+      setup(done => {
+        sandbox.stub(element, '_fireAction');
+
+        const UnreviewedAction = {
+          __key: 'unreviewed',
+          __type: 'change',
+          __primary: false,
+          method: 'PUT',
+          label: 'Mark unreviewed',
+          title: 'Working...',
+          enabled: true,
+        };
+
+        element.actions = {
+          unreviewed: UnreviewedAction,
+        };
+
+        element.changeNum = '2';
+        element.patchNum = '2';
+
+        element.reload().then(() => { flush(done); });
+      });
+
+
+      test('unreviewed button not outside of the overflow menu', () => {
+        assert.isNotOk(element.$$('[data-action-key="unreviewed"]'));
+      });
+
+      test('unreviewed change', () => {
+        assert.isOk(
+            element.$.moreActions.$$('span[data-id="unreviewed-change"]'));
+        element.setActionOverflow('change', 'unreviewed', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="unreviewed"]'));
+        assert.isNotOk(
+            element.$.moreActions.$$('span[data-id="unreviewed-change"]'));
+      });
+    });
+
+    suite('quick approve', () => {
+      setup(() => {
         element.change = {
           current_revision: 'abc1234',
         };
@@ -590,17 +1016,18 @@
         flushAsynchronousOperations();
       });
 
-      test('added when can approve', function() {
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+      test('added when can approve', () => {
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.isNotNull(approveButton);
       });
 
-      test('is first in list of actions', function() {
-        var approveButton = element.$$('gr-button');
+      test('is first in list of actions', () => {
+        const approveButton = element.$$('gr-button');
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
-      test('not added when already approved', function() {
+      test('not added when already approved', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -614,11 +1041,12 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
-      test('not added when label not permitted', function() {
+      test('not added when label not permitted', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -629,23 +1057,23 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
-      test('approves when taped', function() {
-        var fireActionStub = sinon.stub(element, '_fireAction');
+      test('approves when tapped', () => {
+        const fireActionStub = sandbox.stub(element, '_fireAction');
         MockInteractions.tap(
             element.$$('gr-button[data-action-key=\'review\']'));
         flushAsynchronousOperations();
         assert.isTrue(fireActionStub.called);
         assert.isTrue(fireActionStub.calledWith('/review'));
-        var payload = fireActionStub.lastCall.args[3];
+        const payload = fireActionStub.lastCall.args[3];
         assert.deepEqual(payload.labels, {foo: '+1'});
-        fireActionStub.restore();
       });
 
-      test('not added when multiple labels are required', function() {
+      test('not added when multiple labels are required', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -658,11 +1086,12 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
-      test('button label for missing approval', function() {
+      test('button label for missing approval', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -680,11 +1109,12 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.equal(approveButton.getAttribute('data-label'), 'foo+1');
       });
 
-      test('no quick approve if score is not maximal for a label', function() {
+      test('no quick approve if score is not maximal for a label', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -702,11 +1132,12 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.isNull(approveButton);
       });
 
-      test('approving label with a non-max score', function() {
+      test('approving label with a non-max score', () => {
         element.change = {
           current_revision: 'abc1234',
           labels: {
@@ -724,9 +1155,164 @@
           },
         };
         flushAsynchronousOperations();
-        var approveButton = element.$$('gr-button[data-action-key=\'review\']');
+        const approveButton =
+            element.$$('gr-button[data-action-key=\'review\']');
         assert.equal(approveButton.getAttribute('data-label'), 'bar+2');
       });
     });
+
+    test('adds download revision action', () => {
+      const handler = sandbox.stub();
+      element.addEventListener('download-tap', handler);
+      assert.ok(element.revisionActions.download);
+      element._handleDownloadTap();
+      flushAsynchronousOperations();
+
+      assert.isTrue(handler.called);
+    });
+
+    test('changing changeNum or patchNum does not reload', () => {
+      const reloadStub = sandbox.stub(element, 'reload');
+      element.changeNum = 123;
+      assert.isFalse(reloadStub.called);
+      element.patchNum = 456;
+      assert.isFalse(reloadStub.called);
+    });
+
+    suite('setActionOverflow', () => {
+      test('move action from overflow', () => {
+        assert.isNotOk(element.$$('[data-action-key="cherrypick"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+        element.setActionOverflow('revision', 'cherrypick', false);
+        flushAsynchronousOperations();
+        assert.isOk(element.$$('[data-action-key="cherrypick"]'));
+        assert.notEqual(
+            element.$.moreActions.items[0].id, 'cherrypick-revision');
+      });
+
+      test('move action to overflow', () => {
+        assert.isOk(element.$$('[data-action-key="submit"]'));
+        element.setActionOverflow('revision', 'submit', true);
+        flushAsynchronousOperations();
+        assert.isNotOk(element.$$('[data-action-key="submit"]'));
+        assert.strictEqual(
+            element.$.moreActions.items[3].id, 'submit-revision');
+      });
+
+      suite('_waitForChangeReachable', () => {
+        setup(() => {
+          sandbox.stub(element, 'async', fn => fn());
+        });
+
+        const makeGetChange = numTries => {
+          return () => {
+            if (numTries === 1) {
+              return Promise.resolve({_number: 123});
+            } else {
+              numTries--;
+              return Promise.resolve(undefined);
+            }
+          };
+        };
+
+        test('succeed', () => {
+          sandbox.stub(element.$.restAPI, 'getChange', makeGetChange(5));
+          return element._waitForChangeReachable(123).then(success => {
+            assert.isTrue(success);
+          });
+        });
+
+        test('fail', () => {
+          sandbox.stub(element.$.restAPI, 'getChange', makeGetChange(6));
+          return element._waitForChangeReachable(123).then(success => {
+            assert.isFalse(success);
+          });
+        });
+      });
+    });
+
+    suite('_send', () => {
+      let cleanup;
+      let payload;
+      let onShowAlert;
+
+      setup(() => {
+        cleanup = sinon.stub();
+        element.changeNum = 42;
+        element.patchNum = 12;
+        payload = {foo: 'bar'};
+
+        onShowAlert = sinon.stub();
+        element.addEventListener('show-alert', onShowAlert);
+      });
+
+      suite('happy path', () => {
+        let sendStub;
+
+        setup(() => {
+          sandbox.stub(element, 'fetchIsLatestKnown')
+              .returns(Promise.resolve(true));
+          sendStub = sandbox.stub(element.$.restAPI, 'getChangeURLAndSend')
+              .returns(Promise.resolve({}));
+        });
+
+        test('change action', () => {
+          return element._send('DELETE', payload, '/endpoint', false, cleanup)
+              .then(() => {
+                assert.isFalse(onShowAlert.called);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isTrue(sendStub.calledWith(42, 'DELETE', null,
+                    '/endpoint', payload));
+              });
+        });
+
+        test('revision action', () => {
+          return element._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isFalse(onShowAlert.called);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isTrue(sendStub.calledWith(42, 'DELETE', 12, '/endpoint',
+                    payload));
+              });
+        });
+      });
+
+      suite('failure modes', () => {
+        test('non-latest', () => {
+          sandbox.stub(element, 'fetchIsLatestKnown')
+              .returns(Promise.resolve(false));
+          const sendStub = sandbox.stub(element.$.restAPI,
+              'getChangeURLAndSend');
+
+          return element._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isTrue(onShowAlert.calledOnce);
+                assert.isTrue(cleanup.calledOnce);
+                assert.isFalse(sendStub.called);
+              });
+        });
+
+        test('send fails', () => {
+          sandbox.stub(element, 'fetchIsLatestKnown')
+              .returns(Promise.resolve(true));
+          const sendStub = sandbox.stub(element.$.restAPI,
+              'getChangeURLAndSend',
+              (num, method, patchNum, endpoint, payload, onErr) => {
+                onErr();
+                return Promise.resolve(null);
+              });
+          const handleErrorStub = sandbox.stub(element, '_handleResponseError');
+
+          return element._send('DELETE', payload, '/endpoint', true, cleanup)
+              .then(() => {
+                assert.isFalse(onShowAlert.called);
+                assert.isTrue(cleanup.called);
+                assert.isTrue(sendStub.calledOnce);
+                assert.isTrue(handleErrorStub.called);
+              });
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
new file mode 100644
index 0000000..90776c0
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata-it_test.html
@@ -0,0 +1,117 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-change-metadata</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../../plugins/gr-plugin-host/gr-plugin-host.html">
+<link rel="import" href="gr-change-metadata.html">
+
+<script>void(0);</script>
+
+<test-fixture id="element">
+  <template>
+    <gr-change-metadata></gr-change-metadata>
+  </template>
+</test-fixture>
+
+<test-fixture id="plugin-host">
+  <template>
+    <gr-plugin-host></gr-plugin-host>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-change-metadata integration tests', () => {
+    let sandbox;
+    let element;
+
+    const sectionSelectors = [
+      'section.assignee',
+      'section.labelStatus',
+      'section.strategy',
+      'section.topic',
+    ];
+
+    const getStyle = function(selector, name) {
+      return window.getComputedStyle(
+          Polymer.dom(element.root).querySelector(selector))[name];
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-change-metadata', {
+        _computeShowLabelStatus() { return true; },
+        _computeShowReviewersByState() { return true; },
+        ready() {
+          this.change = {labels: [], status: 'NEW'};
+          this.serverConfig = {};
+        },
+      });
+    });
+
+    teardown(() => {
+      Gerrit._pluginsPending = -1;
+      Gerrit._allPluginsPromise = undefined;
+      sandbox.restore();
+    });
+
+    suite('by default', () => {
+      setup(done => {
+        element = fixture('element');
+        flush(done);
+      });
+
+      for (const sectionSelector of sectionSelectors) {
+        test(sectionSelector + ' does not have display: none', () => {
+          assert.notEqual(getStyle(sectionSelector, 'display'), 'none');
+        });
+      }
+    });
+
+    suite('with plugin style', () => {
+      setup(done => {
+        const pluginHost = fixture('plugin-host');
+        pluginHost.config = {
+          plugin: {
+            js_resource_paths: [],
+            html_resource_paths: [
+              new URL('test/plugin.html?' + Math.random(),
+                      window.location.href).toString(),
+            ],
+          },
+        };
+        element = fixture('element');
+        const importSpy = sandbox.spy(element.$.externalStyle, '_import');
+        Gerrit.awaitPluginsLoaded().then(() => {
+          Promise.all(importSpy.returnValues).then(() => {
+            flush(done);
+          });
+        });
+      });
+
+      for (const sectionSelector of sectionSelectors) {
+        test(sectionSelector + ' may have display: none', () => {
+          assert.equal(getStyle(sectionSelector, 'display'), 'none');
+        });
+      }
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 78bcb9a..9553d1e 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -14,20 +14,26 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../plugins/gr-external-style/gr-external-style.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
-<link rel="import" href="../../shared/gr-label/gr-label.html">
+<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-linked-chip/gr-linked-chip.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-reviewer-list/gr-reviewer-list.html">
 
 <dom-module id="gr-change-metadata">
   <template>
-    <style>
+    <style include="shared-styles">
+      .hideDisplay {
+        display: none;
+      }
       section:not(:first-of-type) {
         margin-top: 1em;
       }
@@ -37,8 +43,9 @@
       }
       .title {
         color: #666;
-        font-weight: bold;
-        white-space: nowrap;
+        font-family: var(--font-family-bold);
+        max-width: 20em;
+        word-break: break-word;
       }
       gr-account-link {
         max-width: 20ch;
@@ -69,12 +76,43 @@
       .notApproved {
         background-color: #ffd4d4;
       }
-      .labelStatus {
+      .labelStatus .value {
         max-width: 9em;
       }
+      .labelStatus li {
+        list-style-type: disc;
+      }
       .webLink {
         display: block;
       }
+      #missingLabels {
+        padding-left: 1.5em;
+      }
+
+      /* CSS Mixins should be applied last. */
+      section.assignee {
+        @apply(--change-metadata-assignee);
+      }
+      section.labelStatus {
+        @apply(--change-metadata-label-status);
+      }
+      section.strategy {
+        @apply(--change-metadata-strategy);
+      }
+      section.topic {
+        @apply(--change-metadata-topic);
+      }
+      gr-account-chip([disabled]),
+      gr-linked-chip([disabled]) {
+        opacity: 0;
+        pointer-events: none;
+      }
+      .hashtagChip {
+        margin-bottom: .5em;
+      }
+      #externalStyle {
+        display: block;
+      }
       @media screen and (max-width: 50em), screen and (min-width: 75em) {
         :host {
           display: table;
@@ -89,162 +127,203 @@
         .title,
         .value {
           display: table-cell;
-          vertical-align: top;
         }
         .title {
           padding-right: .5em;
         }
       }
     </style>
-    <section>
-      <span class="title">Updated</span>
-      <span class="value">
-        <gr-date-formatter
-            has-tooltip
-            date-str="[[change.updated]]"></gr-date-formatter>
-      </span>
-    </section>
-    <section>
-      <span class="title">Owner</span>
-      <span class="value">
-        <gr-account-link account="[[change.owner]]"></gr-account-link>
-      </span>
-    </section>
-    <template is="dom-if" if="[[_showReviewersByState]]">
+    <gr-external-style id="externalStyle" name="change-metadata">
       <section>
+        <span class="title">Updated</span>
+        <span class="value">
+          <gr-date-formatter
+              has-tooltip
+              date-str="[[change.updated]]"></gr-date-formatter>
+        </span>
+      </section>
+      <section>
+        <span class="title">Owner</span>
+        <span class="value">
+          <gr-account-link account="[[change.owner]]"></gr-account-link>
+        </span>
+      </section>
+      <section class$="[[_computeShowUploaderHide(change)]]">
+        <span class="title">Uploader</span>
+        <span class="value">
+          <gr-account-link
+              account="[[_computeShowUploader(change)]]"></gr-account-link>
+        </span>
+      </section>
+      <section class="assignee">
         <span class="title">Assignee</span>
         <span class="value">
           <gr-account-list
               max-count="1"
               id="assigneeValue"
-              placeholder="Add assignee..."
+              placeholder="Set assignee..."
               accounts="{{_assignee}}"
               change="[[change]]"
-              readonly="[[!mutable]]"
+              readonly="[[_computeAssigneeReadOnly(mutable, change)]]"
               allow-any-user></gr-account-list>
         </span>
       </section>
+      <template is="dom-if" if="[[_showReviewersByState]]">
+        <section>
+          <span class="title">Reviewers</span>
+          <span class="value">
+            <gr-reviewer-list
+                change="{{change}}"
+                mutable="[[mutable]]"
+                reviewers-only></gr-reviewer-list>
+          </span>
+        </section>
+        <section>
+          <span class="title">CC</span>
+          <span class="value">
+            <gr-reviewer-list
+                change="{{change}}"
+                mutable="[[mutable]]"
+                ccs-only
+                max-reviewers-displayed="5"></gr-reviewer-list>
+          </span>
+        </section>
+      </template>
+      <template is="dom-if" if="[[!_showReviewersByState]]">
+        <section>
+          <span class="title">Reviewers</span>
+          <span class="value">
+            <gr-reviewer-list
+                change="{{change}}"
+                mutable="[[mutable]]"></gr-reviewer-list>
+          </span>
+        </section>
+      </template>
       <section>
-        <span class="title">Reviewers</span>
+        <span class="title">Project</span>
         <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[mutable]]"
-              reviewers-only></gr-reviewer-list>
+          <a href$="[[_computeProjectURL(change.project)]]">[[change.project]]</a>
         </span>
       </section>
       <section>
-        <span class="title">CC</span>
+        <span class="title">Branch</span>
         <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[mutable]]"
-              ccs-only></gr-reviewer-list>
+          <a href$="[[_computeBranchURL(change.project, change.branch)]]">[[change.branch]]</a>
         </span>
       </section>
-    </template>
-    <template is="dom-if" if="[[!_showReviewersByState]]">
-      <section>
-        <span class="title">Assignee</span>
+      <section class="topic">
+        <span class="title">Topic</span>
         <span class="value">
-          <gr-account-list
-              max-count="1"
-              id="assigneeValue"
-              placeholder="Add assignee..."
-              accounts="{{_assignee}}"
-              change="[[change]]"
-              readonly="[[!mutable]]"
-              allow-any-user></gr-account-list>
-        </span>
-      </section>
-      <section>
-        <span class="title">Reviewers</span>
-        <span class="value">
-          <gr-reviewer-list
-              change="{{change}}"
-              mutable="[[mutable]]"></gr-reviewer-list>
-        </span>
-      </section>
-    </template>
-    <section>
-      <span class="title">Project</span>
-      <span class="value">[[change.project]]</span>
-    </section>
-    <section>
-      <span class="title">Branch</span>
-      <span class="value">[[change.branch]]</span>
-    </section>
-    <section>
-      <span class="title">Topic</span>
-      <span class="value">
-        <template is="dom-if" if="[[change.topic]]">
-          <gr-linked-chip
-              text="[[change.topic]]"
-              href="[[_computeTopicHref(change.topic)]]"
-              removable="[[!_topicReadOnly]]"
-              on-remove="_handleTopicRemoved"></gr-linked-chip>
-        </template>
-        <template is="dom-if" if="[[!change.topic]]">
-          <gr-editable-label
-              value="{{change.topic}}"
-              placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
-              read-only="[[_topicReadOnly]]"
-              on-changed="_handleTopicChanged"></gr-editable-label>
-        </template>
-      </span>
-    </section>
-    <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
-      <span class="title">Strategy</span>
-      <span class="value">[[_computeStrategy(change)]]</span>
-    </section>
-    <template is="dom-repeat"
-        items="[[_computeLabelNames(change.labels)]]" as="labelName">
-      <section>
-        <span class="title">[[labelName]]</span>
-        <span class="value">
-          <template is="dom-repeat"
-              items="[[_computeLabelValues(labelName, change.labels.*)]]"
-              as="label">
-            <div class="labelValueContainer">
-              <span class$="[[label.className]]">
-                <gr-label
-                    has-tooltip
-                    title="[[_computeValueTooltip(label.value, labelName)]]"
-                    class="labelValue">
-                  [[label.value]]
-                </gr-label>
-                <gr-account-chip
-                    account="[[label.account]]"
-                    data-account-id$="[[label.account._account_id]]"
-                    label-name="[[labelName]]"
-                    removable="[[_computeCanDeleteVote(label.account, mutable)]]"
-                    transparent-background
-                    on-remove="_onDeleteVote"></gr-account-chip>
-              </span>
-            </div>
+          <template is="dom-if" if="[[change.topic]]">
+            <gr-linked-chip
+                text="[[change.topic]]"
+                limit="40"
+                href="[[_computeTopicURL(change.topic)]]"
+                removable="[[!_topicReadOnly]]"
+                on-remove="_handleTopicRemoved"></gr-linked-chip>
+          </template>
+          <template is="dom-if" if="[[!change.topic]]">
+            <gr-editable-label
+                uppercase
+                label-text="Add a topic"
+                value="[[change.topic]]"
+                placeholder="[[_computeTopicPlaceholder(_topicReadOnly)]]"
+                read-only="[[_topicReadOnly]]"
+                on-changed="_handleTopicChanged"></gr-editable-label>
           </template>
         </span>
       </section>
-    </template>
-    <template is="dom-if" if="[[_showLabelStatus]]">
-      <section>
-        <span class="title">Label Status</span>
-        <span class="value labelStatus">
-          [[_computeSubmitStatus(change.labels)]]
+      <section class="strategy" hidden$="[[_computeHideStrategy(change)]]" hidden>
+        <span class="title">Strategy</span>
+        <span class="value">[[_computeStrategy(change)]]</span>
+      </section>
+      <template is="dom-if" if="[[serverConfig.note_db_enabled]]">
+        <section class="hashtag">
+          <span class="title">Hashtags</span>
+          <span class="value">
+            <template is="dom-repeat" items="[[change.hashtags]]">
+              <gr-linked-chip
+                  class="hashtagChip"
+                  text="[[item]]"
+                  href="[[_computeHashtagURL(item)]]"
+                  removable="[[!_hashtagReadOnly]]"
+                  on-remove="_handleHashtagRemoved">
+              </gr-linked-chip>
+            </template>
+            <gr-editable-label
+                uppercase
+                label-text="Add a hashtag"
+                value="{{_newHashtag}}"
+                placeholder="[[_computeHashtagPlaceholder(_hashtagReadOnly)]]"
+                read-only="[[_hashtagReadOnly]]"
+                on-changed="_handleHashtagChanged"></gr-editable-label>
+          </span>
+        </section>
+      </template>
+      <template is="dom-repeat"
+          items="[[_computeLabelNames(change.labels)]]" as="labelName">
+        <section>
+          <span class="title">[[labelName]]</span>
+          <span class="value">
+            <template is="dom-repeat"
+                items="[[_computeLabelValues(labelName, change.labels.*)]]"
+                as="label">
+              <div class="labelValueContainer">
+                <span class$="[[label.className]]">
+                  <gr-label
+                      has-tooltip
+                      title="[[_computeValueTooltip(change, label.value, labelName)]]"
+                      class="labelValue">
+                    [[label.value]]
+                  </gr-label>
+                  <gr-account-chip
+                      account="[[label.account]]"
+                      data-account-id$="[[label.account._account_id]]"
+                      label-name="[[labelName]]"
+                      removable="[[_computeCanDeleteVote(label.account, mutable)]]"
+                      transparent-background
+                      on-remove="_onDeleteVote"></gr-account-chip>
+                </span>
+              </div>
+            </template>
+          </span>
+        </section>
+      </template>
+      <template is="dom-if" if="[[_showLabelStatus]]">
+        <section class="labelStatus">
+          <span class="title">Label Status</span>
+          <span class="value">
+            <div hidden$="[[!_isWip]]">
+              Work in progress
+            </div>
+            <div hidden$="[[!_showMissingLabels(change.labels)]]">
+              [[_computeMissingLabelsHeader(change.labels)]]
+              <ul id="missingLabels">
+                <template
+                    is="dom-repeat"
+                    items="[[_computeMissingLabels(change.labels)]]">
+                  <li>[[item]]</li>
+                </template>
+              </ul>
+            </div>
+            <div hidden$="[[_showMissingRequirements(change.labels, _isWip)]]">
+              Ready to submit
+            </div>
+          </span>
+        </section>
+      </template>
+      <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo)]]">
+        <span class="title">Links</span>
+        <span class="value">
+          <template is="dom-repeat"
+              items="[[_computeWebLinks(commitInfo)]]" as="link">
+            <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
+              [[link.name]]
+            </a>
+          </template>
         </span>
       </section>
-    </template>
-    <section id="webLinks" hidden$="[[!_computeWebLinks(commitInfo)]]">
-      <span class="title">Links</span>
-      <span class="value">
-        <template is="dom-repeat"
-            items="[[_computeWebLinks(commitInfo)]]" as="link">
-          <a href="[[link.url]]" class="webLink" rel="noopener" target="_blank">
-            [[link.name]]
-          </a>
-        </template>
-      </span>
-    </section>
+    </gr-external-style>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-change-metadata.js"></script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 57e25f8..e95c494 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -14,26 +14,43 @@
 (function() {
   'use strict';
 
-  var SubmitTypeLabel = {
+  const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
+
+  const SubmitTypeLabel = {
     FAST_FORWARD_ONLY: 'Fast Forward Only',
     MERGE_IF_NECESSARY: 'Merge if Necessary',
     REBASE_IF_NECESSARY: 'Rebase if Necessary',
     MERGE_ALWAYS: 'Always Merge',
+    REBASE_ALWAYS: 'Rebase Always',
     CHERRY_PICK: 'Cherry Pick',
   };
 
   Polymer({
     is: 'gr-change-metadata',
 
+    /**
+     * Fired when the change topic is changed.
+     *
+     * @event topic-changed
+     */
+
     properties: {
+      /** @type {?} */
       change: Object,
       commitInfo: Object,
       mutable: Boolean,
+      /**
+       * @type {{ note_db_enabled: string }}
+       */
       serverConfig: Object,
       _topicReadOnly: {
         type: Boolean,
         computed: '_computeTopicReadOnly(mutable, change)',
       },
+      _hashtagReadOnly: {
+        type: Boolean,
+        computed: '_computeHashtagReadOnly(mutable, change)',
+      },
       _showReviewersByState: {
         type: Boolean,
         computed: '_computeShowReviewersByState(serverConfig)',
@@ -44,10 +61,14 @@
       },
 
       _assignee: Array,
+      _isWip: {
+        type: Boolean,
+        computed: '_computeIsWip(change)',
+      },
+      _newHashtag: String,
     },
 
     behaviors: [
-      Gerrit.BaseUrlBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
@@ -56,15 +77,15 @@
       '_assigneeChanged(_assignee.*)',
     ],
 
-    _changeChanged: function(change) {
+    _changeChanged(change) {
       this._assignee = change.assignee ? [change.assignee] : [];
     },
 
-    _assigneeChanged: function(assigneeRecord) {
+    _assigneeChanged(assigneeRecord) {
       if (!this.change) { return; }
-      var assignee = assigneeRecord.base;
+      const assignee = assigneeRecord.base;
       if (assignee.length) {
-        var acct = assignee[0];
+        const acct = assignee[0];
         if (this.change.assignee &&
             acct._account_id === this.change.assignee._account_id) { return; }
         this.set(['change', 'assignee'], acct);
@@ -76,7 +97,7 @@
       }
     },
 
-    _computeHideStrategy: function(change) {
+    _computeHideStrategy(change) {
       return !this.changeIsOpen(change.status);
     },
 
@@ -84,7 +105,7 @@
      * This is a whitelist of web link types that provide direct links to
      * the commit in the url property.
      */
-    _isCommitWebLink: function(link) {
+    _isCommitWebLink(link) {
       return link.name === 'gitiles' || link.name === 'gitweb';
     },
 
@@ -94,34 +115,34 @@
      * an existential check can be used to hide or show the webLinks
      * section.
      */
-    _computeWebLinks: function(commitInfo) {
-      if (!commitInfo || !commitInfo.web_links) { return null }
+    _computeWebLinks(commitInfo) {
+      if (!commitInfo || !commitInfo.web_links) { return null; }
       // We are already displaying these types of links elsewhere,
       // don't include in the metadata links section.
-      var webLinks = commitInfo.web_links.filter(
-          function(l) {return !this._isCommitWebLink(l); }.bind(this));
+      const webLinks = commitInfo.web_links.filter(
+          l => { return !this._isCommitWebLink(l); });
 
       return webLinks.length ? webLinks : null;
     },
 
-    _computeStrategy: function(change) {
+    _computeStrategy(change) {
       return SubmitTypeLabel[change.submit_type];
     },
 
-    _computeLabelNames: function(labels) {
+    _computeLabelNames(labels) {
       return Object.keys(labels).sort();
     },
 
-    _computeLabelValues: function(labelName, _labels) {
-      var result = [];
-      var labels = _labels.base;
-      var t = labels[labelName];
+    _computeLabelValues(labelName, _labels) {
+      const result = [];
+      const labels = _labels.base;
+      const t = labels[labelName];
       if (!t) { return result; }
-      var approvals = t.all || [];
-      approvals.forEach(function(label) {
+      const approvals = t.all || [];
+      for (const label of approvals) {
         if (label.value && label.value != labels[labelName].default_value) {
-          var labelClassName;
-          var labelValPrefix = '';
+          let labelClassName;
+          let labelValPrefix = '';
           if (label.value > 0) {
             labelValPrefix = '+';
             labelClassName = 'approved';
@@ -134,29 +155,69 @@
             account: label,
           });
         }
-      });
+      }
       return result;
     },
 
-    _computeValueTooltip: function(score, labelName) {
-      var values = this.change.labels[labelName].values;
-      return values[score];
+    _computeValueTooltip(change, score, labelName) {
+      if (!change.labels[labelName] ||
+          !change.labels[labelName].values ||
+          !change.labels[labelName].values[score]) { return ''; }
+      return change.labels[labelName].values[score];
     },
 
-    _handleTopicChanged: function(e, topic) {
+    _handleTopicChanged(e, topic) {
+      const lastTopic = this.change.topic;
       if (!topic.length) { topic = null; }
-      this.$.restAPI.setChangeTopic(this.change._number, topic);
+      this.$.restAPI.setChangeTopic(this.change._number, topic)
+          .then(newTopic => {
+            this.set(['change', 'topic'], newTopic);
+            if (newTopic !== lastTopic) {
+              this.dispatchEvent(
+                  new CustomEvent('topic-changed', {bubbles: true}));
+            }
+          });
     },
 
-    _computeTopicReadOnly: function(mutable, change) {
+    _handleHashtagChanged(e) {
+      const lastHashtag = this.change.hashtag;
+      if (!this._newHashtag.length) { return; }
+      this.$.restAPI.setChangeHashtag(
+          this.change._number, {add: [this._newHashtag]}).then(newHashtag => {
+            this.set(['change', 'hashtags'], newHashtag);
+            if (newHashtag !== lastHashtag) {
+              this.dispatchEvent(
+                  new CustomEvent('hashtag-changed', {bubbles: true}));
+            }
+            this._newHashtag = '';
+          });
+    },
+
+    _computeTopicReadOnly(mutable, change) {
       return !mutable || !change.actions.topic || !change.actions.topic.enabled;
     },
 
-    _computeTopicPlaceholder: function(_topicReadOnly) {
-      return _topicReadOnly ? 'No Topic' : 'Click to add topic';
+    _computeHashtagReadOnly(mutable, change) {
+      return !mutable ||
+          !change.actions.hashtags ||
+          !change.actions.hashtags.enabled;
     },
 
-    _computeShowReviewersByState: function(serverConfig) {
+    _computeAssigneeReadOnly(mutable, change) {
+      return !mutable ||
+          !change.actions.assignee ||
+          !change.actions.assignee.enabled;
+    },
+
+    _computeTopicPlaceholder(_topicReadOnly) {
+      return _topicReadOnly ? 'No Topic' : 'Add Topic';
+    },
+
+    _computeHashtagPlaceholder(_hashtagReadOnly) {
+      return _hashtagReadOnly ? '' : HASHTAG_ADD_MESSAGE;
+    },
+
+    _computeShowReviewersByState(serverConfig) {
       return !!serverConfig.note_db_enabled;
     },
 
@@ -170,9 +231,9 @@
      * @param {boolean} mutable this.mutable describes whether the
      *     change-metadata section is modifiable by the current user.
      */
-    _computeCanDeleteVote: function(reviewer, mutable) {
+    _computeCanDeleteVote(reviewer, mutable) {
       if (!mutable) { return false; }
-      for (var i = 0; i < this.change.removable_reviewers.length; i++) {
+      for (let i = 0; i < this.change.removable_reviewers.length; i++) {
         if (this.change.removable_reviewers[i]._account_id ===
             reviewer._account_id) {
           return true;
@@ -181,60 +242,146 @@
       return false;
     },
 
-    _onDeleteVote: function(e) {
+    /**
+     * Closure annotation for Polymer.prototype.splice is off.
+     * For now, supressing annotations.
+     *
+     * TODO(beckysiegel) submit Polymer PR
+     *
+     * @suppress {checkTypes} */
+    _onDeleteVote(e) {
       e.preventDefault();
-      var target = Polymer.dom(e).rootTarget;
-      var labelName = target.labelName;
-      var accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      const target = Polymer.dom(e).rootTarget;
+      target.disabled = true;
+      const labelName = target.labelName;
+      const accountID = parseInt(target.getAttribute('data-account-id'), 10);
       this._xhrPromise =
-          this.$.restAPI.deleteVote(this.change.id, accountID, labelName)
-          .then(function(response) {
-        if (!response.ok) { return response; }
-
-        var labels = this.change.labels[labelName].all || [];
-        for (var i = 0; i < labels.length; i++) {
-          if (labels[i]._account_id === accountID) {
-            this.splice(['change.labels', labelName, 'all'], i, 1);
-            break;
-          }
-        }
-      }.bind(this));
+          this.$.restAPI.deleteVote(this.change._number, accountID, labelName)
+          .then(response => {
+            target.disabled = false;
+            if (!response.ok) { return response; }
+            const label = this.change.labels[labelName];
+            const labels = label.all || [];
+            for (let i = 0; i < labels.length; i++) {
+              if (labels[i]._account_id === accountID) {
+                for (const key in label) {
+                  if (label.hasOwnProperty(key) &&
+                      label[key]._account_id === accountID) {
+                    // Remove special label field, keeping change label values
+                    // in sync with the backend.
+                    this.set(['change.labels', labelName, key], null);
+                  }
+                }
+                this.splice(['change.labels', labelName, 'all'], i, 1);
+                break;
+              }
+            }
+          }).catch(err => {
+            target.disabled = false;
+            return;
+          });
     },
 
-    _computeShowLabelStatus: function(change) {
-      var isNewChange = change.status === this.ChangeStatus.NEW;
-      var hasLabels = Object.keys(change.labels).length > 0;
+    _computeShowLabelStatus(change) {
+      const isNewChange = change.status === this.ChangeStatus.NEW;
+      const hasLabels = Object.keys(change.labels).length > 0;
       return isNewChange && hasLabels;
     },
 
-    _computeSubmitStatus: function(labels) {
-      var missingLabels = [];
-      var output = '';
-      for (var label in labels) {
-        var obj = labels[label];
+    _computeMissingLabels(labels) {
+      const missingLabels = [];
+      for (const label in labels) {
+        if (!labels.hasOwnProperty(label)) { continue; }
+        const obj = labels[label];
         if (!obj.optional && !obj.approved) {
           missingLabels.push(label);
         }
       }
-      if (missingLabels.length) {
-        output += 'Needs ';
-        output += missingLabels.join(' and ');
-        output += missingLabels.length > 1 ? ' labels' : ' label';
-      } else {
-        output = 'Ready to submit';
+      return missingLabels;
+    },
+
+    _computeMissingLabelsHeader(labels) {
+      return 'Needs label' +
+          (this._computeMissingLabels(labels).length > 1 ? 's' : '') + ':';
+    },
+
+    _showMissingLabels(labels) {
+      return !!this._computeMissingLabels(labels).length;
+    },
+
+    _showMissingRequirements(labels, workInProgress) {
+      return workInProgress || this._showMissingLabels(labels);
+    },
+
+    _computeProjectURL(project) {
+      return Gerrit.Nav.getUrlForProject(project);
+    },
+
+    _computeBranchURL(project, branch) {
+      return Gerrit.Nav.getUrlForBranch(branch, project,
+          this.change.status == this.ChangeStatus.NEW ? 'open' :
+              this.change.status.toLowerCase());
+    },
+
+    _computeTopicURL(topic) {
+      return Gerrit.Nav.getUrlForTopic(topic);
+    },
+
+    _computeHashtagURL(hashtag) {
+      return Gerrit.Nav.getUrlForHashtag(hashtag);
+    },
+
+    _handleTopicRemoved(e) {
+      const target = Polymer.dom(e).rootTarget;
+      target.disabled = true;
+      this.$.restAPI.setChangeTopic(this.change._number, null).then(() => {
+        target.disabled = false;
+        this.set(['change', 'topic'], '');
+        this.dispatchEvent(
+            new CustomEvent('topic-changed', {bubbles: true}));
+      }).catch(err => {
+        target.disabled = false;
+        return;
+      });
+    },
+
+    _handleHashtagRemoved(e) {
+      e.preventDefault();
+      const target = Polymer.dom(e).rootTarget;
+      target.disabled = true;
+      this.$.restAPI.setChangeHashtag(this.change._number,
+          {remove: [target.text]})
+          .then(newHashtag => {
+            target.disabled = false;
+            this.set(['change', 'hashtags'], newHashtag);
+          }).catch(err => {
+            target.disabled = false;
+            return;
+          });
+    },
+
+    _computeIsWip(change) {
+      return !!change.work_in_progress;
+    },
+
+    _computeShowUploaderHide(change) {
+      return this._computeShowUploader(change) ? '' : 'hideDisplay';
+    },
+
+    _computeShowUploader(change) {
+      if (!change.current_revision ||
+          !change.revisions[change.current_revision]) {
+        return null;
       }
-      return output;
-    },
 
-    _computeTopicHref: function(topic) {
-      var encodedTopic = encodeURIComponent('\"' + topic + '\"');
-      return this.getBaseUrl() + '/q/topic:' + encodeURIComponent(encodedTopic) +
-          '+(status:open OR status:merged)';
-    },
+      const rev = change.revisions[change.current_revision];
 
-    _handleTopicRemoved: function() {
-      this.set(['change', 'topic'], '');
-      this.$.restAPI.setChangeTopic(this.change._number, null);
+      if (!rev || !rev.uploader ||
+        change.owner._account_id === rev.uploader._account_id) {
+        return null;
+      }
+
+      return rev.uploader;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index 4eda281..58188cd 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-metadata.html">
 
 <script>void(0);</script>
@@ -33,49 +32,50 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-metadata tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-change-metadata tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
 
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('computed fields', function() {
+    test('computed fields', () => {
       assert.isFalse(element._computeHideStrategy({status: 'NEW'}));
-      assert.isFalse(element._computeHideStrategy({status: 'DRAFT'}));
       assert.isTrue(element._computeHideStrategy({status: 'MERGED'}));
       assert.isTrue(element._computeHideStrategy({status: 'ABANDONED'}));
       assert.equal(element._computeStrategy({submit_type: 'CHERRY_PICK'}),
           'Cherry Pick');
+      assert.equal(element._computeStrategy({submit_type: 'REBASE_ALWAYS'}),
+          'Rebase Always');
     });
 
-    test('show strategy for open change', function() {
+    test('show strategy for open change', () => {
       element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {}};
       flushAsynchronousOperations();
-      var strategy = element.$$('.strategy');
+      const strategy = element.$$('.strategy');
       assert.ok(strategy);
       assert.isFalse(strategy.hasAttribute('hidden'));
       assert.equal(strategy.children[1].innerHTML, 'Cherry Pick');
     });
 
-    test('hide strategy for closed change', function() {
+    test('hide strategy for closed change', () => {
       element.change = {status: 'MERGED', labels: {}};
       flushAsynchronousOperations();
       assert.isTrue(element.$$('.strategy').hasAttribute('hidden'));
     });
 
-    test('show CC section when NoteDb enabled', function() {
+    test('show CC section when NoteDb enabled', () => {
       function hasCc() {
         return element._showReviewersByState;
       }
@@ -87,41 +87,54 @@
       assert.isTrue(hasCc());
     });
 
-    test('computes submit status', function() {
-      var labels = {};
-      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
-      labels = {test: {}};
-      assert.equal(element._computeSubmitStatus(labels), 'Needs test label');
-      labels.test.approved = true;
-      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
-      labels.test.approved = false;
-      labels.test.optional = true;
-      assert.equal(element._computeSubmitStatus(labels), 'Ready to submit');
-      labels.test.optional = false;
-      labels.test2 = {};
-      assert.equal(element._computeSubmitStatus(labels),
-          'Needs test and test2 labels');
+    test('computes submit status', () => {
+      let showMissingLabels = false;
+      sandbox.stub(element, '_showMissingLabels', () => {
+        return showMissingLabels;
+      });
+      assert.isFalse(element._showMissingRequirements(null, false));
+      assert.isTrue(element._showMissingRequirements(null, true));
+      showMissingLabels = true;
+      assert.isTrue(element._showMissingRequirements(null, false));
     });
 
-    test('weblinks hidden when no weblinks', function() {
+    test('show missing labels', () => {
+      let labels = {};
+      assert.isFalse(element._showMissingLabels(labels));
+      labels = {test: {}};
+      assert.isTrue(element._showMissingLabels(labels));
+      assert.deepEqual(element._computeMissingLabels(labels), ['test']);
+      labels.test.approved = true;
+      assert.isFalse(element._showMissingLabels(labels));
+      labels.test.approved = false;
+      labels.test.optional = true;
+      assert.isFalse(element._showMissingLabels(labels));
+      labels.test.optional = false;
+      labels.test2 = {};
+      assert.isTrue(element._showMissingLabels(labels));
+      assert.deepEqual(element._computeMissingLabels(labels),
+          ['test', 'test2']);
+    });
+
+    test('weblinks hidden when no weblinks', () => {
       element.commitInfo = {};
       flushAsynchronousOperations();
-      var webLinks = element.$.webLinks;
+      const webLinks = element.$.webLinks;
       assert.isTrue(webLinks.hasAttribute('hidden'));
     });
 
-    test('weblinks hidden when only gitiles weblink', function() {
+    test('weblinks hidden when only gitiles weblink', () => {
       element.commitInfo = {web_links: [{name: 'gitiles', url: '#'}]};
       flushAsynchronousOperations();
-      var webLinks = element.$.webLinks;
+      const webLinks = element.$.webLinks;
       assert.isTrue(webLinks.hasAttribute('hidden'));
       assert.equal(element._computeWebLinks(element.commitInfo), null);
     });
 
-    test('weblinks are visible when other weblinks', function() {
+    test('weblinks are visible when other weblinks', () => {
       element.commitInfo = {web_links: [{name: 'test', url: '#'}]};
       flushAsynchronousOperations();
-      var webLinks = element.$.webLinks;
+      const webLinks = element.$.webLinks;
       assert.isFalse(webLinks.hasAttribute('hidden'));
       assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
       // With two non-gitiles weblinks, there are two returned.
@@ -130,19 +143,169 @@
       assert.equal(element._computeWebLinks(element.commitInfo).length, 2);
     });
 
-    test('weblinks are visible when gitiles and other weblinks', function() {
+    test('weblinks are visible when gitiles and other weblinks', () => {
       element.commitInfo = {
         web_links: [{name: 'test', url: '#'}, {name: 'gitiles', url: '#'}]};
       flushAsynchronousOperations();
-      var webLinks = element.$.webLinks;
+      const webLinks = element.$.webLinks;
       assert.isFalse(webLinks.hasAttribute('hidden'));
       // Only the non-gitiles weblink is returned.
       assert.equal(element._computeWebLinks(element.commitInfo).length, 1);
     });
 
-    suite('Topic removal', function() {
-      var change;
-      setup(function() {
+    test('determines whether to show "Ready to Submit" label', () => {
+      const showMissingSpy = sandbox.spy(element, '_showMissingRequirements');
+      element.change = {status: 'NEW', submit_type: 'CHERRY_PICK', labels: {
+        test: {
+          all: [{_account_id: 1, name: 'bojack', value: 1}],
+          default_value: 0,
+          values: [],
+        },
+      }};
+      flushAsynchronousOperations();
+      assert.isTrue(showMissingSpy.called);
+    });
+
+    test('_computeShowUploader test for uploader', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        owner: {
+          _account_id: 1019328,
+        },
+        revisions: {
+          rev1: {
+            _number: 1,
+            uploader: {
+              _account_id: 1011123,
+            },
+          },
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        mergeable: true,
+      };
+      assert.deepEqual(element._computeShowUploader(change),
+          {_account_id: 1011123});
+    });
+
+    test('_computeShowUploader test that it does not return uploader', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        owner: {
+          _account_id: 1011123,
+        },
+        revisions: {
+          rev1: {
+            _number: 1,
+            uploader: {
+              _account_id: 1011123,
+            },
+          },
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        mergeable: true,
+      };
+      assert.isNotOk(element._computeShowUploader(change));
+    });
+
+    test('no current_revision makes _computeShowUploader return null', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        owner: {
+          _account_id: 1011123,
+        },
+        revisions: {
+          rev1: {
+            _number: 1,
+            uploader: {
+              _account_id: 1011123,
+            },
+          },
+        },
+        status: 'NEW',
+        labels: {},
+        mergeable: true,
+      };
+      assert.isNotOk(element._computeShowUploader(change));
+    });
+
+    test('_computeShowUploaderHide test for string which equals true', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        owner: {
+          _account_id: 1019328,
+        },
+        revisions: {
+          rev1: {
+            _number: 1,
+            uploader: {
+              _account_id: 1011123,
+            },
+          },
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        mergeable: true,
+      };
+      assert.equal(element._computeShowUploaderHide(change), '');
+    });
+
+    test('_computeShowUploaderHide test for hideDisplay', () => {
+      const change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        owner: {
+          _account_id: 1011123,
+        },
+        revisions: {
+          rev1: {
+            _number: 1,
+            uploader: {
+              _account_id: 1011123,
+            },
+          },
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        mergeable: true,
+      };
+      assert.equal(
+          element._computeShowUploaderHide(change), 'hideDisplay');
+    });
+
+    test('_computeValueTooltip', () => {
+      // Existing label.
+      const change = {labels: {'Foo-bar': {values: {0: 'Baz'}}}};
+      let score = '0';
+      let labelName = 'Foo-bar';
+      let actual = element._computeValueTooltip(change, score, labelName);
+      assert.equal(actual, 'Baz');
+
+      // Non-extsistent label.
+      labelName = 'xyz';
+      actual = element._computeValueTooltip(change, score, labelName);
+      assert.equal(actual, '');
+
+      // Non-extsistent score.
+      score = '2';
+      actual = element._computeValueTooltip(change, score, labelName);
+      assert.equal(actual, '');
+
+      // No values on label.
+      labelName = 'abcd';
+      score = '0';
+      change.labels.abcd = {};
+      actual = element._computeValueTooltip(change, score, labelName);
+      assert.equal(actual, '');
+    });
+
+    suite('Topic removal', () => {
+      let change;
+      setup(() => {
         change = {
           _number: 'the number',
           actions: {
@@ -163,8 +326,8 @@
         };
       });
 
-      test('_computeTopicReadOnly', function() {
-        var mutable = false;
+      test('_computeTopicReadOnly', () => {
+        let mutable = false;
         assert.isTrue(element._computeTopicReadOnly(mutable, change));
         mutable = true;
         assert.isTrue(element._computeTopicReadOnly(mutable, change));
@@ -174,32 +337,34 @@
         assert.isTrue(element._computeTopicReadOnly(mutable, change));
       });
 
-      test('topic read only hides delete button', function() {
+      test('topic read only hides delete button', () => {
         element.mutable = false;
         element.change = change;
         flushAsynchronousOperations();
-        var button = element.$$('gr-linked-chip').$$('gr-button');
+        const button = element.$$('gr-linked-chip').$$('gr-button');
         assert.isTrue(button.hasAttribute('hidden'));
       });
 
-      test('topic not read only does not hide delete button', function() {
+      test('topic not read only does not hide delete button', () => {
         element.mutable = true;
         change.actions.topic.enabled = true;
         element.change = change;
         flushAsynchronousOperations();
-        var button = element.$$('gr-linked-chip').$$('gr-button');
+        const button = element.$$('gr-linked-chip').$$('gr-button');
         assert.isFalse(button.hasAttribute('hidden'));
       });
     });
 
-    suite('remove reviewer votes', function() {
-      setup(function() {
-        sandbox.stub(element, '_computeValueTooltip').returns('');
-        sandbox.stub(element, '_computeTopicReadOnly').returns(true);
-        element.change = {
+    suite('Hashtag removal', () => {
+      let change;
+      setup(() => {
+        change = {
           _number: 'the number',
+          actions: {
+            hashtags: {enabled: false},
+          },
           change_id: 'the id',
-          topic: 'the topic',
+          hashtags: ['test-hashtag'],
           status: 'NEW',
           submit_type: 'CHERRY_PICK',
           labels: {
@@ -213,94 +378,202 @@
         };
       });
 
-      test('_computeCanDeleteVote hides delete button', function() {
+      test('_computeHashtagReadOnly', () => {
+        element.serverConfig = {
+          note_db_enabled: true,
+        };
         flushAsynchronousOperations();
-        var button = element.$$('gr-account-chip').$$('gr-button');
+        let mutable = false;
+        assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+        mutable = true;
+        assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+        change.actions.hashtags.enabled = true;
+        assert.isFalse(element._computeHashtagReadOnly(mutable, change));
+        mutable = false;
+        assert.isTrue(element._computeHashtagReadOnly(mutable, change));
+      });
+
+      test('hashtag read only hides delete button', () => {
+        element.serverConfig = {
+          note_db_enabled: true,
+        };
+        flushAsynchronousOperations();
+        element.mutable = false;
+        element.change = change;
+        flushAsynchronousOperations();
+        const button = element.$$('gr-linked-chip').$$('gr-button');
+        assert.isTrue(button.hasAttribute('hidden'));
+      });
+
+      test('hashtag not read only does not hide delete button', () => {
+        element.serverConfig = {
+          note_db_enabled: true,
+        };
+        flushAsynchronousOperations();
+        element.mutable = true;
+        change.actions.hashtags.enabled = true;
+        element.change = change;
+        flushAsynchronousOperations();
+        const button = element.$$('gr-linked-chip').$$('gr-button');
+        assert.isFalse(button.hasAttribute('hidden'));
+      });
+    });
+
+    suite('remove reviewer votes', () => {
+      setup(() => {
+        sandbox.stub(element, '_computeValueTooltip').returns('');
+        sandbox.stub(element, '_computeTopicReadOnly').returns(true);
+        element.change = {
+          _number: 42,
+          change_id: 'the id',
+          actions: [],
+          topic: 'the topic',
+          status: 'NEW',
+          submit_type: 'CHERRY_PICK',
+          labels: {
+            test: {
+              all: [{_account_id: 1, name: 'bojack', value: 1}],
+              default_value: 0,
+              values: [],
+            },
+          },
+          removable_reviewers: [],
+        };
+        flushAsynchronousOperations();
+      });
+
+      test('_computeCanDeleteVote hides delete button', () => {
+        const button = element.$$('gr-account-chip').$$('gr-button');
         assert.isTrue(button.hasAttribute('hidden'));
         element.mutable = true;
         assert.isTrue(button.hasAttribute('hidden'));
       });
 
-      test('_computeCanDeleteVote shows delete button', function() {
+      test('_computeCanDeleteVote shows delete button', () => {
         element.change.removable_reviewers = [
           {
             _account_id: 1,
             name: 'bojack',
-          }
+          },
         ];
         element.mutable = true;
-        flushAsynchronousOperations();
-        var button = element.$$('gr-account-chip').$$('gr-button');
+        const button = element.$$('gr-account-chip').$$('gr-button');
         assert.isFalse(button.hasAttribute('hidden'));
       });
 
-      test('deletes votes', function(done) {
-        sandbox.stub(element.$.restAPI, 'deleteVote')
-            .returns(Promise.resolve({'ok': true}));
+      test('deletes votes', done => {
+        const deleteStub = sandbox.stub(element.$.restAPI, 'deleteVote')
+            .returns(Promise.resolve({ok: true}));
+
         element.change.removable_reviewers = [
           {
             _account_id: 1,
             name: 'bojack',
-          }
+          },
         ];
+        element.change.labels.test.recommended = {_account_id: 1};
         element.mutable = true;
         flushAsynchronousOperations();
-        var button = element.$$('gr-account-chip').$$('gr-button');
-        MockInteractions.tap(button);
-        flushAsynchronousOperations();
-        var spliceStub = sinon.stub(element, 'splice',
-            function(path, index, length) {
+        const chip = element.$$('gr-account-chip');
+        const button = chip.$$('gr-button');
+
+        const spliceStub = sandbox.stub(element, 'splice', (path, index,
+            length) => {
+          assert.isFalse(chip.disabled);
           assert.deepEqual(path, ['change.labels', 'test', 'all']);
           assert.equal(index, 0);
           assert.equal(length, 1);
+          assert.notOk(element.change.labels.test.recommended);
+          assert.isTrue(deleteStub.calledWithExactly(42, 1, 'test'));
           spliceStub.restore();
           done();
         });
+
+        MockInteractions.tap(button);
+        assert.isTrue(chip.disabled);
       });
 
-      test('changing topic calls setChangeTopic', function() {
-        var topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic',
-            function() {});
-        element._handleTopicChanged({}, 'the new topic');
-        assert.isTrue(topicStub.calledWith('the number', 'the new topic'));
+      test('changing topic', () => {
+        const newTopic = 'the new topic';
+        sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
+            Promise.resolve(newTopic));
+        element._handleTopicChanged({}, newTopic);
+        const topicChangedSpy = sandbox.spy();
+        element.addEventListener('topic-changed', topicChangedSpy);
+        assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
+            42, newTopic));
+        return element.$.restAPI.setChangeTopic.lastCall.returnValue
+            .then(() => {
+              assert.equal(element.change.topic, newTopic);
+              assert.isTrue(topicChangedSpy.called);
+            });
       });
 
-      test('topic href has quotes', function() {
-        var hrefArr = element._computeTopicHref('test')
-            .split('%2522'); // Double-escaped quote.
-        assert.equal(hrefArr[1], 'test');
-      });
-
-      test('clicking x on topic chip removes topic', function() {
-        var topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic');
-        flushAsynchronousOperations();
-        var remove = element.$$('gr-linked-chip').$.remove;
+      test('topic removal', () => {
+        sandbox.stub(element.$.restAPI, 'setChangeTopic').returns(
+            Promise.resolve());
+        const chip = element.$$('gr-linked-chip');
+        const remove = chip.$.remove;
+        const topicChangedSpy = sandbox.spy();
+        element.addEventListener('topic-changed', topicChangedSpy);
         MockInteractions.tap(remove);
-        assert.equal(element.change.topic, '');
-        assert.isTrue(topicStub.called);
+        assert.isTrue(chip.disabled);
+        assert.isTrue(element.$.restAPI.setChangeTopic.calledWith(
+            42, null));
+        return element.$.restAPI.setChangeTopic.lastCall.returnValue
+            .then(() => {
+              assert.isFalse(chip.disabled);
+              assert.equal(element.change.topic, '');
+              assert.isTrue(topicChangedSpy.called);
+            });
       });
 
-      suite('assignee field', function() {
-        var dummyAccount = {
+      test('changing hashtag', () => {
+        element.serverConfig = {
+          note_db_enabled: true,
+        };
+        flushAsynchronousOperations();
+        element._newHashtag = 'new hashtag';
+        const newHashtag = ['new hashtag'];
+        sandbox.stub(element.$.restAPI, 'setChangeHashtag').returns(
+            Promise.resolve(newHashtag));
+        element._handleHashtagChanged({}, 'new hashtag');
+        assert.isTrue(element.$.restAPI.setChangeHashtag.calledWith(
+            42, {add: ['new hashtag']}));
+        return element.$.restAPI.setChangeHashtag.lastCall.returnValue
+            .then(() => {
+              assert.equal(element.change.hashtags, newHashtag);
+            });
+      });
+
+      suite('assignee field', () => {
+        const dummyAccount = {
           _account_id: 1,
           name: 'bojack',
         };
-        var deleteStub;
-        var setStub;
-        setup(function() {
+        const change = {
+          actions: {
+            assignee: {enabled: false},
+          },
+          assignee: dummyAccount,
+        };
+        let deleteStub;
+        let setStub;
+
+        setup(() => {
           deleteStub = sandbox.stub(element.$.restAPI, 'deleteAssignee');
           setStub = sandbox.stub(element.$.restAPI, 'setAssignee');
         });
 
-        test('changing change recomputes _assignee', function() {
+        test('changing change recomputes _assignee', () => {
           assert.isFalse(!!element._assignee.length);
-          var change = element.change;
+          const change = element.change;
           change.assignee = dummyAccount;
           element._changeChanged(change);
           assert.deepEqual(element._assignee[0], dummyAccount);
         });
 
-        test('modifying _assignee calls API', function() {
+        test('modifying _assignee calls API', () => {
           assert.isFalse(!!element._assignee.length);
           element.set('_assignee', [dummyAccount]);
           assert.isTrue(setStub.calledOnce);
@@ -313,6 +586,17 @@
           element.set('_assignee', []);
           assert.isTrue(deleteStub.calledOnce);
         });
+
+        test('_computeAssigneeReadOnly', () => {
+          let mutable = false;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+          mutable = true;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+          change.actions.assignee.enabled = true;
+          assert.isFalse(element._computeAssigneeReadOnly(mutable, change));
+          mutable = false;
+          assert.isTrue(element._computeAssigneeReadOnly(mutable, change));
+        });
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
new file mode 100644
index 0000000..d0ed4a1
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/test/plugin.html
@@ -0,0 +1,26 @@
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(plugin => {
+      plugin.registerStyleModule('change-metadata', 'my-plugin-style');
+    });
+  </script>
+</dom-module>
+
+<dom-module id="my-plugin-style">
+  <style>
+    html {
+      --change-metadata-assignee: {
+        display: none;
+      }
+      --change-metadata-label-status: {
+        display: none;
+      }
+      --change-metadata-strategy: {
+        display: none;
+      }
+      --change-metadata-topic: {
+        display: none;
+      }
+    }
+  </style>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 8455053..40db0915 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -14,18 +14,17 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../diff/gr-diff-preferences/gr-diff-preferences.html">
 <link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-change-star/gr-change-star.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-content/gr-editable-content.html">
-<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
@@ -36,13 +35,16 @@
 <link rel="import" href="../gr-commit-info/gr-commit-info.html">
 <link rel="import" href="../gr-download-dialog/gr-download-dialog.html">
 <link rel="import" href="../gr-file-list/gr-file-list.html">
+<link rel="import" href="../gr-file-list-header/gr-file-list-header.html">
+<link rel="import" href="../gr-included-in-dialog/gr-included-in-dialog.html">
 <link rel="import" href="../gr-messages-list/gr-messages-list.html">
 <link rel="import" href="../gr-related-changes-list/gr-related-changes-list.html">
 <link rel="import" href="../gr-reply-dialog/gr-reply-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-view">
   <template>
-    <style>
+    <style include="shared-styles">
       .container:not(.loading) {
         background-color: var(--view-background-color);
       }
@@ -68,10 +70,15 @@
         transition: box-shadow 250ms linear;
         width: 100%;
       }
+      .header.wip {
+        background-color: #fcfad6;
+        border-bottom: 1px solid #ddd;
+        margin-bottom: .5em;
+      }
       .header-title {
         flex: 1;
         font-size: 1.2em;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       gr-change-star {
         margin-right: .25em;
@@ -107,14 +114,16 @@
       }
       .commitMessage {
         font-family: var(--monospace-font-family);
-        max-width: 100ch;
         margin-right: 1em;
         margin-bottom: 1em;
+        max-width: var(--commit-message-max-width, 72ch);;
       }
       .commitMessage gr-linked-text {
-        overflow: auto;
         word-break: break-all;
       }
+      #commitMessageEditor {
+        min-width: 72ch;
+      }
       .editCommitMessage {
         margin-top: 1em;
       }
@@ -134,48 +143,16 @@
         flex-direction: column;
         min-width: 0;
       }
-      .commitAndRelated {
+      #commitAndRelated {
         align-content: flex-start;
         display: flex;
         flex: 1;
         overflow-x: hidden;
       }
-      .collapseToggleButton {
-        text-decoration: none;
-      }
       .relatedChanges {
         flex: 1 1 auto;
         overflow: hidden;
       }
-      .patchInfo {
-        border: 1px solid #ddd;
-        margin: 1em var(--default-horizontal-margin);
-      }
-      .patchInfo--oldPatchSet .patchInfo-header {
-        background-color: #fff9c4;
-      }
-      .patchInfo--oldPatchSet .latestPatchContainer {
-        display: initial;
-      }
-      .patchInfo-header,
-      gr-file-list {
-        padding: .5em calc(var(--default-horizontal-margin) / 2);
-      }
-      .patchInfo-header {
-        background-color: #f6f6f6;
-        border-bottom: 1px solid #ebebeb;
-        display: flex;
-        justify-content: space-between;
-      }
-      .latestPatchContainer {
-        display: none;
-      }
-      .patchSetSelect {
-        max-width: 8em;
-      }
-      gr-editable-label.descriptionLabel {
-        max-width: 100%;
-      }
       .mobile {
         display: none;
       }
@@ -188,9 +165,6 @@
         height: 0;
         margin-bottom: 1em;
       }
-      .patchInfo-header-wrapper {
-        width: 100%;
-      }
       #commitMessage.collapsed {
         max-height: 36em;
         overflow: hidden;
@@ -206,10 +180,17 @@
       .commitContainer {
         display: flex;
         flex-direction: column;
+        flex-shrink: 0;
       }
       .collapseToggleContainer {
         display: flex;
       }
+      #relatedChangesToggle {
+        display: none;
+      }
+      #relatedChangesToggle.showToggle {
+        display: flex;
+      }
       .collapseToggleContainer gr-button {
         display: block;
       }
@@ -217,6 +198,36 @@
         margin-left: 1em;
         padding-top: var(--related-change-btn-top-padding, 0);
       }
+      .showOnEdit {
+        display: none;
+      }
+      .scrollable {
+        overflow: auto;
+      }
+      #includedInOverlay {
+        width: 65em;
+      }
+      @media screen and (min-width: 80em) {
+        .commitMessage {
+          max-width: var(--commit-message-max-width, 100ch);
+        }
+      }
+      /* NOTE: If you update this breakpoint, also update the
+      BREAKPOINT_RELATED_MED in the JS */
+      @media screen and (max-width: 60em) {
+        #commitAndRelated {
+          flex-direction: column;
+          flex-wrap: nowrap;
+        }
+        #commitMessageEditor {
+          min-width: 0;
+        }
+      }
+      .patchInfo {
+        margin-top: 1em;
+      }
+      /* NOTE: If you update this breakpoint, also update the
+      BREAKPOINT_RELATED_SMALL in the JS */
       @media screen and (max-width: 50em) {
         .mobile {
           display: block;
@@ -249,7 +260,7 @@
           padding-right: 0;
         }
         .changeInfo,
-        .commitAndRelated {
+        #commitAndRelated {
           flex-direction: column;
           flex-wrap: nowrap;
         }
@@ -270,21 +281,26 @@
           flex: initial;
           margin-right: 0;
         }
-        .scrollable {
-          @apply(--layout-scroll);
+        /* Change actions are the only thing thant need to remain visible due
+        to the fact that they may have the currently visible overlay open. */
+        #mainContent.overlayOpen .hideOnMobileOverlay {
+          display: none;
         }
       }
     </style>
     <div class="container loading" hidden$="[[!_loading]]">Loading...</div>
-    <div class="container" hidden$="{{_loading}}">
-      <div class="header">
+    <div
+        id="mainContent"
+        class="container"
+        hidden$="{{_loading}}">
+      <div class$="hideOnMobileOverlay [[_computeHeaderClass(_change)]]">
         <span class="header-title">
           <gr-change-star
               id="changeStar"
               change="{{_change}}" hidden$="[[!_loggedIn]]"></gr-change-star>
           <a
               aria-label$="[[_computeChangePermalinkAriaLabel(_change._number)]]"
-              href$="[[_computeChangePermalink(_change._number)]]">[[_change._number]]</a><!--
+              href$="[[_computeChangeUrl(_change)]]">[[_change._number]]</a><!--
        --><template is="dom-if" if="[[_changeStatus]]"><!--
          --> (<!--
          --><span
@@ -297,7 +313,7 @@
               <gr-commit-info
                   change="[[_change]]"
                   commit-info="[[_computeMergedCommitInfo(_change.current_revision, _change.revisions)]]"
-                  server-config="[[serverConfig]]"></gr-commit-info><!--
+                  server-config="[[_serverConfig]]"></gr-commit-info><!--
          --></template><!--
          -->)<!--
        --></template><!--
@@ -305,11 +321,11 @@
         </span>
       </div>
       <section class="changeInfo">
-        <div class="changeInfo-column changeMetadata">
+        <div class="changeInfo-column changeMetadata hideOnMobileOverlay">
           <gr-change-metadata
               change="{{_change}}"
               commit-info="[[_commitInfo]]"
-              server-config="[[serverConfig]]"
+              server-config="[[_serverConfig]]"
               mutable="[[_loggedIn]]"
               on-show-reply-dialog="_handleShowReplyDialog">
           </gr-change-metadata>
@@ -333,12 +349,15 @@
                 change-num="[[_changeNum]]"
                 change-status="[[_change.status]]"
                 commit-num="[[_commitInfo.commit]]"
-                patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+                patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
                 commit-message="[[_latestCommitMessage]]"
-                on-reload-change="_handleReloadChange"></gr-change-actions>
+                edit-loaded="[[_editLoaded]]"
+                edit-based-on-current-patch-set="[[hasEditBasedOnCurrentPatchSet(_allPatchSets)]]"
+                on-reload-change="_handleReloadChange"
+                on-download-tap="_handleOpenDownloadDialog"></gr-change-actions>
           </div>
           <hr class="mobile">
-          <div class="commitAndRelated">
+          <div id="commitAndRelated" class="hideOnMobileOverlay">
             <div class="commitContainer">
               <div
                   id="commitMessage"
@@ -385,12 +404,12 @@
                   change="[[_change]]"
                   has-parent="{{hasParent}}"
                   loading="{{_relatedChangesLoading}}"
-                  patch-num="[[_computeLatestPatchNum(_allPatchSets)]]">
+                  on-update="_updateRelatedChangeMaxHeight"
+                  patch-num="[[computeLatestPatchNum(_allPatchSets)]]">
               </gr-related-changes-list>
               <div
                   id="relatedChangesToggle"
-                  class="collapseToggleContainer"
-                  hidden$="[[_computeRelatedChangesToggleHidden(_relatedChangesLoading)]]">
+                  class$="collapseToggleContainer [[_computeRelatedChangesToggleClass(_relatedChangesLoading)]]">
                 <gr-button
                     link
                     id="relatedChangesToggleButton"
@@ -403,74 +422,54 @@
           </div>
         </div>
       </section>
-      <section class$="patchInfo [[_computePatchInfoClass(_patchRange.patchNum,
-          _allPatchSets)]]">
-        <div class="patchInfo-header">
-          <div class="patchInfo-header-wrapper">
-            <label class="patchSelectLabel" for="patchSetSelect">
-              Patch set
-            </label>
-            <select
-                is="gr-select"
-                id="patchSetSelect"
-                bind-value="{{_selectedPatchSet}}"
-                class="patchSetSelect"
-                on-change="_handlePatchChange">
-              <template is="dom-repeat" items="[[_allPatchSets]]"
-                  as="patchNum">
-                <option value$="[[patchNum.num]]"
-                    disabled$="[[_computePatchSetDisabled(patchNum.num, _patchRange.basePatchNum)]]">
-                  [[patchNum.num]]
-                  /
-                  [[_computeLatestPatchNum(_allPatchSets)]]
-                  [[_computePatchSetDescription(_change, patchNum.num)]]
-                </option>
-              </template>
-            </select>
-            /
-            <gr-commit-info
-                change="[[_change]]"
-                server-config="[[serverConfig]]"
-                commit-info="[[_commitInfo]]"></gr-commit-info>
-            <span class="latestPatchContainer">
-              /
-              <a href$="[[getBaseUrl()]]/c/[[_change._number]]">Go to latest patch set</a>
-            </span>
-            <span class="downloadContainer desktop">
-              /
-              <gr-button link
-                  class="download"
-                  on-tap="_handleDownloadTap">Download</gr-button>
-            </span>
-            <span class="descriptionContainer">
-              /
-              <gr-editable-label
-                  id="descriptionLabel"
-                  class="descriptionLabel"
-                  value="[[_computePatchSetDescription(_change, _selectedPatchSet)]]"
-                  placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
-                  read-only="[[_descriptionReadOnly]]"
-                  on-changed="_handleDescriptionChanged"></gr-editable-label>
-            </span>
-          </div>
-        </div>
+      <section class="patchInfo hideOnMobileOverlay">
+        <gr-file-list-header
+            id="fileListHeader"
+            account="[[_account]]"
+            all-patch-sets="[[_allPatchSets]]"
+            change="[[_change]]"
+            change-num="[[_changeNum]]"
+            comments="[[_comments]]"
+            commit-info="[[_commitInfo]]"
+            change-url="[[_computeChangeUrl(_change)]]"
+            edit-loaded="[[_editLoaded]]"
+            logged-in="[[_loggedIn]]"
+            server-config="[[_serverConfig]]"
+            shown-file-count="[[_shownFileCount]]"
+            diff-prefs="[[_diffPrefs]]"
+            diff-view-mode="{{viewState.diffMode}}"
+            patch-num="{{_patchRange.patchNum}}"
+            base-patch-num="{{_patchRange.basePatchNum}}"
+            revisions="[[_sortedRevisions]]"
+            on-open-diff-prefs="_handleOpenDiffPrefs"
+            on-open-download-dialog="_handleOpenDownloadDialog"
+            on-open-included-in-dialog="_handleOpenIncludedInDialog"
+            on-expand-diffs="_expandAllDiffs"
+            on-collapse-diffs="_collapseAllDiffs">
+        </gr-file-list-header>
         <gr-file-list id="fileList"
+            diff-prefs="{{_diffPrefs}}"
             change="[[_change]]"
             change-num="[[_changeNum]]"
             patch-range="{{_patchRange}}"
             comments="[[_comments]]"
             drafts="[[_diffDrafts]]"
-            revisions="[[_change.revisions]]"
+            revisions="[[_sortedRevisions]]"
             project-config="[[_projectConfig]]"
             selected-index="{{viewState.selectedFileIndex}}"
-            diff-view-mode="{{viewState.diffMode}}"></gr-file-list>
+            diff-view-mode="[[viewState.diffMode]]"
+            edit-loaded="[[_editLoaded]]"
+            num-files-shown="{{_numFilesShown}}"
+            file-list-increment="{{_numFilesShown}}"
+            on-files-shown-changed="_setShownFiles"></gr-file-list>
       </section>
       <gr-messages-list id="messageList"
+          class="hideOnMobileOverlay"
           change-num="[[_changeNum]]"
           messages="[[_change.messages]]"
           reviewer-updates="[[_change.reviewer_updates]]"
           comments="[[_comments]]"
-          project-config="[[_projectConfig]]"
+          project-name="[[_change.project]]"
           show-reply-buttons="[[_loggedIn]]"
           on-reply="_handleMessageReply"></gr-messages-list>
     </div>
@@ -478,27 +477,35 @@
       <gr-download-dialog
           id="downloadDialog"
           change="[[_change]]"
-          logged-in="[[_loggedIn]]"
           patch-num="[[_patchRange.patchNum]]"
-          config="[[serverConfig.download]]"
+          config="[[_serverConfig.download]]"
           on-close="_handleDownloadDialogClose"></gr-download-dialog>
     </gr-overlay>
+    <gr-overlay id="includedInOverlay" with-backdrop>
+      <gr-included-in-dialog
+          id="includedInDialog"
+          change-num="[[_changeNum]]"
+          on-close="_handleIncludedInDialogClose"></gr-included-in-dialog>
+    </gr-overlay>
     <gr-overlay id="replyOverlay"
         class="scrollable"
         no-cancel-on-outside-click
+        no-cancel-on-esc-key
         on-iron-overlay-opened="_handleReplyOverlayOpen"
         with-backdrop>
       <gr-reply-dialog id="replyDialog"
           change="{{_change}}"
-          patch-num="[[_computeLatestPatchNum(_allPatchSets)]]"
+          patch-num="[[computeLatestPatchNum(_allPatchSets)]]"
           permitted-labels="[[_change.permitted_labels]]"
           diff-drafts="[[_diffDrafts]]"
-          server-config="[[serverConfig]]"
+          server-config="[[_serverConfig]]"
           project-config="[[_projectConfig]]"
+          can-be-started="[[_canStartReview]]"
           on-send="_handleReplySent"
           on-cancel="_handleReplyCancel"
           on-autogrow="_handleReplyAutogrow"
-          hidden$="[[!_loggedIn]]">Reply</gr-reply-dialog>
+          hidden$="[[!_loggedIn]]">
+      </gr-reply-dialog>
     </gr-overlay>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index baaf0029..1f050ba 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -14,18 +14,30 @@
 (function() {
   'use strict';
 
-  var CHANGE_ID_ERROR = {
+  const CHANGE_ID_ERROR = {
     MISMATCH: 'mismatch',
     MISSING: 'missing',
   };
-  var CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
-  var COMMENT_SAVE = 'Saving... Try again after all comments are saved.';
+  const CHANGE_ID_REGEX_PATTERN = /^Change-Id\:\s(I[0-9a-f]{8,40})/gm;
 
-  var MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+  const MIN_LINES_FOR_COMMIT_COLLAPSE = 30;
+  const DEFAULT_NUM_FILES_SHOWN = 200;
 
-  // Maximum length for patch set descriptions.
-  var PATCH_DESC_MAX_LENGTH = 500;
-  var REVIEWERS_REGEX = /^R=/gm;
+  const REVIEWERS_REGEX = /^(R|CC)=/gm;
+  const MIN_CHECK_INTERVAL_SECS = 0;
+
+  // These are the same as the breakpoint set in CSS. Make sure both are changed
+  // together.
+  const BREAKPOINT_RELATED_SMALL = '50em';
+  const BREAKPOINT_RELATED_MED = '60em';
+
+  // In the event that the related changes medium width calculation is too close
+  // to zero, provide some height.
+  const MINIMUM_RELATED_MAX_HEIGHT = 100;
+
+  const SMALL_RELATED_HEIGHT = 400;
+
+  const REPLY_REFIT_DEBOUNCE_INTERVAL_MS = 500;
 
   Polymer({
     is: 'gr-change-view',
@@ -42,6 +54,12 @@
      * @event page-error
      */
 
+    /**
+     * Fired if being logged in is required.
+     *
+     * @event show-auth-required
+     */
+
     properties: {
       /**
        * URL params passed from the router.
@@ -50,34 +68,51 @@
         type: Object,
         observer: '_paramsChanged',
       },
+      /** @type {?} */
       viewState: {
         type: Object,
         notify: true,
-        value: function() { return {}; },
+        value() { return {}; },
+        observer: '_viewStateChanged',
       },
       backPage: String,
       hasParent: Boolean,
-      serverConfig: Object,
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
-
+      /** @type {?} */
+      _serverConfig: {
+        type: Object,
+        observer: '_startUpdateCheckTimer',
+      },
+      _diffPrefs: Object,
+      _numFilesShown: {
+        type: Number,
+        value: DEFAULT_NUM_FILES_SHOWN,
+        observer: '_numFilesShownChanged',
+      },
       _account: {
         type: Object,
         value: {},
       },
+      _canStartReview: {
+        type: Boolean,
+        computed: '_computeCanStartReview(_change)',
+      },
       _comments: Object,
+      /** @type {?} */
       _change: {
         type: Object,
         observer: '_changeChanged',
       },
+      /** @type {?} */
       _commitInfo: Object,
       _files: Object,
       _changeNum: String,
       _diffDrafts: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       _editingCommitMessage: {
         type: Boolean,
@@ -88,6 +123,8 @@
         computed: '_computeHideEditCommitMessage(_loggedIn, ' +
             '_editingCommitMessage, _change)',
       },
+      _diffAgainst: String,
+      /** @type {?string} */
       _latestCommitMessage: {
         type: String,
         value: '',
@@ -98,10 +135,20 @@
         computed:
           '_computeChangeIdCommitMessageError(_latestCommitMessage, _change)',
       },
+        /** @type {?} */
       _patchRange: {
         type: Object,
-        observer: '_updateSelected',
       },
+      // These are kept as separate properties from the patchRange so that the
+      // observer can be aware of the previous value. In order to view sub
+      // property changes for _patchRange, a complex observer must be used, and
+      // that only displays the new value.
+      //
+      // If a previous value did not exist, the change is not reloaded with the
+      // new patches. This is just the initial setting from the change view vs.
+      // an update coming from the two way data binding.
+      _patchNum: String,
+      _basePatchNum: String,
       _relatedChangesLoading: {
         type: Boolean,
         value: true,
@@ -109,37 +156,35 @@
       _currentRevisionActions: Object,
       _allPatchSets: {
         type: Array,
-        computed: '_computeAllPatchSets(_change, _change.revisions.*)',
+        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
       },
       _loggedIn: {
         type: Boolean,
         value: false,
       },
       _loading: Boolean,
+      /** @type {?} */
       _projectConfig: Object,
       _rebaseOnCurrent: Boolean,
       _replyButtonLabel: {
         type: String,
         value: 'Reply',
-        computed: '_computeReplyButtonLabel(_diffDrafts.*)',
+        computed: '_computeReplyButtonLabel(_diffDrafts.*, _canStartReview)',
       },
       _selectedPatchSet: String,
+      _shownFileCount: Number,
       _initialLoadComplete: {
         type: Boolean,
         value: false,
       },
-      _descriptionReadOnly: {
-        type: Boolean,
-        computed: '_computeDescriptionReadOnly(_loggedIn, _change, _account)',
-      },
       _replyDisabled: {
         type: Boolean,
         value: true,
-        computed: '_computeReplyDisabled(serverConfig)',
+        computed: '_computeReplyDisabled(_serverConfig)',
       },
       _changeStatus: {
         type: String,
-        computed: '_computeChangeStatus(_change, _patchRange.patchNum)',
+        computed: 'changeStatusString(_change)',
       },
       _commitCollapsed: {
         type: Boolean,
@@ -149,18 +194,35 @@
         type: Boolean,
         value: true,
       },
+      /** @type {?number} */
+      _updateCheckTimerHandle: Number,
+      _sortedRevisions: Array,
+      _editLoaded: {
+        type: Boolean,
+        computed: '_computeEditLoaded(_patchRange.*)',
+      },
     },
 
     behaviors: [
-      Gerrit.BaseUrlBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
+    listeners: {
+      'topic-changed': '_handleTopicChanged',
+      // When an overlay is opened in a mobile viewport, the overlay has a full
+      // screen view. When it has a full screen view, we do not want the
+      // background to be scrollable. This will eliminate background scroll by
+      // hiding most of the contents on the screen upon opening, and showing
+      // again upon closing.
+      'fullscreen-overlay-opened': '_handleHideBackgroundContent',
+      'fullscreen-overlay-closed': '_handleShowBackgroundContent',
+    },
     observers: [
       '_labelsChanged(_change.labels.*)',
       '_paramsAndChangeChanged(params, _change)',
+      '_updateSortedRevisions(_change.revisions.*)',
     ],
 
     keyBindings: {
@@ -171,19 +233,26 @@
       'u': '_handleUKey',
       'x': '_handleXKey',
       'z': '_handleZKey',
+      ',': '_handleCommaKey',
     },
 
-    attached: function() {
-      this._getLoggedIn().then(function(loggedIn) {
+    attached() {
+      this._getServerConfig().then(config => {
+        this._serverConfig = config;
+      });
+
+      this._getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
         if (loggedIn) {
-          this.$.restAPI.getAccount().then(function(acct) {
+          this.$.restAPI.getAccount().then(acct => {
             this._account = acct;
-          }.bind(this));
+          });
         }
-      }.bind(this));
+        this._setDiffViewMode();
+      });
 
       this.addEventListener('comment-save', this._handleCommentSave.bind(this));
+      this.addEventListener('comment-refresh', this._getDiffDrafts.bind(this));
       this.addEventListener('comment-discard',
           this._handleCommentDiscard.bind(this));
       this.addEventListener('editable-content-save',
@@ -191,53 +260,74 @@
       this.addEventListener('editable-content-cancel',
           this._handleCommitMessageCancel.bind(this));
       this.listen(window, 'scroll', '_handleScroll');
+      this.listen(document, 'visibilitychange', '_handleVisibilityChange');
     },
 
-    detached: function() {
+    detached() {
       this.unlisten(window, 'scroll', '_handleScroll');
+      this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
+
+      if (this._updateCheckTimerHandle) {
+        this._cancelUpdateCheckTimer();
+      }
     },
 
-    _handleEditCommitMessage: function(e) {
+    /**
+     * @param {boolean=} opt_reset
+     */
+    _setDiffViewMode(opt_reset) {
+      if (!opt_reset && this.viewState.diffViewMode) { return; }
+
+      return this.$.restAPI.getPreferences().then( prefs => {
+        if (!this.viewState.diffMode) {
+          this.set('viewState.diffMode', prefs.default_diff_view);
+        }
+      }).then(() => {
+        if (!this.viewState.diffMode) {
+          this.set('viewState.diffMode', 'SIDE_BY_SIDE');
+        }
+      });
+    },
+
+    _updateSortedRevisions(revisionsRecord) {
+      const revisions = revisionsRecord.base;
+      this._sortedRevisions = this.sortRevisions(Object.values(revisions));
+    },
+
+    _handleEditCommitMessage(e) {
       this._editingCommitMessage = true;
       this.$.commitMessageEditor.focusTextarea();
     },
 
-    _handleCommitMessageSave: function(e) {
-      var message = e.detail.content;
+    _handleCommitMessageSave(e) {
+      const message = e.detail.content;
 
       this.$.jsAPI.handleCommitMessage(this._change, message);
 
       this.$.commitMessageEditor.disabled = true;
-      this._saveCommitMessage(message).then(function(resp) {
-        this.$.commitMessageEditor.disabled = false;
-        if (!resp.ok) { return; }
+      this.$.restAPI.putChangeCommitMessage(
+          this._changeNum, message).then(resp => {
+            this.$.commitMessageEditor.disabled = false;
+            if (!resp.ok) { return; }
 
-        this._latestCommitMessage = this._prepareCommitMsgForLinkify(message);
-        this._editingCommitMessage = false;
-        this._reloadWindow();
-      }.bind(this)).catch(function(err) {
-        this.$.commitMessageEditor.disabled = false;
-      }.bind(this));
+            this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+                message);
+            this._editingCommitMessage = false;
+            this._reloadWindow();
+          }).catch(err => {
+            this.$.commitMessageEditor.disabled = false;
+          });
     },
 
-    _reloadWindow: function() {
+    _reloadWindow() {
       window.location.reload();
     },
 
-    _handleCommitMessageCancel: function(e) {
+    _handleCommitMessageCancel(e) {
       this._editingCommitMessage = false;
     },
 
-    _saveCommitMessage: function(message) {
-      return this.$.restAPI.saveChangeCommitMessageEdit(
-          this._changeNum, message).then(function(resp) {
-            if (!resp.ok) { return resp; }
-
-            return this.$.restAPI.publishChangeEdit(this._changeNum);
-          }.bind(this));
-    },
-
-    _computeHideEditCommitMessage: function(loggedIn, editing, change) {
+    _computeHideEditCommitMessage(loggedIn, editing, change) {
       if (!loggedIn || editing || change.status === this.ChangeStatus.MERGED) {
         return true;
       }
@@ -245,24 +335,23 @@
       return false;
     },
 
-    _handleCommentSave: function(e) {
+    _handleCommentSave(e) {
       if (!e.target.comment.__draft) { return; }
 
-      var draft = e.target.comment;
+      const draft = e.target.comment;
       draft.patch_set = draft.patch_set || this._patchRange.patchNum;
 
       // The use of path-based notification helpers (set, push) can’t be used
       // because the paths could contain dots in them. A new object must be
       // created to satisfy Polymer’s dirty checking.
       // https://github.com/Polymer/polymer/issues/3127
-      // TODO(andybons): Polyfill for Object.assign in IE.
-      var diffDrafts = Object.assign({}, this._diffDrafts);
+      const diffDrafts = Object.assign({}, this._diffDrafts);
       if (!diffDrafts[draft.path]) {
         diffDrafts[draft.path] = [draft];
         this._diffDrafts = diffDrafts;
         return;
       }
-      for (var i = 0; i < this._diffDrafts[draft.path].length; i++) {
+      for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
         if (this._diffDrafts[draft.path][i].id === draft.id) {
           diffDrafts[draft.path][i] = draft;
           this._diffDrafts = diffDrafts;
@@ -270,7 +359,7 @@
         }
       }
       diffDrafts[draft.path].push(draft);
-      diffDrafts[draft.path].sort(function(c1, c2) {
+      diffDrafts[draft.path].sort((c1, c2) => {
         // No line number means that it’s a file comment. Sort it above the
         // others.
         return (c1.line || -1) - (c2.line || -1);
@@ -278,15 +367,15 @@
       this._diffDrafts = diffDrafts;
     },
 
-    _handleCommentDiscard: function(e) {
+    _handleCommentDiscard(e) {
       if (!e.target.comment.__draft) { return; }
 
-      var draft = e.target.comment;
+      const draft = e.target.comment;
       if (!this._diffDrafts[draft.path]) {
         return;
       }
-      var index = -1;
-      for (var i = 0; i < this._diffDrafts[draft.path].length; i++) {
+      let index = -1;
+      for (let i = 0; i < this._diffDrafts[draft.path].length; i++) {
         if (this._diffDrafts[draft.path][i].id === draft.id) {
           index = i;
           break;
@@ -304,8 +393,7 @@
       // because the paths could contain dots in them. A new object must be
       // created to satisfy Polymer’s dirty checking.
       // https://github.com/Polymer/polymer/issues/3127
-      // TODO(andybons): Polyfill for Object.assign in IE.
-      var diffDrafts = Object.assign({}, this._diffDrafts);
+      const diffDrafts = Object.assign({}, this._diffDrafts);
       diffDrafts[draft.path].splice(index, 1);
       if (diffDrafts[draft.path].length === 0) {
         delete diffDrafts[draft.path];
@@ -313,32 +401,44 @@
       this._diffDrafts = diffDrafts;
     },
 
-    _handlePatchChange: function(e) {
-      this._changePatchNum(parseInt(e.target.value, 10), true);
-    },
-
-    _handleReplyTap: function(e) {
+    _handleReplyTap(e) {
       e.preventDefault();
       this._openReplyDialog();
     },
 
-    _handleDownloadTap: function(e) {
-      e.preventDefault();
-      this.$.downloadOverlay.open().then(function() {
+    _handleOpenDiffPrefs() {
+      this.$.fileList.openDiffPrefs();
+    },
+
+
+    _handleOpenIncludedInDialog() {
+      this.$.includedInDialog.loadData().then(() => {
+        Polymer.dom.flush();
+        this.$.includedInOverlay.refit();
+      });
+      this.$.includedInOverlay.open();
+    },
+
+    _handleIncludedInDialogClose(e) {
+      this.$.includedInOverlay.close();
+    },
+
+    _handleOpenDownloadDialog() {
+      this.$.downloadOverlay.open().then(() => {
         this.$.downloadOverlay
             .setFocusStops(this.$.downloadDialog.getFocusStops());
         this.$.downloadDialog.focus();
-      }.bind(this));
+      });
     },
 
-    _handleDownloadDialogClose: function(e) {
+    _handleDownloadDialogClose(e) {
       this.$.downloadOverlay.close();
     },
 
-    _handleMessageReply: function(e) {
-      var msg = e.detail.message.message;
-      var quoteStr = msg.split('\n').map(
-          function(line) { return '> ' + line; }).join('\n') + '\n\n';
+    _handleMessageReply(e) {
+      const msg = e.detail.message.message;
+      const quoteStr = msg.split('\n').map(
+          line => { return '> ' + line; }).join('\n') + '\n\n';
 
       if (quoteStr !== this.$.replyDialog.quote) {
         this.$.replyDialog.draft = quoteStr;
@@ -347,49 +447,71 @@
       this._openReplyDialog();
     },
 
-    _handleReplyOverlayOpen: function(e) {
-      this.$.replyDialog.focus();
+    _handleReplyOverlayOpen(e) {
+      // This is needed so that focus is not set on the reply overlay
+      // when the suggestion overaly from gr-autogrow-textarea opens.
+      if (e.target === this.$.replyOverlay) {
+        this.$.replyDialog.focus();
+      }
     },
 
-    _handleReplySent: function(e) {
+    _handleHideBackgroundContent() {
+      this.$.mainContent.classList.add('overlayOpen');
+    },
+
+    _handleShowBackgroundContent() {
+      this.$.mainContent.classList.remove('overlayOpen');
+    },
+
+    _handleReplySent(e) {
       this.$.replyOverlay.close();
       this._reload();
     },
 
-    _handleReplyCancel: function(e) {
+    _handleReplyCancel(e) {
       this.$.replyOverlay.close();
     },
 
-    _handleReplyAutogrow: function(e) {
-      this.$.replyOverlay.refit();
+    _handleReplyAutogrow(e) {
+      // If the textarea resizes, we need to re-fit the overlay.
+      this.debounce('reply-overlay-refit', () => {
+        this.$.replyOverlay.refit();
+      }, REPLY_REFIT_DEBOUNCE_INTERVAL_MS);
     },
 
-    _handleShowReplyDialog: function(e) {
-      var target = this.$.replyDialog.FocusTarget.REVIEWERS;
+    _handleShowReplyDialog(e) {
+      let target = this.$.replyDialog.FocusTarget.REVIEWERS;
       if (e.detail.value && e.detail.value.ccsOnly) {
         target = this.$.replyDialog.FocusTarget.CCS;
       }
       this._openReplyDialog(target);
     },
 
-    _handleScroll: function() {
-      this.debounce('scroll', function() {
-        history.replaceState(
-            {
-              scrollTop: document.body.scrollTop,
-              path: location.pathname,
-            },
-            location.pathname);
+    _handleScroll() {
+      this.debounce('scroll', () => {
+        this.viewState.scrollTop = document.body.scrollTop;
       }, 150);
     },
 
-    _paramsChanged: function(value) {
-      if (value.view !== this.tagName.toLowerCase()) {
+    _setShownFiles(e) {
+      this._shownFileCount = e.detail.length;
+    },
+
+    _expandAllDiffs() {
+      this.$.fileList.expandAllDiffs();
+    },
+
+    _collapseAllDiffs() {
+      this.$.fileList.collapseAllDiffs();
+    },
+
+    _paramsChanged(value) {
+      if (value.view !== Gerrit.Nav.View.CHANGE) {
         this._initialLoadComplete = false;
         return;
       }
 
-      var patchChanged = this._patchRange &&
+      const patchChanged = this._patchRange &&
           (value.patchNum !== undefined && value.basePatchNum !== undefined) &&
           (this._patchRange.patchNum !== value.patchNum ||
           this._patchRange.basePatchNum !== value.basePatchNum);
@@ -398,53 +520,38 @@
         this._initialLoadComplete = false;
       }
 
-      var patchRange = {
+      const patchRange = {
         patchNum: value.patchNum,
         basePatchNum: value.basePatchNum || 'PARENT',
       };
 
+      this.$.fileList.collapseAllDiffs();
+      this._patchRange = patchRange;
+
       if (this._initialLoadComplete && patchChanged) {
         if (patchRange.patchNum == null) {
-          patchRange.patchNum = this._computeLatestPatchNum(this._allPatchSets);
+          patchRange.patchNum = this.computeLatestPatchNum(this._allPatchSets);
         }
-        this._patchRange = patchRange;
-        this._reloadPatchNumDependentResources().then(function() {
+        this._reloadPatchNumDependentResources().then(() => {
           this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
             change: this._change,
             patchNum: patchRange.patchNum,
           });
-        }.bind(this));
+        });
         return;
       }
 
       this._changeNum = value.changeNum;
-      this._patchRange = patchRange;
       this.$.relatedChanges.clear();
 
-      this._reload().then(function() {
+      this._reload().then(() => {
         this._performPostLoadTasks();
-      }.bind(this));
+      });
     },
 
-    _performPostLoadTasks: function() {
-      // Allow the message list and related changes to render before scrolling.
-      // Related changes are loaded here (after everything else) because they
-      // take the longest and are secondary information. Because the element may
-      // alter the total height of the page, the call to potentially scroll to
-      // a linked message is performed after related changes is fully loaded.
-      this.$.relatedChanges.reload().then(function() {
-        this.async(function() {
-          if (history.state && history.state.scrollTop) {
-            document.documentElement.scrollTop =
-                document.body.scrollTop = history.state.scrollTop;
-          } else {
-            this._maybeScrollToMessage();
-          }
-        }, 1);
-      }.bind(this));
-
+    _performPostLoadTasks() {
+      this.$.relatedChanges.reload();
       this._maybeShowReplyDialog();
-
       this._maybeShowRevertDialog();
 
       this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
@@ -452,13 +559,21 @@
         patchNum: this._patchRange.patchNum,
       });
 
-      this._initialLoadComplete = true;
+      this.async(() => {
+        if (this.viewState.scrollTop) {
+          document.documentElement.scrollTop =
+              document.body.scrollTop = this.viewState.scrollTop;
+        } else {
+          this._maybeScrollToMessage(window.location.hash);
+        }
+        this._initialLoadComplete = true;
+      });
     },
 
-    _paramsAndChangeChanged: function(value) {
+    _paramsAndChangeChanged(value) {
       // If the change number or patch range is different, then reset the
       // selected file index.
-      var patchRangeState = this.viewState.patchRange;
+      const patchRangeState = this.viewState.patchRange;
       if (this.viewState.changeNum !== this._changeNum ||
           patchRangeState.basePatchNum !== this._patchRange.basePatchNum ||
           patchRangeState.patchNum !== this._patchRange.patchNum) {
@@ -466,24 +581,32 @@
       }
     },
 
-    _maybeScrollToMessage: function() {
-      var msgPrefix = '#message-';
-      var hash = window.location.hash;
-      if (hash.indexOf(msgPrefix) === 0) {
+    _viewStateChanged(viewState) {
+      this._numFilesShown = viewState.numFilesShown ?
+          viewState.numFilesShown : DEFAULT_NUM_FILES_SHOWN;
+    },
+
+    _numFilesShownChanged(numFilesShown) {
+      this.viewState.numFilesShown = numFilesShown;
+    },
+
+    _maybeScrollToMessage(hash) {
+      const msgPrefix = '#message-';
+      if (hash.startsWith(msgPrefix)) {
         this.$.messageList.scrollToMessage(hash.substr(msgPrefix.length));
       }
     },
 
-    _getLocationSearch: function() {
+    _getLocationSearch() {
       // Not inlining to make it easier to test.
       return window.location.search;
     },
 
-    _getUrlParameter: function(param) {
-      var pageURL = this._getLocationSearch().substring(1);
-      var vars = pageURL.split('&');
-      for (var i = 0; i < vars.length; i++) {
-        var name = vars[i].split('=');
+    _getUrlParameter(param) {
+      const pageURL = this._getLocationSearch().substring(1);
+      const vars = pageURL.split('&');
+      for (let i = 0; i < vars.length; i++) {
+        const name = vars[i].split('=');
         if (name[0] == param) {
           return name[0];
         }
@@ -491,107 +614,71 @@
       return null;
     },
 
-    _maybeShowRevertDialog: function() {
+    _maybeShowRevertDialog() {
       Gerrit.awaitPluginsLoaded()
-        .then(this._getLoggedIn.bind(this))
-        .then(function(loggedIn) {
-          if (!loggedIn || this._change.status !== this.ChangeStatus.MERGED) {
+          .then(this._getLoggedIn.bind(this))
+          .then(loggedIn => {
+            if (!loggedIn || this._change.status !== this.ChangeStatus.MERGED) {
             // Do not display dialog if not logged-in or the change is not
             // merged.
-            return;
-          }
-          if (!!this._getUrlParameter('revert')) {
-            this.$.actions.showRevertDialog();
-          }
-        }.bind(this));
+              return;
+            }
+            if (this._getUrlParameter('revert')) {
+              this.$.actions.showRevertDialog();
+            }
+          });
     },
 
-    _maybeShowReplyDialog: function() {
-      this._getLoggedIn().then(function(loggedIn) {
+    _maybeShowReplyDialog() {
+      this._getLoggedIn().then(loggedIn => {
         if (!loggedIn) { return; }
 
         if (this.viewState.showReplyDialog) {
           this._openReplyDialog();
-          this.async(function() { this.$.replyOverlay.center(); }, 1);
+          // TODO(kaspern@): Find a better signal for when to call center.
+          this.async(() => { this.$.replyOverlay.center(); }, 100);
+          this.async(() => { this.$.replyOverlay.center(); }, 1000);
           this.set('viewState.showReplyDialog', false);
         }
-      }.bind(this));
+      });
     },
 
-    _resetFileListViewState: function() {
+    _resetFileListViewState() {
       this.set('viewState.selectedFileIndex', 0);
+      this.set('viewState.scrollTop', 0);
       if (!!this.viewState.changeNum &&
           this.viewState.changeNum !== this._changeNum) {
         // Reset the diff mode to null when navigating from one change to
         // another, so that the user's preference is restored.
-        this.set('viewState.diffMode', null);
+        this._setDiffViewMode(true);
+        this.set('_numFilesShown', DEFAULT_NUM_FILES_SHOWN);
       }
       this.set('viewState.changeNum', this._changeNum);
       this.set('viewState.patchRange', this._patchRange);
     },
 
-    _changeChanged: function(change) {
-      if (!change) { return; }
+    _changeChanged(change) {
+      if (!change || !this._patchRange || !this._allPatchSets) { return; }
       this.set('_patchRange.basePatchNum',
           this._patchRange.basePatchNum || 'PARENT');
       this.set('_patchRange.patchNum',
           this._patchRange.patchNum ||
-              this._computeLatestPatchNum(this._allPatchSets));
+              this.computeLatestPatchNum(this._allPatchSets));
 
-      this._updateSelected();
-
-      var title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
-      this.fire('title-change', {title: title});
+      const title = change.subject + ' (' + change.change_id.substr(0, 9) + ')';
+      this.fire('title-change', {title});
     },
 
-    /**
-     * Change active patch to the provided patch num.
-     * @param {number} patchNum the patchn number to be viewed.
-     * @param {boolean} opt_forceParams When set to true, the resulting URL will
-     *     always include the patch range, even if the requested patchNum is
-     *     known to be the latest.
-     */
-    _changePatchNum: function(patchNum, opt_forceParams) {
-      if (!opt_forceParams) {
-        var currentPatchNum;
-        if (this._change.current_revision) {
-          currentPatchNum =
-              this._change.revisions[this._change.current_revision]._number;
-        } else {
-          currentPatchNum = this._computeLatestPatchNum(this._allPatchSets);
-        }
-        if (patchNum === currentPatchNum &&
-            this._patchRange.basePatchNum === 'PARENT') {
-          page.show(this.changePath(this._changeNum));
-          return;
-        }
-      }
-      var patchExpr = this._patchRange.basePatchNum === 'PARENT' ? patchNum :
-          this._patchRange.basePatchNum + '..' + patchNum;
-      page.show(this.changePath(this._changeNum) + '/' + patchExpr);
+    _computeChangeUrl(change) {
+      return Gerrit.Nav.getUrlForChange(change);
     },
 
-    _computeChangePermalink: function(changeNum) {
-      return this.getBaseUrl() + '/' + changeNum;
-    },
-
-    _computeChangeStatus: function(change, patchNum) {
-      var statusString = this.changeStatusString(change);
-      if (change.status === this.ChangeStatus.NEW) {
-        var rev = this.getRevisionByPatchNum(change.revisions, patchNum);
-        if (rev && rev.draft === true) {
-          statusString = 'Draft';
-        }
-      }
-      return statusString;
-    },
-
-    _computeShowCommitInfo: function(changeStatus, current_revision) {
+    _computeShowCommitInfo(changeStatus, current_revision) {
       return changeStatus === 'Merged' && current_revision;
     },
 
-    _computeMergedCommitInfo: function(current_revision, revisions) {
-      var rev = revisions[current_revision];
+    _computeMergedCommitInfo(current_revision, revisions) {
+      const rev = revisions[current_revision];
       if (!rev || !rev.commit) { return {}; }
       // CommitInfo.commit is optional. Set commit in all cases to avoid error
       // in <gr-commit-info>. @see Issue 5337
@@ -599,11 +686,11 @@
       return rev.commit;
     },
 
-    _computeChangeIdClass: function(displayChangeId) {
+    _computeChangeIdClass(displayChangeId) {
       return displayChangeId === CHANGE_ID_ERROR.MISMATCH ? 'warning' : '';
     },
 
-    _computeTitleAttributeWarning: function(displayChangeId) {
+    _computeTitleAttributeWarning(displayChangeId) {
       if (displayChangeId === CHANGE_ID_ERROR.MISMATCH) {
         return 'Change-Id mismatch';
       } else if (displayChangeId === CHANGE_ID_ERROR.MISSING) {
@@ -611,12 +698,12 @@
       }
     },
 
-    _computeChangeIdCommitMessageError: function(commitMessage, change) {
+    _computeChangeIdCommitMessageError(commitMessage, change) {
       if (!commitMessage) { return CHANGE_ID_ERROR.MISSING; }
 
       // Find the last match in the commit message:
-      var changeId;
-      var changeIdArr;
+      let changeId;
+      let changeIdArr;
 
       while (changeIdArr = CHANGE_ID_REGEX_PATTERN.exec(commitMessage)) {
         changeId = changeIdArr[1];
@@ -636,56 +723,19 @@
       return CHANGE_ID_ERROR.MISSING;
     },
 
-    _computeLatestPatchNum: function(allPatchSets) {
-      return allPatchSets[allPatchSets.length - 1].num;
-    },
-
-    _computePatchInfoClass: function(patchNum, allPatchSets) {
-      if (parseInt(patchNum, 10) ===
-          this._computeLatestPatchNum(allPatchSets)) {
-        return '';
-      }
-      return 'patchInfo--oldPatchSet';
-    },
-
-    /**
-     * Determines if a patch number should be disabled based on value of the
-     * basePatchNum from gr-file-list.
-     * @param {Number} patchNum Patch number available in dropdown
-     * @param {Number|String} basePatchNum Base patch number from file list
-     * @return {Boolean}
-     */
-    _computePatchSetDisabled: function(patchNum, basePatchNum) {
-      basePatchNum = basePatchNum === 'PARENT' ? 0 : basePatchNum;
-      return parseInt(patchNum, 10) <= parseInt(basePatchNum, 10);
-    },
-
-    _computeAllPatchSets: function(change) {
-      var patchNums = [];
-      for (var commit in change.revisions) {
-        if (change.revisions.hasOwnProperty(commit)) {
-          patchNums.push({
-            num: change.revisions[commit]._number,
-            desc: change.revisions[commit].description,
-          });
-        }
-      }
-      return patchNums.sort(function(a, b) { return a.num - b.num; });
-    },
-
-    _computeLabelNames: function(labels) {
+    _computeLabelNames(labels) {
       return Object.keys(labels).sort();
     },
 
-    _computeLabelValues: function(labelName, labels) {
-      var result = [];
-      var t = labels[labelName];
+    _computeLabelValues(labelName, labels) {
+      const result = [];
+      const t = labels[labelName];
       if (!t) { return result; }
-      var approvals = t.all || [];
-      approvals.forEach(function(label) {
+      const approvals = t.all || [];
+      for (const label of approvals) {
         if (label.value && label.value != labels[labelName].default_value) {
-          var labelClassName;
-          var labelValPrefix = '';
+          let labelClassName;
+          let labelValPrefix = '';
           if (label.value > 0) {
             labelValPrefix = '+';
             labelClassName = 'approved';
@@ -698,33 +748,44 @@
             account: label,
           });
         }
-      });
+      }
       return result;
     },
 
-    _computeReplyButtonLabel: function(changeRecord) {
-      var drafts = (changeRecord && changeRecord.base) || {};
-      var draftCount = Object.keys(drafts).reduce(function(count, file) {
+    _computeReplyButtonLabel(changeRecord, canStartReview) {
+      if (canStartReview) {
+        return 'Start review';
+      }
+
+      const drafts = (changeRecord && changeRecord.base) || {};
+      const draftCount = Object.keys(drafts).reduce((count, file) => {
         return count + drafts[file].length;
       }, 0);
 
-      var label = 'Reply';
+      let label = 'Reply';
       if (draftCount > 0) {
         label += ' (' + draftCount + ')';
       }
       return label;
     },
 
-    _handleAKey: function(e) {
+    _handleAKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e) ||
-          !this._loggedIn) { return; }
+          this.modifierPressed(e)) {
+        return;
+      }
+      this._getLoggedIn().then(isLoggedIn => {
+        if (!isLoggedIn) {
+          this.fire('show-auth-required');
+          return;
+        }
 
-      e.preventDefault();
-      this._openReplyDialog();
+        e.preventDefault();
+        this._openReplyDialog();
+      });
     },
 
-    _handleDKey: function(e) {
+    _handleDKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -732,13 +793,13 @@
       this.$.downloadOverlay.open();
     },
 
-    _handleCapitalRKey: function(e) {
+    _handleCapitalRKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       e.preventDefault();
-      page.show('/c/' + this._change._number);
+      Gerrit.Nav.navigateToChange(this._change);
     },
 
-    _handleSKey: function(e) {
+    _handleSKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -746,14 +807,15 @@
       this.$.changeStar.toggleStar();
     },
 
-    _handleUKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    _handleUKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
 
       e.preventDefault();
       this._determinePageBack();
     },
 
-    _handleXKey: function(e) {
+    _handleXKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -761,7 +823,7 @@
       this.$.messageList.handleExpandCollapse(true);
     },
 
-    _handleZKey: function(e) {
+    _handleZKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -769,20 +831,26 @@
       this.$.messageList.handleExpandCollapse(false);
     },
 
-    _determinePageBack: function() {
-      // Default backPage to '/' if user came to change view page
-      // via an email link, etc.
-      page.show(this.backPage || '/');
+    _handleCommaKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+
+      e.preventDefault();
+      this.$.fileList.openDiffPrefs();
     },
 
-    _handleLabelRemoved: function(splices, path) {
-      for (var i = 0; i < splices.length; i++) {
-        var splice = splices[i];
-        for (var j = 0; j < splice.removed.length; j++) {
-          var removed = splice.removed[j];
-          var changePath = path.split('.');
-          var labelPath = changePath.splice(0, changePath.length - 2);
-          var labelDict = this.get(labelPath);
+    _determinePageBack() {
+      // Default backPage to '/' if user came to change view page
+      // via an email link, etc.
+      Gerrit.Nav.navigateToRelativeUrl(this.backPage || '/');
+    },
+
+    _handleLabelRemoved(splices, path) {
+      for (const splice of splices) {
+        for (const removed of splice.removed) {
+          const changePath = path.split('.');
+          const labelPath = changePath.splice(0, changePath.length - 2);
+          const labelDict = this.get(labelPath);
           if (labelDict.approved &&
               labelDict.approved._account_id === removed._account_id) {
             this._reload();
@@ -792,9 +860,9 @@
       }
     },
 
-    _labelsChanged: function(changeRecord) {
+    _labelsChanged(changeRecord) {
       if (!changeRecord) { return; }
-      if (changeRecord.value.indexSplices) {
+      if (changeRecord.value && changeRecord.value.indexSplices) {
         this._handleLabelRemoved(changeRecord.value.indexSplices,
             changeRecord.path);
       }
@@ -803,51 +871,55 @@
       });
     },
 
-    _openReplyDialog: function(opt_section) {
-      if (this.$.restAPI.hasPendingDiffDrafts()) {
-        this.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message: COMMENT_SAVE}, bubbles: true}));
-        return;
-      }
-      this.$.replyOverlay.open().then(function() {
+    /**
+     * @param {string=} opt_section
+     */
+    _openReplyDialog(opt_section) {
+      this.$.replyOverlay.open().then(() => {
         this.$.replyOverlay.setFocusStops(this.$.replyDialog.getFocusStops());
         this.$.replyDialog.open(opt_section);
-      }.bind(this));
+        Polymer.dom.flush();
+        this.$.replyOverlay.center();
+      });
     },
 
-    _handleReloadChange: function(e) {
-      return this._reload().then(function() {
-        // If the change was rebased, we need to reload the page with the
-        // latest patch.
-        if (e.detail.action === 'rebase') {
-          page.show(this.changePath(this._changeNum));
+    _handleReloadChange(e) {
+      return this._reload().then(() => {
+        // If the change was rebased or submitted, we need to reload the page
+        // with the latest patch.
+        const action = e.detail.action;
+        if (action === 'rebase' || action === 'submit') {
+          Gerrit.Nav.navigateToChange(this._change);
         }
-      }.bind(this));
+      });
     },
 
-    _handleGetChangeDetailError: function(response) {
-      this.fire('page-error', {response: response});
+    _handleGetChangeDetailError(response) {
+      this.fire('page-error', {response});
     },
 
-    _getDiffDrafts: function() {
-      return this.$.restAPI.getDiffDrafts(this._changeNum).then(
-          function(drafts) {
-            return this._diffDrafts = drafts;
-          }.bind(this));
+    _getDiffDrafts() {
+      return this.$.restAPI.getDiffDrafts(this._changeNum).then(drafts => {
+        this._diffDrafts = drafts;
+      });
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _getProjectConfig: function() {
-      return this.$.restAPI.getProjectConfig(this._change.project).then(
-          function(config) {
-            this._projectConfig = config;
-          }.bind(this));
+    _getServerConfig() {
+      return this.$.restAPI.getConfig();
     },
 
-    _updateRebaseAction: function(revisionActions) {
+    _getProjectConfig() {
+      return this.$.restAPI.getProjectConfig(this._change.project).then(
+          config => {
+            this._projectConfig = config;
+          });
+    },
+
+    _updateRebaseAction(revisionActions) {
       if (revisionActions && revisionActions.rebase) {
         revisionActions.rebase.rebaseOnCurrent =
             !!revisionActions.rebase.enabled;
@@ -856,73 +928,116 @@
       return revisionActions;
     },
 
-    _prepareCommitMsgForLinkify: function(msg) {
+    _prepareCommitMsgForLinkify(msg) {
       // TODO(wyatta) switch linkify sequence, see issue 5526.
       // This is a zero-with space. It is added to prevent the linkify library
-      // from including R= as part of the email address.
-      return msg.replace(REVIEWERS_REGEX, 'R=\u200B');
+      // from including R= or CC= as part of the email address.
+      return msg.replace(REVIEWERS_REGEX, '$1=\u200B');
     },
 
-    _getChangeDetail: function() {
-      return this.$.restAPI.getChangeDetail(this._changeNum,
-          this._handleGetChangeDetailError.bind(this)).then(
-              function(change) {
-                // Issue 4190: Coalesce missing topics to null.
-                if (!change.topic) { change.topic = null; }
-                if (!change.reviewer_updates) {
-                  change.reviewer_updates = null;
-                }
-                var latestRevisionSha = this._getLatestRevisionSHA(change);
-                var currentRevision = change.revisions[latestRevisionSha];
-                if (currentRevision.commit && currentRevision.commit.message) {
-                  this._latestCommitMessage = this._prepareCommitMsgForLinkify(
-                      currentRevision.commit.message);
-                } else {
-                  this._latestCommitMessage = null;
-                }
-                var lineHeight = getComputedStyle(this).lineHeight;
-                this._lineHeight = lineHeight.slice(0, lineHeight.length - 2);
+    /**
+     * Utility function to make the necessary modifications to a change in the
+     * case an edit exists.
+     *
+     * @param {!Object} change
+     * @param {?Object} edit
+     */
+    _processEdit(change, edit) {
+      if (!edit) { return; }
+      change.revisions[edit.commit.commit] = {
+        _number: this.EDIT_NAME,
+        basePatchNum: edit.base_patch_set_number,
+        commit: edit.commit,
+        fetch: edit.fetch,
+      };
+      // If the edit is based on the most recent patchset, load it by
+      // default, unless another patch set to load was specified in the URL.
+      if (!this._patchRange.patchNum &&
+          change.current_revision === edit.base_revision) {
+        change.current_revision = edit.commit.commit;
+        this._patchRange.patchNum = this.EDIT_NAME;
+        // Because edits are fibbed as revisions and added to the revisions
+        // array, and revision actions are always derived from the 'latest'
+        // patch set, we must copy over actions from the patch set base.
+        // Context: Issue 7243
+        change.revisions[edit.commit.commit].actions =
+            change.revisions[edit.base_revision].actions;
+      }
+    },
 
-                this._change = change;
-                if (!this._patchRange || !this._patchRange.patchNum ||
-                    this._patchRange.patchNum === currentRevision._number) {
-                  // CommitInfo.commit is optional, and may need patching.
-                  if (!currentRevision.commit.commit) {
-                    currentRevision.commit.commit = latestRevisionSha;
-                  }
-                  this._commitInfo = currentRevision.commit;
-                  this._currentRevisionActions =
+    _getChangeDetail() {
+      const detailCompletes = this.$.restAPI.getChangeDetail(
+          this._changeNum, this._handleGetChangeDetailError.bind(this));
+      const editCompletes = this._getEdit();
+
+      return Promise.all([detailCompletes, editCompletes])
+          .then(([change, edit]) => {
+            if (!change) {
+              return '';
+            }
+            this._processEdit(change, edit);
+            // Issue 4190: Coalesce missing topics to null.
+            if (!change.topic) { change.topic = null; }
+            if (!change.reviewer_updates) {
+              change.reviewer_updates = null;
+            }
+            const latestRevisionSha = this._getLatestRevisionSHA(change);
+            const currentRevision = change.revisions[latestRevisionSha];
+            if (currentRevision.commit && currentRevision.commit.message) {
+              this._latestCommitMessage = this._prepareCommitMsgForLinkify(
+                  currentRevision.commit.message);
+            } else {
+              this._latestCommitMessage = null;
+            }
+            const lineHeight = getComputedStyle(this).lineHeight;
+
+            // Slice returns a number as a string, convert to an int.
+            this._lineHeight =
+                parseInt(lineHeight.slice(0, lineHeight.length - 2), 10);
+
+            this._change = change;
+            if (!this._patchRange || !this._patchRange.patchNum ||
+                this.patchNumEquals(this._patchRange.patchNum,
+                    currentRevision._number)) {
+              // CommitInfo.commit is optional, and may need patching.
+              if (!currentRevision.commit.commit) {
+                currentRevision.commit.commit = latestRevisionSha;
+              }
+              this._commitInfo = currentRevision.commit;
+              this._currentRevisionActions =
                       this._updateRebaseAction(currentRevision.actions);
                   // TODO: Fetch and process files.
-                }
-              }.bind(this));
+            }
+          });
     },
 
-    _getComments: function() {
-      return this.$.restAPI.getDiffComments(this._changeNum).then(
-          function(comments) {
-            this._comments = comments;
-          }.bind(this));
+    _getComments() {
+      return this.$.restAPI.getDiffComments(this._changeNum).then(comments => {
+        this._comments = comments;
+      });
     },
 
-    _getLatestCommitMessage: function() {
+    _getEdit() {
+      return this.$.restAPI.getChangeEdit(this._changeNum, true);
+    },
+
+    _getLatestCommitMessage() {
       return this.$.restAPI.getChangeCommitInfo(this._changeNum,
-          this._computeLatestPatchNum(this._allPatchSets)).then(
-              function(commitInfo) {
-                this._latestCommitMessage =
+          this.computeLatestPatchNum(this._allPatchSets)).then(commitInfo => {
+            this._latestCommitMessage =
                     this._prepareCommitMsgForLinkify(commitInfo.message);
-              }.bind(this));
+          });
     },
 
-    _getLatestRevisionSHA: function(change) {
+    _getLatestRevisionSHA(change) {
       if (change.current_revision) {
         return change.current_revision;
       }
       // current_revision may not be present in the case where the latest rev is
       // a draft and the user doesn’t have permission to view that rev.
-      var latestRev = null;
-      var latestPatchNum = -1;
-      for (var rev in change.revisions) {
+      let latestRev = null;
+      let latestPatchNum = -1;
+      for (const rev in change.revisions) {
         if (!change.revisions.hasOwnProperty(rev)) { continue; }
 
         if (change.revisions[rev]._number > latestPatchNum) {
@@ -933,54 +1048,54 @@
       return latestRev;
     },
 
-    _getCommitInfo: function() {
+    _getCommitInfo() {
       return this.$.restAPI.getChangeCommitInfo(
           this._changeNum, this._patchRange.patchNum).then(
-              function(commitInfo) {
-                this._commitInfo = commitInfo;
-              }.bind(this));
+          commitInfo => {
+            this._commitInfo = commitInfo;
+          });
     },
 
-    _reloadDiffDrafts: function() {
+    _reloadDiffDrafts() {
       this._diffDrafts = {};
-      this._getDiffDrafts().then(function() {
+      this._getDiffDrafts().then(() => {
         if (this.$.replyOverlay.opened) {
-          this.async(function() { this.$.replyOverlay.center(); }, 1);
+          this.async(() => { this.$.replyOverlay.center(); }, 1);
         }
-      }.bind(this));
+      });
     },
 
-    _reload: function() {
+    _reload() {
       this._loading = true;
       this._relatedChangesCollapsed = true;
 
-      this._getLoggedIn().then(function(loggedIn) {
+      this._getLoggedIn().then(loggedIn => {
         if (!loggedIn) { return; }
 
         this._reloadDiffDrafts();
-      }.bind(this));
+      });
 
-      var detailCompletes = this._getChangeDetail().then(function() {
+      const detailCompletes = this._getChangeDetail().then(() => {
         this._loading = false;
         this._getProjectConfig();
-      }.bind(this));
+      });
       this._getComments();
 
       if (this._patchRange.patchNum) {
         return Promise.all([
           this._reloadPatchNumDependentResources(),
           detailCompletes,
-        ]).then(function() {
+        ]).then(() => {
           return this.$.actions.reload();
-        }.bind(this));
+        });
       } else {
         // The patch number is reliant on the change detail request.
-        return detailCompletes.then(function() {
+        return detailCompletes.then(() => {
           this.$.fileList.reload();
           if (!this._latestCommitMessage) {
             this._getLatestCommitMessage();
           }
-        }.bind(this));
+        });
       }
     },
 
@@ -988,115 +1103,75 @@
      * Kicks off requests for resources that rely on the patch range
      * (`this._patchRange`) being defined.
      */
-    _reloadPatchNumDependentResources: function() {
+    _reloadPatchNumDependentResources() {
       return Promise.all([
         this._getCommitInfo(),
         this.$.fileList.reload(),
       ]);
     },
 
-    _updateSelected: function() {
-      this._selectedPatchSet = this._patchRange.patchNum;
+    _computeCanStartReview(change) {
+      return !!(change.actions && change.actions.ready &&
+          change.actions.ready.enabled);
     },
 
-    _computePatchSetDescription: function(change, patchNum) {
-      var rev = this.getRevisionByPatchNum(change.revisions, patchNum);
-      return (rev && rev.description) ?
-          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
-    },
+    _computeReplyDisabled() { return false; },
 
-    _computeDescriptionPlaceholder: function(readOnly) {
-      return (readOnly ? 'No' : 'Add a') + ' patch set description';
-    },
-
-    _handleDescriptionChanged: function(e) {
-      var desc = e.detail.trim();
-      var rev = this.getRevisionByPatchNum(this._change.revisions,
-          this._selectedPatchSet);
-      var sha = this._getPatchsetHash(this._change.revisions, rev);
-      this.$.restAPI.setDescription(this._changeNum,
-          this._selectedPatchSet, desc)
-          .then(function(res) {
-            if (res.ok) {
-              this.set(['_change', 'revisions', sha, 'description'], desc);
-            }
-          }.bind(this));
-    },
-
-
-    /**
-     * @param {Object} revisions The revisions object keyed by revision hashes
-     * @param {Object} patchSet A revision already fetched from {revisions}
-     * @return {string} the SHA hash corresponding to the revision.
-     */
-    _getPatchsetHash: function(revisions, patchSet) {
-      for (var rev in revisions) {
-        if (revisions.hasOwnProperty(rev) &&
-            revisions[rev] === patchSet) {
-          return rev;
-        }
-      }
-    },
-
-    _computeDescriptionReadOnly: function(loggedIn, change, account) {
-      return !(loggedIn && (account._account_id === change.owner._account_id));
-    },
-
-    _computeReplyDisabled: function() { return false; },
-
-    _computeChangePermalinkAriaLabel: function(changeNum) {
+    _computeChangePermalinkAriaLabel(changeNum) {
       return 'Change ' + changeNum;
     },
 
-    _computeCommitClass: function(collapsed, commitMessage) {
+    _computeCommitClass(collapsed, commitMessage) {
       if (this._computeCommitToggleHidden(commitMessage)) { return ''; }
       return collapsed ? 'collapsed' : '';
     },
 
-    _computeRelatedChangesClass: function(collapsed, loading) {
+    _computeRelatedChangesClass(collapsed, loading) {
+      // TODO(beckysiegel) figure out how to check for customstyle in Polymer2,
+      // since customStyle was removed.
       if (!loading && !this.customStyle['--relation-chain-max-height']) {
         this._updateRelatedChangeMaxHeight();
       }
       return collapsed ? 'collapsed' : '';
     },
 
-    _computeCollapseText: function(collapsed) {
+    _computeCollapseText(collapsed) {
       // Symbols are up and down triangles.
       return collapsed ? '\u25bc Show more' : '\u25b2 Show less';
     },
 
-    _toggleCommitCollapsed: function() {
+    _toggleCommitCollapsed() {
       this._commitCollapsed = !this._commitCollapsed;
       if (this._commitCollapsed) {
         window.scrollTo(0, 0);
       }
     },
 
-    _toggleRelatedChangesCollapsed: function() {
+    _toggleRelatedChangesCollapsed() {
       this._relatedChangesCollapsed = !this._relatedChangesCollapsed;
       if (this._relatedChangesCollapsed) {
         window.scrollTo(0, 0);
       }
     },
 
-    _computeCommitToggleHidden: function(commitMessage) {
+    _computeCommitToggleHidden(commitMessage) {
       if (!commitMessage) { return true; }
       return commitMessage.split('\n').length < MIN_LINES_FOR_COMMIT_COLLAPSE;
     },
 
-    _getOffsetHeight: function(element) {
+    _getOffsetHeight(element) {
       return element.offsetHeight;
     },
 
-    _getScrollHeight: function(element) {
+    _getScrollHeight(element) {
       return element.scrollHeight;
     },
 
     /**
      * Get the line height of an element to the nearest integer.
      */
-    _getLineHeight: function(element) {
-      var lineHeightStr = getComputedStyle(element).lineHeight;
+    _getLineHeight(element) {
+      const lineHeightStr = getComputedStyle(element).lineHeight;
       return Math.round(lineHeightStr.slice(0, lineHeightStr.length - 2));
     },
 
@@ -1104,41 +1179,127 @@
      * New max height for the related changes section, shorter than the existing
      * change info height.
      */
-    _updateRelatedChangeMaxHeight: function() {
+    _updateRelatedChangeMaxHeight() {
       // Takes into account approximate height for the expand button and
-      // bottom margin
-      var extraHeight = 24;
-      var maxExistingHeight;
-      var hasCommitToggle =
+      // bottom margin.
+      const EXTRA_HEIGHT = 30;
+      let newHeight;
+      const hasCommitToggle =
           !this._computeCommitToggleHidden(this._latestCommitMessage);
-      if (hasCommitToggle) {
-        // Make sure the content is lined up if both areas have buttons. If the
-        // commit message is not collapsed, instead use the change info hight.
-        maxExistingHeight = this._getOffsetHeight(this.$.commitMessage);
+
+      if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_SMALL})`)
+          .matches) {
+        // In a small (mobile) view, give the relation chain some space.
+        newHeight = SMALL_RELATED_HEIGHT;
+      } else if (window.matchMedia(`(max-width: ${BREAKPOINT_RELATED_MED})`)
+          .matches) {
+        // Since related changes are below the commit message, but still next to
+        // metadata, the height should be the height of the metadata minus the
+        // height of the commit message to reduce jank. However, if that doesn't
+        // result in enough space, instead use the MINIMUM_RELATED_MAX_HEIGHT.
+        // Note: extraHeight is to take into account margin/padding.
+        const medRelatedHeight = Math.max(
+            this._getOffsetHeight(this.$.mainChangeInfo) -
+            this._getOffsetHeight(this.$.commitMessage) - 2 * EXTRA_HEIGHT,
+            MINIMUM_RELATED_MAX_HEIGHT);
+        newHeight = medRelatedHeight;
       } else {
-        maxExistingHeight = this._getOffsetHeight(this.$.mainChangeInfo) -
-            extraHeight;
+        if (hasCommitToggle) {
+          // Make sure the content is lined up if both areas have buttons. If
+          // the commit message is not collapsed, instead use the change info
+          // height.
+          newHeight = this._getOffsetHeight(this.$.commitMessage);
+        } else {
+          newHeight = this._getOffsetHeight(this.$.commitAndRelated) -
+              EXTRA_HEIGHT;
+        }
       }
+      const stylesToUpdate = {};
 
       // Get the line height of related changes, and convert it to the nearest
       // integer.
-      var lineHeight = this._getLineHeight(this.$.relatedChanges);
+      const lineHeight = this._getLineHeight(this.$.relatedChanges);
 
       // Figure out a new height that is divisible by the rounded line height.
-      var remainder = maxExistingHeight % lineHeight;
-      var newHeight = maxExistingHeight - remainder;
+      const remainder = newHeight % lineHeight;
+      newHeight = newHeight - remainder;
 
-      // Update the max-height of the relation chain to this new height;
-      this.customStyle['--relation-chain-max-height'] = newHeight + 'px';
+      stylesToUpdate['--relation-chain-max-height'] = newHeight + 'px';
+
+      // Update the max-height of the relation chain to this new height.
       if (hasCommitToggle) {
-        this.customStyle['--related-change-btn-top-padding'] = remainder + 'px';
+        stylesToUpdate['--related-change-btn-top-padding'] = remainder + 'px';
       }
-      this.updateStyles();
+
+      this.updateStyles(stylesToUpdate);
     },
 
-    _computeRelatedChangesToggleHidden: function() {
-      return this._getScrollHeight(this.$.relatedChanges) <=
-          this._getOffsetHeight(this.$.relatedChanges);
+    _computeRelatedChangesToggleClass() {
+      // Prevents showMore from showing when click on related change, since the
+      // line height would be positive, but related changes height is 0.
+      if (!this._getScrollHeight(this.$.relatedChanges)) { return ''; }
+
+      return this._getScrollHeight(this.$.relatedChanges) >
+          (this._getOffsetHeight(this.$.relatedChanges) +
+          this._getLineHeight(this.$.relatedChanges)) ? 'showToggle' : '';
+    },
+
+    _startUpdateCheckTimer() {
+      if (!this._serverConfig ||
+          !this._serverConfig.change ||
+          this._serverConfig.change.update_delay === undefined ||
+          this._serverConfig.change.update_delay <= MIN_CHECK_INTERVAL_SECS) {
+        return;
+      }
+
+      this._updateCheckTimerHandle = this.async(() => {
+        this.fetchIsLatestKnown(this._change, this.$.restAPI)
+            .then(latest => {
+              if (latest) {
+                this._startUpdateCheckTimer();
+              } else {
+                this._cancelUpdateCheckTimer();
+                this.fire('show-alert', {
+                  message: 'A newer patch set has been uploaded.',
+                  // Persist this alert.
+                  dismissOnNavigation: true,
+                  action: 'Reload',
+                  callback: function() {
+                    // Load the current change without any patch range.
+                    Gerrit.Nav.navigateToChange(this._change);
+                  }.bind(this),
+                });
+              }
+            });
+      }, this._serverConfig.change.update_delay * 1000);
+    },
+
+    _cancelUpdateCheckTimer() {
+      if (this._updateCheckTimerHandle) {
+        this.cancelAsync(this._updateCheckTimerHandle);
+      }
+      this._updateCheckTimerHandle = null;
+    },
+
+    _handleVisibilityChange() {
+      if (document.hidden && this._updateCheckTimerHandle) {
+        this._cancelUpdateCheckTimer();
+      } else if (!this._updateCheckTimerHandle) {
+        this._startUpdateCheckTimer();
+      }
+    },
+
+    _handleTopicChanged() {
+      this.$.relatedChanges.reload();
+    },
+
+    _computeHeaderClass(change) {
+      return change.work_in_progress ? 'header wip' : 'header';
+    },
+
+    _computeEditLoaded(patchRangeRecord) {
+      const patchRange = patchRangeRecord.base || {};
+      return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index 28164cd..b37fac5 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-change-view.html">
 
 <script>void(0);</script>
@@ -33,202 +33,265 @@
   </template>
 </test-fixture>
 
-<script>
-  suite('gr-change-view tests', function() {
-    var element;
-    var sandbox;
-    var showStub;
-    var TEST_SCROLL_TOP_PX = 100;
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
 
-    setup(function() {
+<script>
+  suite('gr-change-view tests', () => {
+    let element;
+    let sandbox;
+    let navigateToChangeStub;
+    const TEST_SCROLL_TOP_PX = 100;
+
+    setup(() => {
       sandbox = sinon.sandbox.create();
-      showStub = sandbox.stub(page, 'show');
+      navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getAccount: function() { return Promise.resolve(null); },
+        getConfig() { return Promise.resolve({test: 'config'}); },
+        getAccount() { return Promise.resolve(null); },
+        _fetchSharedCacheURL() { return Promise.resolve({}); },
       });
       element = fixture('basic');
     });
 
-    teardown(function(done) {
-      flush(function() {
+    teardown(done => {
+      flush(() => {
         sandbox.restore();
         done();
       });
     });
 
-    suite('keyboard shortcuts', function() {
-      test('S should toggle the CL star', function() {
-        var starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
+    suite('keyboard shortcuts', () => {
+      setup(() => {
+        sandbox.stub(element, '_updateSortedRevisions');
+      });
+
+      test('S should toggle the CL star', () => {
+        const starStub = sandbox.stub(element.$.changeStar, 'toggleStar');
         MockInteractions.pressAndReleaseKeyOn(element, 83, null, 's');
         assert(starStub.called);
       });
 
-      test('U should navigate to / if no backPage set', function() {
+      test('U should navigate to / if no backPage set', () => {
+        const relativeNavStub = sandbox.stub(Gerrit.Nav,
+            'navigateToRelativeUrl');
         MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-        assert(showStub.lastCall.calledWithExactly('/'));
+        assert.isTrue(relativeNavStub.called);
+        assert.isTrue(relativeNavStub.lastCall.calledWithExactly('/'));
       });
 
-      test('U should navigate to backPage if set', function() {
+      test('U should navigate to backPage if set', () => {
+        const relativeNavStub = sandbox.stub(Gerrit.Nav,
+            'navigateToRelativeUrl');
         element.backPage = '/dashboard/self';
         MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-        assert(showStub.lastCall.calledWithExactly('/dashboard/self'));
+        assert.isTrue(relativeNavStub.called);
+        assert.isTrue(relativeNavStub.lastCall.calledWithExactly(
+            '/dashboard/self'));
       });
 
-      test('A should toggle overlay', function() {
+      test('A fires an error event when not logged in', done => {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(false));
+        const loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
         MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        var overlayEl = element.$.replyOverlay;
-        assert.isFalse(overlayEl.opened);
-        element._loggedIn = true;
+        flush(() => {
+          assert.isFalse(element.$.replyOverlay.opened);
+          assert.isTrue(loggedInErrorSpy.called);
+          done();
+        });
+      });
 
+      test('shift A does not open reply overlay', done => {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
         MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
-        assert.isFalse(overlayEl.opened);
-
-        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-        assert.isTrue(overlayEl.opened);
-        overlayEl.close();
-        assert.isFalse(overlayEl.opened);
+        flush(() => {
+          assert.isFalse(element.$.replyOverlay.opened);
+          done();
+        });
       });
 
-      test('X should expand all messages', function() {
-        var handleExpand =
+      test('A toggles overlay when logged in', done => {
+        sandbox.stub(element, '_getLoggedIn').returns(Promise.resolve(true));
+        sandbox.stub(element.$.replyDialog, 'fetchIsLatestKnown')
+            .returns(Promise.resolve(true));
+        element._change = {labels: {}};
+        MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
+        flush(() => {
+          assert.isTrue(element.$.replyOverlay.opened);
+          element.$.replyOverlay.close();
+          assert.isFalse(element.$.replyOverlay.opened);
+          done();
+        });
+      });
+
+      test('fullscreen-overlay-opened hides content', () => {
+        element._loggedIn = true;
+        element._loading = false;
+        element._change = {
+          owner: {_account_id: 1},
+          labels: {},
+          actions: {
+            abandon: {
+              enabled: true,
+              label: 'Abandon',
+              method: 'POST',
+              title: 'Abandon',
+            },
+          },
+        };
+        sandbox.spy(element, '_handleHideBackgroundContent');
+        element.$.replyDialog.fire('fullscreen-overlay-opened');
+        assert.isTrue(element._handleHideBackgroundContent.called);
+        assert.isTrue(element.$.mainContent.classList.contains('overlayOpen'));
+        assert.equal(getComputedStyle(element.$.actions).display, 'block');
+      });
+
+      test('fullscreen-overlay-closed shows content', () => {
+        element._loggedIn = true;
+        element._loading = false;
+        element._change = {
+          owner: {_account_id: 1},
+          labels: {},
+          actions: {
+            abandon: {
+              enabled: true,
+              label: 'Abandon',
+              method: 'POST',
+              title: 'Abandon',
+            },
+          },
+        };
+        sandbox.spy(element, '_handleShowBackgroundContent');
+        element.$.replyDialog.fire('fullscreen-overlay-closed');
+        assert.isTrue(element._handleShowBackgroundContent.called);
+        assert.isFalse(element.$.mainContent.classList.contains('overlayOpen'));
+      });
+
+      test('expand all messages when expand-diffs fired', () => {
+        const handleExpand =
+            sandbox.stub(element.$.fileList, 'expandAllDiffs');
+        element.$.fileListHeader.fire('expand-diffs');
+        assert.isTrue(handleExpand.called);
+      });
+
+      test('collapse all messages when collapse-diffs fired', () => {
+        const handleCollapse =
+        sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+        element.$.fileListHeader.fire('collapse-diffs');
+        assert.isTrue(handleCollapse.called);
+      });
+
+      test('X should expand all messages', () => {
+        const handleExpand =
             sandbox.stub(element.$.messageList, 'handleExpandCollapse');
         MockInteractions.pressAndReleaseKeyOn(element, 88, null, 'x');
         assert(handleExpand.calledWith(true));
       });
 
-      test('Z should collapse all messages', function() {
-         var handleExpand =
+      test('Z should collapse all messages', () => {
+        const handleExpand =
             sandbox.stub(element.$.messageList, 'handleExpandCollapse');
         MockInteractions.pressAndReleaseKeyOn(element, 90, null, 'z');
         assert(handleExpand.calledWith(false));
       });
 
       test('shift + R should fetch and navigate to the latest patch set',
-          function(done) {
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: 1,
-        };
-        element._change = {
-          change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-          _number: 42,
-          revisions: {
-            rev1: {_number: 1},
-          },
-          current_revision: 'rev1',
-          status: 'NEW',
-          labels: {},
-          actions: {},
-        };
+          done => {
+            element._changeNum = '42';
+            element._patchRange = {
+              basePatchNum: 'PARENT',
+              patchNum: 1,
+            };
+            element._change = {
+              change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+              _number: 42,
+              revisions: {
+                rev1: {_number: 1},
+              },
+              current_revision: 'rev1',
+              status: 'NEW',
+              labels: {},
+              actions: {},
+            };
 
-        sandbox.stub(element.$.actions, 'reload');
+            sandbox.stub(element.$.actions, 'reload');
 
-        showStub.restore();
-        showStub = sandbox.stub(page, 'show', function(arg) {
-          assert.equal(arg, '/c/42');
-          done();
-        });
+            navigateToChangeStub.restore();
+            navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange',
+                (change, patchNum, basePatchNum) => {
+                  assert.equal(change, element._change);
+                  assert.isUndefined(patchNum);
+                  assert.isUndefined(basePatchNum);
+                  done();
+                });
 
-        MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+            MockInteractions.pressAndReleaseKeyOn(element, 82, 'shift', 'r');
+          });
+
+      test('d should open download overlay', () => {
+        const stub = sandbox.stub(element.$.downloadOverlay, 'open');
+        MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+        assert.isTrue(stub.called);
       });
 
-      test('d should open download overlay', function() {
-        var stub = sandbox.stub(element.$.downloadOverlay, 'open');
-        MockInteractions.pressAndReleaseKeyOn(element, 68, null, 'd');
+      test(', should open diff preferences', () => {
+        const stub = sandbox.stub(element.$.fileList.$.diffPreferences, 'open');
+        MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
         assert.isTrue(stub.called);
       });
     });
 
-    test('_computeDescriptionReadOnly', function() {
-      assert.equal(element._computeDescriptionReadOnly(false,
-          {owner: {_account_id: 1}}, {_account_id: 1}), true);
-      assert.equal(element._computeDescriptionReadOnly(true,
-          {owner: {_account_id: 0}}, {_account_id: 1}), true);
-      assert.equal(element._computeDescriptionReadOnly(true,
-          {owner: {_account_id: 1}}, {_account_id: 1}), false);
+    test('download tap calls _handleOpenDownloadDialog', () => {
+      sandbox.stub(element, '_handleOpenDownloadDialog');
+      element.$.actions.fire('download-tap');
+      assert.isTrue(element._handleOpenDownloadDialog.called);
     });
 
-    test('_computeDescriptionPlaceholder', function() {
-      assert.equal(element._computeDescriptionPlaceholder(true),
-          'No patch set description');
-      assert.equal(element._computeDescriptionPlaceholder(false),
-          'Add a patch set description');
+    test('fetches the server config on attached', done => {
+      flush(() => {
+        assert.equal(element._serverConfig.test, 'config');
+        done();
+      });
     });
 
-    test('_computePatchSetDisabled', function() {
-      var basePatchNum = 'PARENT';
-      var patchNum = 1;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          false);
-      basePatchNum = 1;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          true);
-      patchNum = 2;
-      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
-          false);
+    test('diff preferences open when open-diff-prefs is fired', () => {
+      const overlayOpenStub = sandbox.stub(element.$.fileList,
+          'openDiffPrefs');
+      element.$.fileListHeader.fire('open-diff-prefs');
+      assert.isTrue(overlayOpenStub.called);
     });
 
-    test('_prepareCommitMsgForLinkify', function() {
-      var commitMessage = 'R=test@google.com';
-      var result = element._prepareCommitMsgForLinkify(commitMessage);
+    test('_prepareCommitMsgForLinkify', () => {
+      let commitMessage = 'R=test@google.com';
+      let result = element._prepareCommitMsgForLinkify(commitMessage);
       assert.equal(result, 'R=\u200Btest@google.com');
 
       commitMessage = 'R=test@google.com\nR=test@google.com';
-      var result = element._prepareCommitMsgForLinkify(commitMessage);
+      result = element._prepareCommitMsgForLinkify(commitMessage);
       assert.equal(result, 'R=\u200Btest@google.com\nR=\u200Btest@google.com');
+
+      commitMessage = 'CC=test@google.com';
+      result = element._prepareCommitMsgForLinkify(commitMessage);
+      assert.equal(result, 'CC=\u200Btest@google.com');
     }),
 
-    test('_handleDescriptionChanged', function() {
-      var putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
-          .returns(Promise.resolve({ok: true}));
-      sandbox.stub(element, '_computeDescriptionReadOnly');
-
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._selectedPatchNum = '1';
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-        actions: {},
-        owner: {_account_id: 1},
-      };
-      element._account = {_account_id: 1};
-      element._loggedIn = true;
-
-      flushAsynchronousOperations();
-      var label = element.$.descriptionLabel;
-      assert.equal(label.value, 'test');
-      label.editing = true;
-      label._inputText = 'test2';
-      label._save();
-      flushAsynchronousOperations();
-      assert.isTrue(putDescStub.called);
-      assert.equal(putDescStub.args[0][2], 'test2');
-    });
-
-    test('_updateRebaseAction', function() {
-      var currentRevisionActions = {
+    test('_updateRebaseAction', () => {
+      const currentRevisionActions = {
         cherrypick: {
           enabled: true,
           label: 'Cherry Pick',
           method: 'POST',
-          title: 'cherrypick'
+          title: 'cherrypick',
         },
         rebase: {
           enabled: true,
           label: 'Rebase',
           method: 'POST',
-          title: 'Rebase onto tip of branch or parent change'
+          title: 'Rebase onto tip of branch or parent change',
         },
       };
 
@@ -236,7 +299,7 @@
       // When rebase is enabled initially, rebaseOnCurrent should be set to
       // true.
       assert.equal(element._updateRebaseAction(currentRevisionActions),
-        currentRevisionActions);
+          currentRevisionActions);
 
       assert.isTrue(currentRevisionActions.rebase.enabled);
       assert.isTrue(currentRevisionActions.rebase.rebaseOnCurrent);
@@ -246,14 +309,14 @@
       // When rebase is not enabled initially, rebaseOnCurrent should be set to
       // false.
       assert.equal(element._updateRebaseAction(currentRevisionActions),
-        currentRevisionActions);
+          currentRevisionActions);
 
       assert.isTrue(currentRevisionActions.rebase.enabled);
       assert.isFalse(currentRevisionActions.rebase.rebaseOnCurrent);
     });
 
-    test('_reload is called when an approved label is removed', function() {
-      var vote = {_account_id: 1, name: 'bojack', value: 1};
+    test('_reload is called when an approved label is removed', () => {
+      const vote = {_account_id: 1, name: 'bojack', value: 1};
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -279,7 +342,7 @@
         },
       };
       flushAsynchronousOperations();
-      var reloadStub = sandbox.stub(element, '_reload');
+      const reloadStub = sandbox.stub(element, '_reload');
       element.splice('_change.labels.test.all', 0, 1);
       assert.isFalse(reloadStub.called);
       element._change.labels.test.all.push(vote);
@@ -291,30 +354,37 @@
       assert.isTrue(reloadStub.calledOnce);
     });
 
-    test('reply button has updated count when there are drafts', function() {
-      var replyButton = element.$$('gr-button.reply');
-      assert.ok(replyButton);
-      assert.equal(replyButton.textContent, 'Reply');
+    test('reply button has updated count when there are drafts', () => {
+      const getLabel = element._computeReplyButtonLabel;
 
-      element._diffDrafts = null;
-      assert.equal(replyButton.textContent, 'Reply');
+      assert.equal(getLabel(null, false), 'Reply');
+      assert.equal(getLabel(null, true), 'Start review');
 
-      element._diffDrafts = {};
-      assert.equal(replyButton.textContent, 'Reply');
+      const changeRecord = {base: null};
+      assert.equal(getLabel(changeRecord, false), 'Reply');
 
-      element._diffDrafts = {
+      changeRecord.base = {};
+      assert.equal(getLabel(changeRecord, false), 'Reply');
+
+      changeRecord.base = {
         'file1.txt': [{}],
         'file2.txt': [{}, {}],
       };
-      assert.equal(replyButton.textContent, 'Reply (3)');
+      assert.equal(getLabel(changeRecord, false), 'Reply (3)');
     });
 
-    test('comment events properly update diff drafts', function() {
+    test('start review button when owner of WIP change', () => {
+      assert.equal(
+          element._computeReplyButtonLabel(null, true),
+          'Start review');
+    });
+
+    test('comment events properly update diff drafts', () => {
       element._patchRange = {
         basePatchNum: 'PARENT',
         patchNum: 2,
       };
-      var draft = {
+      const draft = {
         __draft: true,
         id: 'id1',
         path: '/foo/bar.txt',
@@ -328,7 +398,7 @@
       element._handleCommentSave({target: {comment: draft}});
       draft.patch_set = 2;
       assert.deepEqual(element._diffDrafts, {'/foo/bar.txt': [draft]});
-      var draft2 = {
+      const draft2 = {
         __draft: true,
         id: 'id2',
         path: '/foo/bar.txt',
@@ -345,7 +415,8 @@
       assert.deepEqual(element._diffDrafts, {});
     });
 
-    test('change num change', function() {
+    test('change num change', () => {
+      sandbox.stub(element, '_updateSortedRevisions');
       element._changeNum = null;
       element._patchRange = {
         basePatchNum: 'PARENT',
@@ -357,8 +428,12 @@
       };
       element.viewState.changeNum = null;
       element.viewState.diffMode = 'UNIFIED';
+      assert.equal(element.viewState.numFilesShown, 200);
+      assert.equal(element._numFilesShown, 200);
+      element._numFilesShown = 150;
       flushAsynchronousOperations();
       assert.equal(element.viewState.diffMode, 'UNIFIED');
+      assert.equal(element.viewState.numFilesShown, 150);
 
       element._changeNum = '1';
       element.params = {changeNum: '1'};
@@ -371,120 +446,70 @@
       element.params = {changeNum: '2'};
       element._change.newProp = '2';
       flushAsynchronousOperations();
-      assert.isNull(element.viewState.diffMode);
+      assert.equal(element.viewState.diffMode, 'UNIFIED');
       assert.equal(element.viewState.changeNum, '2');
+      assert.equal(element.viewState.numFilesShown, 200);
+      assert.equal(element._numFilesShown, 200);
     });
 
-    test('patch num change', function(done) {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
-        },
-        current_revision: 'rev3',
-        status: 'NEW',
-        labels: {},
-      };
-      element.viewState.diffMode = 'UNIFIED';
-      flushAsynchronousOperations();
+    test('_setDiffViewMode is called with reset when new change is loaded',
+        () => {
+          sandbox.stub(element, '_setDiffViewMode');
+          element.viewState = {changeNum: 1};
+          element._changeNum = 2;
+          element._resetFileListViewState();
+          assert.isTrue(
+              element._setDiffViewMode.lastCall.calledWithExactly(true));
+        });
 
-      var selectEl = element.$$('.patchInfo-header select');
-      assert.ok(selectEl);
-      var optionEls = Polymer.dom(element.root).querySelectorAll(
-          '.patchInfo-header option');
-      assert.equal(optionEls.length, 4);
-      var select = element.$$('.patchInfo-header #patchSetSelect').bindValue;
-      assert.notEqual(select, 1);
-      assert.equal(select, 2);
-      assert.notEqual(select, 3);
-      assert.equal(optionEls[3].value, 13);
+    test('diffViewMode is propagated from file list header', () => {
+      element.viewState = {diffMode: 'UNIFIED'};
+      element.$.fileListHeader.diffViewMode = 'SIDE_BY_SIDE';
+      assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+    });
 
-      var numEvents = 0;
-      selectEl.addEventListener('change', function(e) {
+    test('diffMode defaults to side by side without preferences', done => {
+      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+          Promise.resolve({}));
+      // No user prefs or diff view mode set.
+
+      element._setDiffViewMode().then(() => {
+        assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+        done();
+      });
+    });
+
+    test('diffMode defaults to preference when not already set', done => {
+      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+          Promise.resolve({default_diff_view: 'UNIFIED'}));
+
+      element._setDiffViewMode().then(() => {
         assert.equal(element.viewState.diffMode, 'UNIFIED');
-        numEvents++;
-        if (numEvents == 1) {
-          assert(showStub.lastCall.calledWithExactly('/c/42/1'),
-              'Should navigate to /c/42/1');
-          selectEl.value = '3';
-          element.fire('change', {}, {node: selectEl});
-        } else if (numEvents == 2) {
-          assert(showStub.lastCall.calledWithExactly('/c/42/3'),
-              'Should navigate to /c/42/3');
-          done();
-        }
+        done();
       });
-      selectEl.value = '1';
-      element.fire('change', {}, {node: selectEl});
     });
 
-    test('patch num change with missing current_revision', function(done) {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 2,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
-        },
-        status: 'NEW',
-        labels: {},
-      };
-      flushAsynchronousOperations();
-      var selectEl = element.$$('.patchInfo-header select');
-      assert.ok(selectEl);
-      var optionEls = Polymer.dom(element.root).querySelectorAll(
-          '.patchInfo-header option');
-      assert.equal(optionEls.length, 4);
-      assert.notEqual(
-        element.$$('.patchInfo-header #patchSetSelect').bindValue, 1);
-      assert.equal(
-        element.$$('.patchInfo-header #patchSetSelect').bindValue, 2);
-      assert.notEqual(
-        element.$$('.patchInfo-header #patchSetSelect').bindValue, 3);
-      assert.equal(optionEls[3].value, 13);
-
-      var numEvents = 0;
-      selectEl.addEventListener('change', function(e) {
-        numEvents++;
-        if (numEvents == 1) {
-          assert(showStub.lastCall.calledWithExactly('/c/42/1'),
-              'Should navigate to /c/42/1');
-          selectEl.value = '3';
-          element.fire('change', {}, {node: selectEl});
-        } else if (numEvents == 2) {
-          assert(showStub.lastCall.calledWithExactly('/c/42/3'),
-              'Should navigate to /c/42/3');
-          done();
-        }
+    test('existing diffMode overrides preference', done => {
+      element.viewState.diffMode = 'SIDE_BY_SIDE';
+      sandbox.stub(element.$.restAPI, 'getPreferences').returns(
+          Promise.resolve({default_diff_view: 'UNIFIED'}));
+      element._setDiffViewMode().then(() => {
+        assert.equal(element.viewState.diffMode, 'SIDE_BY_SIDE');
+        done();
       });
-      selectEl.value = '1';
-      element.fire('change', {}, {node: selectEl});
     });
 
-    test('don’t reload entire page when patchRange changes', function() {
-      var reloadStub = sandbox.stub(element, '_reload',
-          function() { return Promise.resolve(); });
-      var reloadPatchDependentStub = sandbox.stub(element,
+    test('don’t reload entire page when patchRange changes', () => {
+      const reloadStub = sandbox.stub(element, '_reload',
+          () => { return Promise.resolve(); });
+      const reloadPatchDependentStub = sandbox.stub(element,
           '_reloadPatchNumDependentResources',
-          function() { return Promise.resolve(); });
-      var relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
+          () => { return Promise.resolve(); });
+      const relatedClearSpy = sandbox.spy(element.$.relatedChanges, 'clear');
+      const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
 
-      var value = {
-        view: 'gr-change-view',
+      const value = {
+        view: Gerrit.Nav.View.CHANGE,
         patchNum: '1',
       };
       element._paramsChanged(value);
@@ -499,178 +524,52 @@
       assert.isFalse(reloadStub.calledTwice);
       assert.isTrue(reloadPatchDependentStub.calledOnce);
       assert.isTrue(relatedClearSpy.calledOnce);
+      assert.isTrue(collapseStub.calledTwice);
     });
 
-    test('reload entire page when patchRange doesnt change', function() {
-      var reloadStub = sandbox.stub(element, '_reload',
-          function() { return Promise.resolve(); });
-
-      var value = {
-        view: 'gr-change-view',
+    test('reload entire page when patchRange doesnt change', () => {
+      const reloadStub = sandbox.stub(element, '_reload',
+          () => { return Promise.resolve(); });
+      const collapseStub = sandbox.stub(element.$.fileList, 'collapseAllDiffs');
+      const value = {
+        view: Gerrit.Nav.View.CHANGE,
       };
       element._paramsChanged(value);
       assert.isTrue(reloadStub.calledOnce);
       element._initialLoadComplete = true;
       element._paramsChanged(value);
       assert.isTrue(reloadStub.calledTwice);
-    });
-
-    test('include base patch when not parent', function() {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: '2',
-        patchNum: '3',
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev2: {_number: 2},
-          rev1: {_number: 1},
-          rev13: {_number: 13},
-          rev3: {_number: 3},
-        },
-        status: 'NEW',
-        labels: {},
-      };
-
-      element._changePatchNum(13);
-      assert(showStub.lastCall.calledWithExactly('/c/42/2..13'));
-
-      element._patchRange.basePatchNum = 'PARENT';
-
-      element._changePatchNum(3);
-      assert(showStub.lastCall.calledWithExactly('/c/42/3'));
+      assert.isTrue(collapseStub.calledTwice);
     });
 
     test('related changes are updated and new patch selected after rebase',
-        function(done) {
-      element._changeNum = '42';
-      sandbox.stub(element, '_computeLatestPatchNum', function() {
-        return 1;
-      });
-      sandbox.stub(element, '_reload',
-          function() { return Promise.resolve(); });
-      var e = {detail: {action: 'rebase'}};
-      element._handleReloadChange(e).then(function() {
-        assert.isTrue(showStub.lastCall.calledWithExactly('/c/42'));
-        done();
-      });
-    });
+        done => {
+          element._changeNum = '42';
+          sandbox.stub(element, 'computeLatestPatchNum', () => {
+            return 1;
+          });
+          sandbox.stub(element, '_reload',
+              () => { return Promise.resolve(); });
+          const e = {detail: {action: 'rebase'}};
+          element._handleReloadChange(e).then(() => {
+            assert.isTrue(navigateToChangeStub.lastCall.calledWithExactly(
+                element._change));
+            done();
+          });
+        });
 
-    test('related changes are not updated after other action', function(done) {
-      sandbox.stub(element, '_reload',
-          function() { return Promise.resolve(); });
-      sandbox.stub(element, '_updateSelected');
+    test('related changes are not updated after other action', done => {
+      sandbox.stub(element, '_reload', () => { return Promise.resolve(); });
       sandbox.stub(element.$.relatedChanges, 'reload');
-      var e = {detail: {action: 'abandon'}};
-      element._handleReloadChange(e).then(function() {
-        assert.isFalse(showStub.called);
+      const e = {detail: {action: 'abandon'}};
+      element._handleReloadChange(e).then(() => {
+        assert.isFalse(navigateToChangeStub.called);
         done();
       });
     });
 
-    test('change status new', function() {
-      element._changeNum = '1';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-      };
-      var status = element._computeChangeStatus(element._change, '1');
-      assert.equal(status, '');
-    });
-
-    test('change status draft', function() {
-      element._changeNum = '1';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: 'DRAFT',
-        labels: {},
-      };
-      var status = element._computeChangeStatus(element._change, '1');
-      assert.equal(status, 'Draft');
-    });
-
-    test('change status conflict', function() {
-      element._changeNum = '1';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        mergeable: false,
-        status: 'NEW',
-        labels: {},
-      };
-      var status = element._computeChangeStatus(element._change, '1');
-      assert.equal(status, 'Merge Conflict');
-    });
-
-    test('change status merged', function() {
-      element._changeNum = '1';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-        },
-        current_revision: 'rev1',
-        status: element.ChangeStatus.MERGED,
-        labels: {},
-      };
-      var status = element._computeChangeStatus(element._change, '1');
-      assert.equal(status, 'Merged');
-    });
-
-    test('revision status draft', function() {
-      element._changeNum = '1';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: 1,
-      };
-      element._change = {
-        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
-        revisions: {
-          rev1: {_number: 1},
-          rev2: {
-            _number: 2,
-            draft: true,
-          },
-        },
-        current_revision: 'rev1',
-        status: 'NEW',
-        labels: {},
-      };
-      var status = element._computeChangeStatus(element._change, '2');
-      assert.equal(status, 'Draft');
-    });
-
-    test('_computeMergedCommitInfo', function() {
-      var dummyRevs = {
+    test('_computeMergedCommitInfo', () => {
+      const dummyRevs = {
         1: {commit: {commit: 1}},
         2: {commit: {}},
       };
@@ -679,13 +578,13 @@
           dummyRevs[1].commit);
 
       // Regression test for issue 5337.
-      var commit = element._computeMergedCommitInfo(2, dummyRevs);
+      const commit = element._computeMergedCommitInfo(2, dummyRevs);
       assert.notDeepEqual(commit, dummyRevs[2]);
       assert.deepEqual(commit, {commit: 2});
     });
 
-    test('get latest revision', function() {
-      var change = {
+    test('get latest revision', () => {
+      let change = {
         revisions: {
           rev1: {_number: 1},
           rev3: {_number: 3},
@@ -701,8 +600,8 @@
       assert.equal(element._getLatestRevisionSHA(change), 'rev1');
     });
 
-    test('show commit message edit button', function() {
-      var _change = {
+    test('show commit message edit button', () => {
+      const _change = {
         status: element.ChangeStatus.MERGED,
       };
       assert.isTrue(element._computeHideEditCommitMessage(false, false, {}));
@@ -713,80 +612,80 @@
           _change));
     });
 
-    test('_computeChangeIdCommitMessageError', function() {
-      var commitMessage =
+    test('_computeChangeIdCommitMessageError', () => {
+      let commitMessage =
         'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483';
-      var change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          null);
 
       change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          'mismatch');
 
       commitMessage = 'This is the greatest change.';
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'missing');
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          'missing');
     });
 
-    test('multiple change Ids in commit message picks last', function() {
-      var commitMessage = [
-       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    test('multiple change Ids in commit message picks last', () => {
+      const commitMessage = [
+        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
       ].join('\n');
-      var change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
+      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          null);
       change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          'mismatch');
     });
 
-    test('does not count change Id that starts mid line', function() {
-      var commitMessage = [
-       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
-       'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
+    test('does not count change Id that starts mid line', () => {
+      const commitMessage = [
+        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282484',
+        'Change-Id: I4ce18b2395bca69d7a9aa48bf4554faa56282483',
       ].join(' and ');
-      var change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
+      let change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282484'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        null);
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          null);
       change = {change_id: 'I4ce18b2395bca69d7a9aa48bf4554faa56282483'};
       assert.equal(
-        element._computeChangeIdCommitMessageError(commitMessage, change),
-        'mismatch');
+          element._computeChangeIdCommitMessageError(commitMessage, change),
+          'mismatch');
     });
 
-    test('_computeTitleAttributeWarning', function() {
-      var changeIdCommitMessageError = 'missing';
+    test('_computeTitleAttributeWarning', () => {
+      let changeIdCommitMessageError = 'missing';
       assert.equal(
           element._computeTitleAttributeWarning(changeIdCommitMessageError),
           'No Change-Id in commit message');
 
-      var changeIdCommitMessageError = 'mismatch';
+      changeIdCommitMessageError = 'mismatch';
       assert.equal(
           element._computeTitleAttributeWarning(changeIdCommitMessageError),
           'Change-Id mismatch');
     });
 
-    test('_computeChangeIdClass', function() {
-      var changeIdCommitMessageError = 'missing';
+    test('_computeChangeIdClass', () => {
+      let changeIdCommitMessageError = 'missing';
       assert.equal(
-        element._computeChangeIdClass(changeIdCommitMessageError), '');
+          element._computeChangeIdClass(changeIdCommitMessageError), '');
 
-      var changeIdCommitMessageError = 'mismatch';
+      changeIdCommitMessageError = 'mismatch';
       assert.equal(
-        element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
+          element._computeChangeIdClass(changeIdCommitMessageError), 'warning');
     });
 
-    test('topic is coalesced to null', function(done) {
+    test('topic is coalesced to null', done => {
       sandbox.stub(element, '_changeChanged');
-      sandbox.stub(element.$.restAPI, 'getChangeDetail', function() {
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => {
         return Promise.resolve({
           id: '123456789',
           labels: {},
@@ -795,15 +694,15 @@
         });
       });
 
-      element._getChangeDetail().then(function() {
+      element._getChangeDetail().then(() => {
         assert.isNull(element._change.topic);
         done();
       });
     });
 
-    test('commit sha is populated from getChangeDetail', function(done) {
+    test('commit sha is populated from getChangeDetail', done => {
       sandbox.stub(element, '_changeChanged');
-      sandbox.stub(element.$.restAPI, 'getChangeDetail', function() {
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => {
         return Promise.resolve({
           id: '123456789',
           labels: {},
@@ -812,17 +711,48 @@
         });
       });
 
-      element._getChangeDetail().then(function() {
+      element._getChangeDetail().then(() => {
         assert.equal('foo', element._commitInfo.commit);
         done();
       });
     });
 
-    test('reply dialog focus can be controlled', function() {
-      var FocusTarget = element.$.replyDialog.FocusTarget;
-      var openStub = sandbox.stub(element, '_openReplyDialog');
+    test('edit is added to change', () => {
+      sandbox.stub(element, '_changeChanged');
+      sandbox.stub(element.$.restAPI, 'getChangeDetail', () => {
+        return Promise.resolve({
+          id: '123456789',
+          labels: {},
+          current_revision: 'foo',
+          revisions: {foo: {commit: {}}},
+        });
+      });
+      sandbox.stub(element, '_getEdit', () => {
+        return Promise.resolve({
+          base_patch_set_number: 1,
+          commit: {commit: 'bar'},
+        });
+      });
+      element._patchRange = {};
 
-      var e = {detail: {}};
+      return element._getChangeDetail().then(() => {
+        const revs = element._change.revisions;
+        assert.equal(Object.keys(revs).length, 2);
+        assert.deepEqual(revs['foo'], {commit: {commit: 'foo'}});
+        assert.deepEqual(revs['bar'], {
+          _number: element.EDIT_NAME,
+          basePatchNum: 1,
+          commit: {commit: 'bar'},
+          fetch: undefined,
+        });
+      });
+    });
+
+    test('reply dialog focus can be controlled', () => {
+      const FocusTarget = element.$.replyDialog.FocusTarget;
+      const openStub = sandbox.stub(element, '_openReplyDialog');
+
+      const e = {detail: {}};
       element._handleShowReplyDialog(e);
       assert(openStub.lastCall.calledWithExactly(FocusTarget.REVIEWERS),
           '_openReplyDialog should have been passed REVIEWERS');
@@ -833,17 +763,8 @@
           '_openReplyDialog should have been passed CCS');
     });
 
-    test('class is applied to file list on old patch set', function() {
-      var allPatchSets = [{num: 1}, {num: 2}, {num: 4}];
-      assert.equal(element._computePatchInfoClass('1', allPatchSets),
-          'patchInfo--oldPatchSet');
-      assert.equal(element._computePatchInfoClass('2', allPatchSets),
-          'patchInfo--oldPatchSet');
-      assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
-    });
-
-    test('getUrlParameter functionality', function() {
-      var locationStub = sandbox.stub(element, '_getLocationSearch');
+    test('getUrlParameter functionality', () => {
+      const locationStub = sandbox.stub(element, '_getLocationSearch');
 
       locationStub.returns('?test');
       assert.equal(element._getUrlParameter('test'), 'test');
@@ -855,14 +776,13 @@
       assert.isNull(element._getUrlParameter('test'));
       locationStub.returns('?test2');
       assert.isNull(element._getUrlParameter('test'));
-
     });
 
-    test('revert dialog opened with revert param', function(done) {
-      sandbox.stub(element.$.restAPI, 'getLoggedIn', function() {
+    test('revert dialog opened with revert param', done => {
+      sandbox.stub(element.$.restAPI, 'getLoggedIn', () => {
         return Promise.resolve(true);
       });
-      sandbox.stub(Gerrit, 'awaitPluginsLoaded', function() {
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded', () => {
         return Promise.resolve();
       });
 
@@ -874,6 +794,7 @@
         change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
         revisions: {
           rev1: {_number: 1},
+          rev2: {_number: 2},
         },
         current_revision: 'rev1',
         status: element.ChangeStatus.MERGED,
@@ -881,95 +802,95 @@
         actions: {},
       };
 
-      var urlParamStub = sandbox.stub(element, '_getUrlParameter',
-          function(param) {
+      sandbox.stub(element, '_getUrlParameter',
+          param => {
             assert.equal(param, 'revert');
             return param;
           });
 
-      var revertDialogStub = sandbox.stub(element.$.actions, 'showRevertDialog',
+      sandbox.stub(element.$.actions, 'showRevertDialog',
           done);
 
       element._maybeShowRevertDialog();
       assert.isTrue(Gerrit.awaitPluginsLoaded.called);
     });
 
-    suite('scroll related tests', function() {
-      test('document scrolling calls function to set scroll height',
-          function(done) {
-            var originalHeight = document.body.scrollHeight;
-            var scrollStub = sandbox.stub(element, '_handleScroll',
-                function() {
-                  assert.isTrue(scrollStub.called);
-                  document.body.style.height =
-                      originalHeight + 'px';
-                  scrollStub.restore();
-                  done();
-                });
-            document.body.style.height = '10000px';
-            document.body.scrollTop = TEST_SCROLL_TOP_PX;
-            element._handleScroll();
-          });
-
-      test('history is loaded correctly', function() {
-        history.replaceState(
-            {
-              scrollTop: 100,
-              path: location.pathname,
-            },
-            location.pathname);
-
-        var reloadStub = sandbox.stub(element, '_reload',
-            function() {
-              // When element is reloaded, ensure that the history
-              // state has the scrollTop set earlier. This will then
-              // be reset.
-              assert.isTrue(history.state.scrollTop == 100);
-              return Promise.resolve({});
+    suite('scroll related tests', () => {
+      test('document scrolling calls function to set scroll height', done => {
+        const originalHeight = document.body.scrollHeight;
+        const scrollStub = sandbox.stub(element, '_handleScroll',
+            () => {
+              assert.isTrue(scrollStub.called);
+              document.body.style.height = originalHeight + 'px';
+              scrollStub.restore();
+              done();
             });
+        document.body.style.height = '10000px';
+        element._handleScroll();
+      });
+
+      test('scrollTop is set correctly', () => {
+        element.viewState = {scrollTop: TEST_SCROLL_TOP_PX};
+
+        sandbox.stub(element, '_reload', () => {
+          // When element is reloaded, ensure that the history
+          // state has the scrollTop set earlier. This will then
+          // be reset.
+          assert.isTrue(element.viewState.scrollTop == TEST_SCROLL_TOP_PX);
+          return Promise.resolve({});
+        });
 
         // simulate reloading component, which is done when route
         // changes to match a regex of change view type.
-        element._paramsChanged({view: 'gr-change-view'});
+        element._paramsChanged({view: Gerrit.Nav.View.CHANGE});
+      });
+
+      test('scrollTop is reset when new change is loaded', () => {
+        element._resetFileListViewState();
+        assert.equal(element.viewState.scrollTop, 0);
       });
     });
 
-    suite('reply dialog tests', function() {
-      setup(function() {
+    suite('reply dialog tests', () => {
+      setup(() => {
         sandbox.stub(element.$.replyDialog, '_draftChanged');
+        sandbox.stub(element, '_updateSortedRevisions');
+        sandbox.stub(element.$.replyDialog, 'fetchIsLatestKnown',
+            () => { return Promise.resolve(true); });
+        element._change = {labels: {}};
       });
 
-      test('reply from comment adds quote text', function() {
-        var e = {detail: {message: {message: 'quote text'}}};
+      test('reply from comment adds quote text', () => {
+        const e = {detail: {message: {message: 'quote text'}}};
         element._handleMessageReply(e);
         assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
         assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
       });
 
-      test('reply from comment replaces quote text', function() {
+      test('reply from comment replaces quote text', () => {
         element.$.replyDialog.draft = '> old quote text\n\n some draft text';
         element.$.replyDialog.quote = '> old quote text\n\n';
-        var e = {detail: {message: {message: 'quote text'}}};
+        const e = {detail: {message: {message: 'quote text'}}};
         element._handleMessageReply(e);
         assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
         assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
       });
 
-      test('reply from same comment preserves quote text', function() {
+      test('reply from same comment preserves quote text', () => {
         element.$.replyDialog.draft = '> quote text\n\n some draft text';
         element.$.replyDialog.quote = '> quote text\n\n';
-        var e = {detail: {message: {message: 'quote text'}}};
+        const e = {detail: {message: {message: 'quote text'}}};
         element._handleMessageReply(e);
         assert.equal(element.$.replyDialog.draft,
             '> quote text\n\n some draft text');
         assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
       });
 
-      test('reply from top of page contains previous draft', function() {
-        var div = document.createElement('div');
+      test('reply from top of page contains previous draft', () => {
+        const div = document.createElement('div');
         element.$.replyDialog.draft = '> quote text\n\n some draft text';
         element.$.replyDialog.quote = '> quote text\n\n';
-        var e = {target: div, preventDefault: sandbox.spy()};
+        const e = {target: div, preventDefault: sandbox.spy()};
         element._handleReplyTap(e);
         assert.equal(element.$.replyDialog.draft,
             '> quote text\n\n some draft text');
@@ -977,24 +898,29 @@
       });
     });
 
-    test('reply button is disabled until server config is loaded', function() {
+    test('reply button is disabled until server config is loaded', () => {
       assert.isTrue(element._replyDisabled);
-      element.serverConfig = {};
+      element._serverConfig = {};
       assert.isFalse(element._replyDisabled);
     });
 
-    suite('commit message expand/collapse', function() {
-      test('commitCollapseToggle hidden for short commit message', function() {
+    suite('commit message expand/collapse', () => {
+      setup(() => {
+        sandbox.stub(element, 'fetchIsLatestKnown',
+            () => { return Promise.resolve(false); });
+      });
+
+      test('commitCollapseToggle hidden for short commit message', () => {
         element._latestCommitMessage = '';
         assert.isTrue(element.$.commitCollapseToggle.hasAttribute('hidden'));
       });
 
-      test('commitCollapseToggle shown for long commit message', function() {
+      test('commitCollapseToggle shown for long commit message', () => {
         element._latestCommitMessage = _.times(31, String).join('\n');
         assert.isFalse(element.$.commitCollapseToggle.hasAttribute('hidden'));
       });
 
-      test('commitCollapseToggle functions', function() {
+      test('commitCollapseToggle functions', () => {
         element._latestCommitMessage = _.times(31, String).join('\n');
         assert.isTrue(element._commitCollapsed);
         assert.isTrue(
@@ -1006,51 +932,43 @@
       });
     });
 
-    suite('related changes expand/collapse', function() {
-      var updateHeightSpy;
-      setup(function() {
+    suite('related changes expand/collapse', () => {
+      let updateHeightSpy;
+      setup(() => {
         updateHeightSpy = sandbox.spy(element, '_updateRelatedChangeMaxHeight');
       });
 
       test('relatedChangesToggle shown height greater than changeInfo height',
-          function() {
-        assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
-
-        sandbox.stub(element, '_getOffsetHeight', function() {
-          return 50;
-        });
-
-        sandbox.stub(element, '_getScrollHeight', function() {
-          return 60;
-        });
-        element._relatedChangesLoading = false;
-        assert.isFalse(element.$.relatedChangesToggle.hasAttribute('hidden'));
-        assert.equal(updateHeightSpy.callCount, 1);
-      });
+          () => {
+            assert.isFalse(element.$.relatedChangesToggle.classList
+                .contains('showToggle'));
+            sandbox.stub(element, '_getOffsetHeight', () => 50);
+            sandbox.stub(element, '_getScrollHeight', () => 60);
+            sandbox.stub(element, '_getLineHeight', () => 5);
+            sandbox.stub(window, 'matchMedia', () => ({matches: true}));
+            element._relatedChangesLoading = false;
+            assert.isTrue(element.$.relatedChangesToggle.classList
+                .contains('showToggle'));
+            assert.equal(updateHeightSpy.callCount, 1);
+          });
 
       test('relatedChangesToggle hidden height less than changeInfo height',
-            function() {
-        assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
-        sandbox.stub(element, '_getOffsetHeight', function() {
-          return 50;
-        });
+          () => {
+            assert.isFalse(element.$.relatedChangesToggle.classList
+                .contains('showToggle'));
+            sandbox.stub(element, '_getOffsetHeight', () => 50);
+            sandbox.stub(element, '_getScrollHeight', () => 40);
+            sandbox.stub(element, '_getLineHeight', () => 5);
+            sandbox.stub(window, 'matchMedia', () => ({matches: true}));
+            element._relatedChangesLoading = false;
+            assert.isFalse(element.$.relatedChangesToggle.classList
+                .contains('showToggle'));
+            assert.equal(updateHeightSpy.callCount, 1);
+          });
 
-        sandbox.stub(element, '_getScrollHeight', function() {
-          return 40;
-        });
-        element._relatedChangesLoading = false;
-        assert.isTrue(element.$.relatedChangesToggle.hasAttribute('hidden'));
-        assert.equal(updateHeightSpy.callCount, 1);
-      });
-
-      test('relatedChangesToggle functions', function() {
-        sandbox.stub(element, '_getOffsetHeight', function() {
-          return 50;
-        });
-
-        sandbox.stub(element, '_getScrollHeight', function() {
-          return 60;
-        });
+      test('relatedChangesToggle functions', () => {
+        sandbox.stub(element, '_getOffsetHeight', () => 50);
+        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
         element._relatedChangesLoading = false;
         assert.isTrue(element._relatedChangesCollapsed);
         assert.isTrue(
@@ -1061,35 +979,27 @@
             element.$.relatedChanges.classList.contains('collapsed'));
       });
 
-      test('_updateRelatedChangeMaxHeight without commit toggle', function() {
-        sandbox.stub(element, '_getOffsetHeight', function() {
-          return 50;
-        });
+      test('_updateRelatedChangeMaxHeight without commit toggle', () => {
+        sandbox.stub(element, '_getOffsetHeight', () => 50);
+        sandbox.stub(element, '_getLineHeight', () => 12);
+        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
 
-        sandbox.stub(element, '_getLineHeight', function() {
-          return 12;
-        });
-
-        // 50 (existing height) - 24 (extra height) = 26 (adjusted height).
-        // 50 (existing height)  % 12 (line height) = 2 (remainder).
-        // 26 (adjusted height) - 2 (remainder) = 24 (max height to set).
+        // 50 (existing height) - 30 (extra height) = 20 (adjusted height).
+        // 20 (max existing height)  % 12 (line height) = 6 (remainder).
+        // 20 (adjusted height) - 8 (remainder) = 12 (max height to set).
 
         element._updateRelatedChangeMaxHeight();
         assert.equal(element.customStyle['--relation-chain-max-height'],
-            '24px');
+            '12px');
         assert.equal(element.customStyle['--related-change-btn-top-padding'],
             undefined);
       });
 
-      test('_updateRelatedChangeMaxHeight with commit toggle', function() {
+      test('_updateRelatedChangeMaxHeight with commit toggle', () => {
         element._latestCommitMessage = _.times(31, String).join('\n');
-        sandbox.stub(element, '_getOffsetHeight', function() {
-          return 50;
-        });
-
-        sandbox.stub(element, '_getLineHeight', function() {
-          return 12;
-        });
+        sandbox.stub(element, '_getOffsetHeight', () => 50);
+        sandbox.stub(element, '_getLineHeight', () => 12);
+        sandbox.stub(window, 'matchMedia', () => ({matches: false}));
 
         // 50 (existing height) % 12 (line height) = 2 (remainder).
         // 50 (existing height)  - 2 (remainder) = 48 (max height to set).
@@ -1100,6 +1010,187 @@
         assert.equal(element.customStyle['--related-change-btn-top-padding'],
             '2px');
       });
+
+      test('_updateRelatedChangeMaxHeight in small screen mode', () => {
+        element._latestCommitMessage = _.times(31, String).join('\n');
+        sandbox.stub(element, '_getOffsetHeight', () => 50);
+        sandbox.stub(element, '_getLineHeight', () => 12);
+        sandbox.stub(window, 'matchMedia', () => ({matches: true}));
+
+        element._updateRelatedChangeMaxHeight();
+
+        // 400 (new height) % 12 (line height) = 4 (remainder).
+        // 400 (new height) - 4 (remainder) = 396.
+
+        assert.equal(element.customStyle['--relation-chain-max-height'],
+            '396px');
+      });
+
+      test('_updateRelatedChangeMaxHeight in medium screen mode', () => {
+        element._latestCommitMessage = _.times(31, String).join('\n');
+        sandbox.stub(element, '_getOffsetHeight', () => 50);
+        sandbox.stub(element, '_getLineHeight', () => 12);
+        sandbox.stub(window, 'matchMedia', () => {
+          if (window.matchMedia.lastCall.args[0] === '(max-width: 60em)') {
+            return {matches: true};
+          } else {
+            return {matches: false};
+          }
+        });
+
+        // 100 (new height) % 12 (line height) = 4 (remainder).
+        // 100 (new height) - 4 (remainder) = 96.
+        element._updateRelatedChangeMaxHeight();
+        assert.equal(element.customStyle['--relation-chain-max-height'],
+            '96px');
+      });
+
+
+      suite('update checks', () => {
+        setup(() => {
+          sandbox.spy(element, '_startUpdateCheckTimer');
+          sandbox.stub(element, 'async', f => {
+            // Only fire the async callback one time.
+            if (element.async.callCount > 1) { return; }
+            f.call(element);
+          });
+        });
+
+        test('_startUpdateCheckTimer negative delay', () => {
+          sandbox.stub(element, 'fetchIsLatestKnown');
+
+          element._serverConfig = {change: {update_delay: -1}};
+
+          assert.isTrue(element._startUpdateCheckTimer.called);
+          assert.isFalse(element.fetchIsLatestKnown.called);
+        });
+
+        test('_startUpdateCheckTimer up-to-date', () => {
+          sandbox.stub(element, 'fetchIsLatestKnown',
+              () => { return Promise.resolve(true); });
+
+          element._serverConfig = {change: {update_delay: 12345}};
+
+          assert.isTrue(element._startUpdateCheckTimer.called);
+          assert.isTrue(element.fetchIsLatestKnown.called);
+          assert.equal(element.async.lastCall.args[1], 12345 * 1000);
+        });
+
+        test('_startUpdateCheckTimer out-of-date shows an alert', done => {
+          sandbox.stub(element, 'fetchIsLatestKnown',
+              () => { return Promise.resolve(false); });
+          element.addEventListener('show-alert', () => {
+            done();
+          });
+          element._serverConfig = {change: {update_delay: 12345}};
+        });
+      });
+
+      test('canStartReview computation', () => {
+        const change1 = {};
+        const change2 = {
+          actions: {
+            ready: {
+              enabled: true,
+            },
+          },
+        };
+        const change3 = {
+          actions: {
+            ready: {
+              label: 'Ready for Review',
+            },
+          },
+        };
+        assert.isFalse(element._computeCanStartReview(change1));
+        assert.isTrue(element._computeCanStartReview(change2));
+        assert.isFalse(element._computeCanStartReview(change3));
+      });
+
+      test('header class computation', () => {
+        assert.equal(element._computeHeaderClass({}), 'header');
+        assert.equal(element._computeHeaderClass({work_in_progress: true}),
+            'header wip');
+      });
+    });
+
+    test('_maybeScrollToMessage', () => {
+      const scrollStub = sandbox.stub(element.$.messageList, 'scrollToMessage');
+
+      element._maybeScrollToMessage('');
+      assert.isFalse(scrollStub.called);
+
+      element._maybeScrollToMessage('message');
+      assert.isFalse(scrollStub.called);
+
+      element._maybeScrollToMessage('#message-TEST');
+      assert.isTrue(scrollStub.called);
+      assert.equal(scrollStub.lastCall.args[0], 'TEST');
+    });
+
+    test('topic update reloads related changes', () => {
+      sandbox.stub(element.$.relatedChanges, 'reload');
+      element.dispatchEvent(new CustomEvent('topic-changed'));
+      assert.isTrue(element.$.relatedChanges.reload.calledOnce);
+    });
+
+    test('_computeEditLoaded', () => {
+      const callCompute = range => element._computeEditLoaded({base: range});
+      assert.isFalse(callCompute({}));
+      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}));
+      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}));
+      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}));
+    });
+
+    test('_processEdit', () => {
+      element._patchRange = {};
+      const change = {
+        current_revision: 'foo',
+        revisions: {foo: {commit: {}, actions: {cherrypick: {enabled: true}}}},
+      };
+      let mockChange;
+
+      // With no edit, mockChange should be unmodified.
+      element._processEdit(mockChange = _.cloneDeep(change), null);
+      assert.deepEqual(mockChange, change);
+
+      // When edit is not based on the latest PS, current_revision should be
+      // unmodified.
+      const edit = {
+        base_patch_set_number: 1,
+        commit: {commit: 'bar'},
+        fetch: true,
+      };
+      element._processEdit(mockChange = _.cloneDeep(change), edit);
+      assert.notDeepEqual(mockChange, change);
+      assert.equal(mockChange.revisions.bar._number, element.EDIT_NAME);
+      assert.equal(mockChange.current_revision, change.current_revision);
+      assert.deepEqual(mockChange.revisions.bar.commit, {commit: 'bar'});
+      assert.notOk(mockChange.revisions.bar.actions);
+
+      edit.base_revision = 'foo';
+      element._processEdit(mockChange = _.cloneDeep(change), edit);
+      assert.notDeepEqual(mockChange, change);
+      assert.equal(mockChange.current_revision, 'bar');
+      assert.deepEqual(mockChange.revisions.bar.actions,
+          mockChange.revisions.foo.actions);
+
+      // If _patchRange.patchNum is defined, do not load edit.
+      element._patchRange.patchNum = 'baz';
+      change.current_revision = 'baz';
+      element._processEdit(mockChange = _.cloneDeep(change), edit);
+      assert.equal(element._patchRange.patchNum, 'baz');
+      assert.notOk(mockChange.revisions.bar.actions);
+    });
+
+    test('_editLoaded set when patchNum is an edit', () => {
+      sandbox.stub(element, 'computeLatestPatchNum').returns('2');
+      element._patchRange = {patchNum: element.EDIT_NAME};
+
+      assert.isTrue(element._editLoaded);
+      element.set('_patchRange.patchNum', 1);
+
+      assert.isFalse(element._editLoaded);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index a03f6cc..e6f37cf 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -16,19 +16,27 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<!--
+  The custom CSS property `--gr-formatted-text-prose-max-width` controls the max
+  width of formatted text blocks that are not code.
+-->
 
 <dom-module id="gr-comment-list">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
         word-wrap: break-word;
       }
       .file {
         border-top: 1px solid #ddd;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         margin: 10px 0 3px;
         padding: 10px 0 5px;
       }
@@ -43,13 +51,13 @@
       }
       .message {
         flex: 1;
-        max-width: 80ch;
+        --gr-formatted-text-prose-max-width: 80ch;
       }
     </style>
     <template is="dom-repeat" items="[[_computeFilesFromComments(comments)]]" as="file">
       <div class="file">
         <a href$="[[_computeFileDiffURL(file, changeNum, patchNum)]]">
-          [[_computeFileDisplayName(file)]]
+          [[computeDisplayPath(file)]]
         </a>:
       </div>
       <template is="dom-repeat"
@@ -69,7 +77,7 @@
               class="message"
               no-trailing-margin
               content="[[comment.message]]"
-              config="[[projectConfig.commentlinks]]"></gr-formatted-text>
+              config="[[commentLinks]]"></gr-formatted-text>
         </div>
       </template>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index 98a2508..7e7a0ec 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -13,70 +13,56 @@
 // limitations under the License.
 (function() {
   'use strict';
-
-  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
-  var MERGE_LIST_PATH = '/MERGE_LIST';
-
   Polymer({
     is: 'gr-comment-list',
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
       Gerrit.PathListBehavior,
+      Gerrit.URLEncodingBehavior,
     ],
 
     properties: {
       changeNum: Number,
       comments: Object,
       patchNum: Number,
+      commentLinks: Object,
+      projectName: String,
+      /** @type {?} */
       projectConfig: Object,
     },
 
-    _computeFilesFromComments: function(comments) {
-      var arr = Object.keys(comments || {});
+    _computeFilesFromComments(comments) {
+      const arr = Object.keys(comments || {});
       return arr.sort(this.specialFilePathCompare);
     },
 
-    _computeFileDiffURL: function(file, changeNum, patchNum) {
-      return this.getBaseUrl() + '/c/' + changeNum +
-        '/' + patchNum + '/' + file;
+    _computeFileDiffURL(file, changeNum, patchNum) {
+      return Gerrit.Nav.getUrlForDiffById(this.changeNum, this.projectName,
+          file, patchNum);
     },
 
-    _computeFileDisplayName: function(path) {
-      if (path === COMMIT_MESSAGE_PATH) {
-        return 'Commit message';
-      } else if (path === MERGE_LIST_PATH) {
-        return 'Merge list';
-      }
-      return path;
-    },
-
-    _isOnParent: function(comment) {
+    _isOnParent(comment) {
       return comment.side === 'PARENT';
     },
 
-    _computeDiffLineURL: function(file, changeNum, patchNum, comment) {
-      var diffURL = this._computeFileDiffURL(file, changeNum, patchNum);
-      if (comment.line) {
-        diffURL += '#';
-        if (this._isOnParent(comment)) { diffURL += 'b'; }
-        diffURL += comment.line;
-      }
-      return diffURL;
+    _computeDiffLineURL(file, changeNum, patchNum, comment) {
+      return Gerrit.Nav.getUrlForDiffById(this.changeNum, this.projectName,
+          file, patchNum, null, comment.line, this._isOnParent(comment));
     },
 
-    _computeCommentsForFile: function(comments, file) {
+    _computeCommentsForFile(comments, file) {
       // Changes are not picked up by the dom-repeat due to the array instance
       // identity not changing even when it has elements added/removed from it.
       return (comments[file] || []).slice();
     },
 
-    _computePatchDisplayName: function(comment) {
+    _computePatchDisplayName(comment) {
       if (this._isOnParent(comment)) {
         return 'Base, ';
       }
       if (comment.patch_set != this.patchNum) {
-        return 'PS' + comment.patch_set + ', ';
+        return `PS${comment.patch_set}, `;
       }
       return '';
     },
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
index e27bad0..0e47e30 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list_test.html
@@ -20,6 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-comment-list.html">
 
 <script>void(0);</script>
@@ -31,66 +32,36 @@
 </test-fixture>
 
 <script>
-  suite('gr-comment-list tests', function() {
-    var element;
+  suite('gr-comment-list tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('_computeFilesFromComments w/ special file path sorting', function() {
-      var comments = {
+    test('_computeFilesFromComments w/ special file path sorting', () => {
+      const comments = {
         'file_b.html': [],
         'file_c.css': [],
         'file_a.js': [],
         'test.cc': [],
         'test.h': [],
       };
-      var expected = [
+      const expected = [
         'file_a.js',
         'file_b.html',
         'file_c.css',
         'test.h',
-        'test.cc'
+        'test.cc',
       ];
-      var actual = element._computeFilesFromComments(comments);
+      const actual = element._computeFilesFromComments(comments);
       assert.deepEqual(actual, expected);
 
       assert.deepEqual(element._computeFilesFromComments(null), []);
     });
 
-    test('_computeFileDiffURL', function() {
-      var expected = '/c/<change>/<patch>/<file>';
-      var actual = element._computeFileDiffURL('<file>', '<change>', '<patch>');
-      assert.equal(actual, expected);
-    });
-
-    test('_computeFileDisplayName', function() {
-      assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
-          'Commit message');
-      assert.equal(element._computeFileDisplayName('/MERGE_LIST'),
-          'Merge list');
-      assert.equal(element._computeFileDisplayName('/foo/bar/baz'),
-          '/foo/bar/baz');
-    });
-
-    test('_computeDiffLineURL', function() {
-      var comment = {line: 123, side: 'REVISION', patch_set: 10};
-      var expected = '/c/<change>/<patch>/<file>#123';
-      var actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
-          comment);
-      assert.equal(actual, expected);
-
-      comment.line = 321;
-      comment.side = 'PARENT';
-
-      expected = '/c/<change>/<patch>/<file>#b321';
-      actual = element._computeDiffLineURL('<file>', '<change>', '<patch>',
-          comment);
-    });
-
-    test('_computePatchDisplayName', function() {
-      var comment = {line: 123, side: 'REVISION', patch_set: 10};
+    test('_computePatchDisplayName', () => {
+      const comment = {line: 123, side: 'REVISION', patch_set: 10};
 
       element.patchNum = 10;
       assert.equal(element._computePatchDisplayName(comment), '');
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
index f1d02f2..dec4e118 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.html
@@ -15,10 +15,11 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-commit-info">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: inline-block;
       }
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
index 6ccf746..ed8abfe 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info.js
@@ -31,13 +31,13 @@
       },
     },
 
-    _isWebLink: function(link) {
+    _isWebLink(link) {
       // This is a whitelist of web link types that provide direct links to
       // the commit in the url property.
       return link.name === 'gitiles' || link.name === 'gitweb';
     },
 
-    _computeShowWebLink: function(change, commitInfo, serverConfig) {
+    _computeShowWebLink(change, commitInfo, serverConfig) {
       if (serverConfig.gitweb && serverConfig.gitweb.url &&
           serverConfig.gitweb.type && serverConfig.gitweb.type.revision) {
         return true;
@@ -47,8 +47,8 @@
         return false;
       }
 
-      for (var i = 0; i < commitInfo.web_links.length; i++) {
-        if (this._isWebLink(commitInfo.web_links[i])) {
+      for (const link of commitInfo.web_links) {
+        if (this._isWebLink(link)) {
           return true;
         }
       }
@@ -56,7 +56,7 @@
       return false;
     },
 
-    _computeWebLink: function(change, commitInfo, serverConfig) {
+    _computeWebLink(change, commitInfo, serverConfig) {
       if (!this._computeShowWebLink(change, commitInfo, serverConfig)) {
         return;
       }
@@ -69,10 +69,10 @@
                 .replace('${commit}', commitInfo.commit);
       }
 
-      var webLink = null;
-      for (var i = 0; i < commitInfo.web_links.length; i++) {
-        if (this._isWebLink(commitInfo.web_links[i])) {
-          webLink = commitInfo.web_links[i].url;
+      let webLink = null;
+      for (const link of commitInfo.web_links) {
+        if (this._isWebLink(link)) {
+          webLink = link.url;
           break;
         }
       }
@@ -84,7 +84,7 @@
       return webLink;
     },
 
-    _computeShortHash: function(commitInfo) {
+    _computeShortHash(commitInfo) {
       if (!commitInfo || !commitInfo.commit) {
         return;
       }
diff --git a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
index 10a51f6..aadd21b 100644
--- a/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
+++ b/polygerrit-ui/app/elements/change/gr-commit-info/gr-commit-info_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-commit-info.html">
 
 <script>void(0);</script>
@@ -33,14 +32,14 @@
 </test-fixture>
 
 <script>
-  suite('gr-commit-info tests', function() {
-    var element;
+  suite('gr-commit-info tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('no web link when unavailable', function() {
+    test('no web link when unavailable', () => {
       element.commitInfo = {};
       element.serverConfig = {};
       element.change = {labels: []};
@@ -49,7 +48,7 @@
           element.commitInfo, element.serverConfig));
     });
 
-    test('use web link when available', function() {
+    test('use web link when available', () => {
       element.commitInfo = {web_links: [{name: 'gitweb', url: 'link-url'}]};
       element.serverConfig = {};
 
@@ -59,9 +58,9 @@
           element.serverConfig), 'link-url');
     });
 
-    test('does not relativize web links that begin with scheme', function() {
+    test('does not relativize web links that begin with scheme', () => {
       element.commitInfo = {
-        web_links: [{name: 'gitweb', url: 'https://link-url'}]
+        web_links: [{name: 'gitweb', url: 'https://link-url'}],
       };
       element.serverConfig = {};
 
@@ -71,7 +70,7 @@
           element.serverConfig), 'https://link-url');
     });
 
-    test('use gitweb when available', function() {
+    test('use gitweb when available', () => {
       element.commitInfo = {commit: 'commit-sha'};
       element.serverConfig = {gitweb: {
         url: 'url-base/',
@@ -80,7 +79,7 @@
       element.change = {
         project: 'project-name',
         labels: [],
-        current_revision: element.commitInfo.commit
+        current_revision: element.commitInfo.commit,
       };
 
       assert.isOk(element._computeShowWebLink(element.change,
@@ -90,10 +89,10 @@
           element.serverConfig), 'url-base/xx project-name xx commit-sha xx');
     });
 
-    test('prefer gitweb when both are available', function() {
+    test('prefer gitweb when both are available', () => {
       element.commitInfo = {
         commit: 'commit-sha',
-        web_links: [{url: 'link-url'}]
+        web_links: [{url: 'link-url'}],
       };
       element.serverConfig = {gitweb: {
         url: 'url-base/',
@@ -102,20 +101,20 @@
       element.change = {
         project: 'project-name',
         labels: [],
-        current_revision: element.commitInfo.commit
+        current_revision: element.commitInfo.commit,
       };
 
       assert.isOk(element._computeShowWebLink(element.change,
           element.commitInfo, element.serverConfig));
 
-      var link = element._computeWebLink(element.change, element.commitInfo,
+      const link = element._computeWebLink(element.change, element.commitInfo,
           element.serverConfig);
 
       assert.equal(link, 'url-base/xx project-name xx commit-sha xx');
       assert.notEqual(link, '../../link-url');
     });
 
-    test('ignore web links that are neither gitweb nor gitiles', function() {
+    test('ignore web links that are neither gitweb nor gitiles', () => {
       element.commitInfo = {
         commit: 'commit-sha',
         web_links: [
@@ -126,7 +125,7 @@
           {
             name: 'gitiles',
             url: 'https://link-url',
-          }
+          },
         ],
       };
       element.serverConfig = {};
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
index 481b124..d3bf159 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.html
@@ -17,10 +17,11 @@
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-confirm-abandon-dialog">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
@@ -44,7 +45,8 @@
         width: 73ch; /* Add a char to account for the border. */
 
         --iron-autogrow-textarea {
-          border: 1px solid #ddd;
+          border: 1px solid #cdcdcd;
+          box-sizing: border-box;
           font-family: var(--monospace-font-family);
         }
       }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
index e47f14f..a3500b0 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-abandon-dialog/gr-confirm-abandon-dialog.js
@@ -33,16 +33,32 @@
       message: String,
     },
 
-    resetFocus: function() {
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    keyBindings: {
+      'ctrl+enter meta+enter': '_handleEnterKey',
+    },
+
+    resetFocus() {
       this.$.messageInput.textarea.focus();
     },
 
-    _handleConfirmTap: function(e) {
+    _handleEnterKey(e) {
+      this._confirm();
+    },
+
+    _handleConfirmTap(e) {
       e.preventDefault();
+      this._confirm();
+    },
+
+    _confirm() {
       this.fire('confirm', {reason: this.message}, {bubbles: false});
     },
 
-    _handleCancelTap: function(e) {
+    _handleCancelTap(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
index ebc6533..5151280 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.html
@@ -16,14 +16,16 @@
 
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-confirm-cherrypick-dialog">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
-        width: 30em;
       }
       :host([disabled]) {
         opacity: .5;
@@ -32,9 +34,6 @@
       label {
         cursor: pointer;
       }
-      iron-autogrow-textarea {
-        padding: 0;
-      }
       .main {
         display: flex;
         flex-direction: column;
@@ -50,6 +49,14 @@
         border: groove;
         width: 100%;
       }
+      iron-autogrow-textarea {
+        padding: 0;
+
+        --iron-autogrow-textarea: {
+          font-family: var(--monospace-font-family);
+          width: 72ch;
+        };
+      }
     </style>
     <gr-confirm-dialog
         confirm-label="Cherry Pick"
@@ -60,11 +67,12 @@
         <label for="branchInput">
           Cherry Pick to branch
         </label>
-        <input is="iron-input"
-            type="text"
+        <gr-autocomplete
             id="branchInput"
-            bind-value="{{branch}}"
+            text="{{branch}}"
+            query="[[_query]]"
             placeholder="Destination branch">
+        </gr-autocomplete>
         <label for="messageInput">
           Cherry Pick Commit Message
         </label>
@@ -77,6 +85,7 @@
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
     </gr-confirm-dialog>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-confirm-cherrypick-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
index 716e29c..fab728d 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog.js
@@ -14,6 +14,8 @@
 (function() {
   'use strict';
 
+  const SUGGESTIONS_LIMIT = 15;
+
   Polymer({
     is: 'gr-confirm-cherrypick-dialog',
 
@@ -35,14 +37,21 @@
       commitMessage: String,
       commitNum: String,
       message: String,
+      project: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getProjectBranchesSuggestions.bind(this);
+        },
+      },
     },
 
     observers: [
       '_computeMessage(changeStatus, commitNum, commitMessage)',
     ],
 
-    _computeMessage: function(changeStatus, commitNum, commitMessage) {
-      var newMessage = commitMessage;
+    _computeMessage(changeStatus, commitNum, commitMessage) {
+      let newMessage = commitMessage;
 
       if (changeStatus === 'MERGED') {
         newMessage += '(cherry picked from commit ' + commitNum + ')';
@@ -50,14 +59,41 @@
       this.message = newMessage;
     },
 
-    _handleConfirmTap: function(e) {
+    _handleConfirmTap(e) {
       e.preventDefault();
       this.fire('confirm', null, {bubbles: false});
     },
 
-    _handleCancelTap: function(e) {
+    _handleCancelTap(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
+
+    resetFocus() {
+      this.$.branchInput.focus();
+    },
+
+    _getProjectBranchesSuggestions(input) {
+      if (input.startsWith('refs/heads/')) {
+        input = input.substring('refs/heads/'.length);
+      }
+      return this.$.restAPI.getProjectBranches(
+          input, this.project, SUGGESTIONS_LIMIT).then(response => {
+            const branches = [];
+            let branch;
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              if (response[key].ref.startsWith('refs/heads/')) {
+                branch = response[key].ref.substring('refs/heads/'.length);
+              } else {
+                branch = response[key].ref;
+              }
+              branches.push({
+                name: branch,
+              });
+            }
+            return branches;
+          });
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
index 3c1cf2b..0956f84 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-confirm-cherrypick-dialog.html">
 
 <script>void(0);</script>
@@ -33,43 +32,83 @@
 </test-fixture>
 
 <script>
-  suite('gr-confirm-cherrypick-dialog tests', function() {
-    var element;
+  suite('gr-confirm-cherrypick-dialog tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getProjectBranches(input) {
+          if (input.startsWith('test')) {
+            return Promise.resolve([
+              {
+                ref: 'refs/heads/test-branch',
+                revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+                can_delete: true,
+              },
+            ]);
+          } else {
+            return Promise.resolve({});
+          }
+        },
+      });
       element = fixture('basic');
+      element.project = 'test-project';
     });
 
-    test('with merged change', function() {
+    teardown(() => { sandbox.restore(); });
+
+    test('with merged change', () => {
       element.changeStatus = 'MERGED';
       element.commitMessage = 'message\n';
       element.commitNum = '123';
       element.branch = 'master';
       flushAsynchronousOperations();
-      var expectedMessage = 'message\n(cherry picked from commit 123)';
+      const expectedMessage = 'message\n(cherry picked from commit 123)';
       assert.equal(element.message, expectedMessage);
     });
 
-    test('with unmerged change', function() {
+    test('with unmerged change', () => {
       element.changeStatus = 'OPEN';
       element.commitMessage = 'message\n';
       element.commitNum = '123';
       element.branch = 'master';
       flushAsynchronousOperations();
-      var expectedMessage = 'message\n';
+      const expectedMessage = 'message\n';
       assert.equal(element.message, expectedMessage);
     });
 
-    test('with updated commit message', function() {
+    test('with updated commit message', () => {
       element.changeStatus = 'OPEN';
       element.commitMessage = 'message\n';
       element.commitNum = '123';
       element.branch = 'master';
-      var myNewMessage = 'updated commit message';
+      const myNewMessage = 'updated commit message';
       element.message = myNewMessage;
       flushAsynchronousOperations();
-      var expectedMessage = 'message\n';
       assert.equal(element.message, myNewMessage);
     });
+
+    test('_getProjectBranchesSuggestions empty', done => {
+      element._getProjectBranchesSuggestions('nonexistent').then(branches => {
+        assert.equal(branches.length, 0);
+        done();
+      });
+    });
+
+    test('resetFocus', () => {
+      const focusStub = sandbox.stub(element.$.branchInput, 'focus');
+      element.resetFocus();
+      assert.isTrue(focusStub.called);
+    });
+
+    test('_getProjectBranchesSuggestions non-empty', done => {
+      element._getProjectBranchesSuggestions('test-branch').then(branches => {
+        assert.equal(branches.length, 1);
+        assert.equal(branches[0].name, 'test-branch');
+        done();
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
new file mode 100644
index 0000000..ec6bfb4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.html
@@ -0,0 +1,93 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-confirm-move-dialog">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        width: 30em;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      label {
+        cursor: pointer;
+      }
+      iron-autogrow-textarea {
+        padding: 0;
+      }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+      .main label,
+      .main input[type="text"] {
+        display: block;
+        font: inherit;
+        width: 100%;
+      }
+      .main .message {
+        border: groove;
+        width: 100%;
+      }
+      .warning {
+        color: red;
+      }
+    </style>
+    <gr-confirm-dialog
+        confirm-label="Move Change"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header">Move Change to Another Branch</div>
+      <div class="main">
+        <p class="warning">
+          Warning: moving a change will not change its parents.
+        </p>
+        <label for="branchInput">
+          Move change to branch
+        </label>
+        <gr-autocomplete
+            id="branchInput"
+            text="{{branch}}"
+            query="[[_query]]"
+            placeholder="Destination branch">
+        </gr-autocomplete>
+        <label for="messageInput">
+          Move Change Message
+        </label>
+        <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+            rows="4"
+            max-rows="15"
+            bind-value="{{message}}"></iron-autogrow-textarea>
+      </div>
+    </gr-confirm-dialog>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-confirm-move-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
new file mode 100644
index 0000000..6d35dbf
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog.js
@@ -0,0 +1,79 @@
+// 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.
+(function() {
+  'use strict';
+
+  const SUGGESTIONS_LIMIT = 15;
+
+  Polymer({
+    is: 'gr-confirm-move-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      branch: String,
+      message: String,
+      project: String,
+      _query: {
+        type: Function,
+        value() {
+          return this._getProjectBranchesSuggestions.bind(this);
+        },
+      },
+    },
+
+    _handleConfirmTap(e) {
+      e.preventDefault();
+      this.fire('confirm', null, {bubbles: false});
+    },
+
+    _handleCancelTap(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+
+    _getProjectBranchesSuggestions(input) {
+      if (input.startsWith('refs/heads/')) {
+        input = input.substring('refs/heads/'.length);
+      }
+      return this.$.restAPI.getProjectBranches(
+          input, this.project, SUGGESTIONS_LIMIT).then(response => {
+            const branches = [];
+            let branch;
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              if (response[key].ref.startsWith('refs/heads/')) {
+                branch = response[key].ref.substring('refs/heads/'.length);
+              } else {
+                branch = response[key].ref;
+              }
+              branches.push({
+                name: branch,
+              });
+            }
+            return branches;
+          });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
new file mode 100644
index 0000000..caf61ba
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-confirm-move-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-confirm-move-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-confirm-move-dialog></gr-confirm-move-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-confirm-move-dialog tests', () => {
+    let element;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getProjectBranches(input) {
+          if (input.startsWith('test')) {
+            return Promise.resolve([
+              {
+                ref: 'refs/heads/test-branch',
+                revision: '67ebf73496383c6777035e374d2d664009e2aa5c',
+                can_delete: true,
+              },
+            ]);
+          } else {
+            return Promise.resolve({});
+          }
+        },
+      });
+      element = fixture('basic');
+      element.project = 'test-project';
+    });
+
+    test('with updated commit message', () => {
+      element.branch = 'master';
+      const myNewMessage = 'updated commit message';
+      element.message = myNewMessage;
+      flushAsynchronousOperations();
+      assert.equal(element.message, myNewMessage);
+    });
+
+    test('_getProjectBranchesSuggestions empty', done => {
+      element._getProjectBranchesSuggestions('nonexistent').then(branches => {
+        assert.equal(branches.length, 0);
+        done();
+      });
+    });
+
+    test('_getProjectBranchesSuggestions non-empty', done => {
+      element._getProjectBranchesSuggestions('test-branch').then(branches => {
+        assert.equal(branches.length, 1);
+        assert.equal(branches[0].name, 'test-branch');
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
index b27e6ba..2772594 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.html
@@ -16,10 +16,11 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-confirm-rebase-dialog">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
         width: 30em;
@@ -75,7 +76,8 @@
               disabled$="[[!_displayTipOption(rebaseOnCurrent, hasParent)]]"
               on-tap="_handleRebaseOnTip">
           <label id="rebaseOnTipLabel" for="rebaseOnTipInput">
-            Rebase on top of the [[branch]] branch<span hidden="[[!hasParent]]">
+            Rebase on top of the [[branch]]
+            branch<span hidden$="[[!hasParent]]">
               (breaks relation chain)
             </span>
           </label>
@@ -90,7 +92,7 @@
               type="radio"
               on-tap="_handleRebaseOnOther">
           <label id="rebaseOnOtherLabel" for="rebaseOnOtherInput">
-            Rebase on a specific change or ref <span hidden="[[!hasParent]]">
+            Rebase on a specific change or ref <span hidden$="[[!hasParent]]">
               (breaks relation chain)
             </span>
           </label>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
index 4ecb31f..eb0fa17 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog.js
@@ -30,6 +30,10 @@
      */
 
     properties: {
+      /**
+       * Weird API usage requires this to be String or Null. Add this so
+       * the closure compiler doesn't complain.
+       * @type {?string} */
       base: String,
       branch: String,
       hasParent: Boolean,
@@ -40,29 +44,29 @@
       '_updateSelectedOption(rebaseOnCurrent, hasParent)',
     ],
 
-    _displayParentOption: function(rebaseOnCurrent, hasParent) {
+    _displayParentOption(rebaseOnCurrent, hasParent) {
       return hasParent && rebaseOnCurrent;
     },
 
-    _displayParentUpToDateMsg: function(rebaseOnCurrent, hasParent) {
+    _displayParentUpToDateMsg(rebaseOnCurrent, hasParent) {
       return hasParent && !rebaseOnCurrent;
     },
 
-    _displayTipOption: function(rebaseOnCurrent, hasParent) {
+    _displayTipOption(rebaseOnCurrent, hasParent) {
       return !(!rebaseOnCurrent && !hasParent);
     },
 
-    _handleConfirmTap: function(e) {
+    _handleConfirmTap(e) {
       e.preventDefault();
       this.fire('confirm', null, {bubbles: false});
     },
 
-    _handleCancelTap: function(e) {
+    _handleCancelTap(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
 
-    _handleRebaseOnOther: function(e) {
+    _handleRebaseOnOther() {
       this.$.parentInput.focus();
     },
 
@@ -73,15 +77,15 @@
      * rebased on top of the target branch. Leaving out the base implies that it
      * should be rebased on top of its current parent.
      */
-    _handleRebaseOnTip: function(e) {
+    _handleRebaseOnTip() {
       this.base = '';
     },
 
-    _handleRebaseOnParent: function(e) {
+    _handleRebaseOnParent() {
       this.base = null;
     },
 
-    _handleEnterChangeNumberTap: function(e) {
+    _handleEnterChangeNumberTap() {
       this.$.rebaseOnOtherInput.checked = true;
     },
 
@@ -89,7 +93,7 @@
      * Sets the default radio button based on the state of the app and
      * the corresponding value to be submitted.
      */
-    _updateSelectedOption: function(rebaseOnCurrent, hasParent) {
+    _updateSelectedOption(rebaseOnCurrent, hasParent) {
       if (this._displayParentOption(rebaseOnCurrent, hasParent)) {
         this.$.rebaseOnParentInput.checked = true;
         this._handleRebaseOnParent();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
index 37eb812..0ea7c49 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-confirm-rebase-dialog.html">
 
 <script>void(0);</script>
@@ -33,14 +32,14 @@
 </test-fixture>
 
 <script>
-  suite('gr-confirm-rebase-dialog tests', function() {
-    var element;
+  suite('gr-confirm-rebase-dialog tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('controls with parent and rebase on current available', function() {
+    test('controls with parent and rebase on current available', () => {
       element.rebaseOnCurrent = true;
       element.hasParent = true;
       flushAsynchronousOperations();
@@ -51,7 +50,7 @@
       assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
     });
 
-    test('controls with parent rebase on current not available', function() {
+    test('controls with parent rebase on current not available', () => {
       element.rebaseOnCurrent = false;
       element.hasParent = true;
       flushAsynchronousOperations();
@@ -62,7 +61,7 @@
       assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
     });
 
-    test('controls without parent and rebase on current available', function() {
+    test('controls without parent and rebase on current available', () => {
       element.rebaseOnCurrent = true;
       element.hasParent = false;
       flushAsynchronousOperations();
@@ -73,7 +72,7 @@
       assert.isTrue(element.$.tipUpToDateMsg.hasAttribute('hidden'));
     });
 
-    test('controls without parent rebase on current not available', function() {
+    test('controls without parent rebase on current not available', () => {
       element.rebaseOnCurrent = false;
       element.hasParent = false;
       flushAsynchronousOperations();
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
index a38811f8..92e8de3 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -17,10 +17,11 @@
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-confirm-revert-dialog">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
@@ -39,7 +40,8 @@
         width: 73ch; /* Add a char to account for the border. */
 
         --iron-autogrow-textarea {
-          border: 1px solid #ddd;
+          border: 1px solid #cdcdcd;
+          box-sizing: border-box;
           font-family: var(--monospace-font-family);
         }
       }
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
index 8f621f0..5b11652 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
@@ -14,6 +14,9 @@
 (function() {
   'use strict';
 
+  const ERR_COMMIT_NOT_FOUND =
+      'Unable to find the commit hash of this change.';
+
   Polymer({
     is: 'gr-confirm-revert-dialog',
 
@@ -33,27 +36,26 @@
       message: String,
     },
 
-    populateRevertMessage: function(message, commitHash) {
+    populateRevertMessage(message, commitHash) {
       // Figure out what the revert title should be.
-      var originalTitle = message.split('\n')[0];
-      var revertTitle = 'Revert "' + originalTitle + '"';
+      const originalTitle = message.split('\n')[0];
+      const revertTitle = `Revert "${originalTitle}"`;
       if (!commitHash) {
-        alert('Unable to find the commit hash of this change.');
+        this.fire('show-alert', {message: ERR_COMMIT_NOT_FOUND});
         return;
       }
-      var revertCommitText = 'This reverts commit ' + commitHash + '.';
+      const revertCommitText = `This reverts commit ${commitHash}.`;
 
-      this.message = revertTitle + '\n\n' +
-                     revertCommitText + '\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n';
+      this.message = `${revertTitle}\n\n${revertCommitText}\n\n` +
+          `Reason for revert: <INSERT REASONING HERE>\n`;
     },
 
-    _handleConfirmTap: function(e) {
+    _handleConfirmTap(e) {
       e.preventDefault();
       this.fire('confirm', null, {bubbles: false});
     },
 
-    _handleCancelTap: function(e) {
+    _handleCancelTap(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
index d5c459b..c1220cb 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-confirm-revert-dialog.html">
 
 <script>void(0);</script>
@@ -33,62 +32,66 @@
 </test-fixture>
 
 <script>
-  suite('gr-confirm-revert-dialog tests', function() {
-    var element;
+  suite('gr-confirm-revert-dialog tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
+      sandbox =sinon.sandbox.create();
     });
 
-    test('no match', function() {
+    teardown(() => sandbox.restore());
+
+    test('no match', () => {
       assert.isNotOk(element.message);
-      var alertStub = sinon.stub(window, 'alert');
+      const alertStub = sandbox.stub();
+      element.addEventListener('show-alert', alertStub);
       element.populateRevertMessage('not a commitHash in sight', undefined);
       assert.isTrue(alertStub.calledOnce);
-      alertStub.restore();
     });
 
-    test('single line', function() {
+    test('single line', () => {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
           'one line commit\n\nChange-Id: abcdefg\n',
           'abcd123');
-      var expected = 'Revert "one line commit"\n\n' +
-                     'This reverts commit abcd123.\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n';
+      const expected = 'Revert "one line commit"\n\n' +
+          'This reverts commit abcd123.\n\n' +
+          'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
 
-    test('multi line', function() {
+    test('multi line', () => {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
           'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
           'abcd123');
-      var expected = 'Revert "many lines"\n\n' +
-                     'This reverts commit abcd123.\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n';
+      const expected = 'Revert "many lines"\n\n' +
+          'This reverts commit abcd123.\n\n' +
+          'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
 
-    test('issue above change id', function() {
+    test('issue above change id', () => {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
           'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
           'abcd123');
-      var expected = 'Revert "much lines"\n\n' +
-                     'This reverts commit abcd123.\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n';
+      const expected = 'Revert "much lines"\n\n' +
+          'This reverts commit abcd123.\n\n' +
+          'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
 
-    test('revert a revert', function() {
+    test('revert a revert', () => {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
           'Revert "one line commit"\n\nChange-Id: abcdefg\n',
           'abcd123');
-      var expected = 'Revert "Revert "one line commit""\n\n' +
-                     'This reverts commit abcd123.\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n';
+      const expected = 'Revert "Revert "one line commit""\n\n' +
+          'This reverts commit abcd123.\n\n' +
+          'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index 0e97d36..fdd9b26 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -14,77 +14,31 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
+<link rel="import" href="../../shared/gr-download-commands/gr-download-commands.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-download-dialog">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
         padding: 1em;
       }
-      ul {
-        list-style: none;
-        margin-bottom: .5em;
-      }
-      li {
-        display: inline-block;
-        margin: 0;
-        padding: 0;
-      }
-      li gr-button {
-        margin-right: 1em;
-      }
-      label,
-      input {
-        display: block;
-      }
-      label {
-        font-weight: bold;
-      }
-      input {
-        font-family: var(--monospace-font-family);
-        font-size: inherit;
-      }
-      li[selected] gr-button {
-        color: #000;
-        font-weight: bold;
-        text-decoration: none;
-      }
       header {
         display: flex;
-        justify-content: space-between;
-      }
-      main {
-        border-bottom: 1px solid #ddd;
-        border-top: 1px solid #ddd;
-        padding: .5em;
       }
       footer {
         display: flex;
         justify-content: space-between;
         padding-top: .75em;
       }
-      .command {
-        display: flex;
-        flex-wrap: wrap;
-        margin-bottom: .5em;
-        width: 60em;
-      }
-      .command label {
-        flex: 0 0 100%;
-      }
-      .copyCommand {
-        flex-grow: 1;
-        margin-right: .3em;
-      }
       .closeButtonContainer {
         display: flex;
-        flex: 1;
+        flex: 0;
         justify-content: flex-end;
       }
       .patchFiles {
@@ -99,49 +53,39 @@
       .archives a:last-of-type {
         margin-right: 0;
       }
+      .title {
+        text-align: center;
+        flex: 1;
+      }
     </style>
     <header>
-      <ul hidden$="[[!_schemes.length]]" hidden>
-        <template is="dom-repeat" items="[[_schemes]]" as="scheme">
-          <li selected$="[[_computeSchemeSelected(scheme, _selectedScheme)]]">
-            <gr-button link data-scheme$="[[scheme]]" on-tap="_handleSchemeTap">
-              [[scheme]]
-            </gr-button>
-          </li>
-        </template>
-      </ul>
+      <span class="title">
+        Patch set [[patchNum]] of [[_computePatchSetQuantity(change.revisions)]]
+      </span>
       <span class="closeButtonContainer">
         <gr-button id="closeButton"
             link
             on-tap="_handleCloseTap">Close</gr-button>
       </span>
     </header>
-    <main hidden$="[[!_schemes.length]]" hidden>
-      <template is="dom-repeat"
-          items="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
-          as="command">
-        <div class="command">
-          <label>[[command.title]]</label>
-          <input is="iron-input"
-              class="copyCommand"
-              type="text"
-              bind-value="[[command.command]]"
-              on-tap="_handleInputTap"
-              readonly>
-          <gr-button class="copyToClipboard" on-tap="_copyToClipboard">
-            copy
-          </gr-button>
-        </div>
-      </template>
-    </main>
+     <gr-download-commands
+          id="downloadCommands"
+          commands="[[_computeDownloadCommands(change, patchNum, _selectedScheme)]]"
+          schemes="[[_schemes]]"
+          selected-scheme="{{_selectedScheme}}"></gr-download-commands>
     <footer>
       <div class="patchFiles">
         <label>Patch file</label>
         <div>
-          <a id="download" href$="[[_computeDownloadLink(change, patchNum)]]">
+          <a
+              id="download"
+              href$="[[_computeDownloadLink(change, patchNum)]]"
+              download>
             [[_computeDownloadFilename(change, patchNum)]]
           </a>
-          <a href$="[[_computeZipDownloadLink(change, patchNum)]]">
+          <a
+              href$="[[_computeZipDownloadLink(change, patchNum)]]"
+              download>
             [[_computeZipDownloadFilename(change, patchNum)]]
           </a>
         </div>
@@ -150,14 +94,15 @@
         <label>Archive</label>
         <div id="archives" class="archives">
           <template is="dom-repeat" items="[[config.archives]]" as="format">
-            <a href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]">
+            <a
+                href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]"
+                download>
               [[format]]
             </a>
           </template>
         </div>
       </div>
     </footer>
-    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-download-dialog.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index 41f6792..41242f2 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -24,18 +24,15 @@
      */
 
     properties: {
+      /** @type {{ revisions: Array }} */
       change: Object,
       patchNum: String,
+      /** @type {?} */
       config: Object,
-      loggedIn: {
-        type: Boolean,
-        value: false,
-        observer: '_loggedInChanged',
-      },
 
       _schemes: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
         computed: '_computeSchemes(change, patchNum)',
         observer: '_schemesChanged',
       },
@@ -47,87 +44,106 @@
     },
 
     behaviors: [
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
-    focus: function() {
+    focus() {
       if (this._schemes.length) {
-        this.$$('.copyToClipboard').focus();
+        this.$.downloadCommands.focusOnCopy();
       } else {
         this.$.download.focus();
       }
     },
 
-    getFocusStops: function() {
-      var links = this.$$('#archives').querySelectorAll('a');
+    getFocusStops() {
+      const links = this.$$('#archives').querySelectorAll('a');
       return {
         start: this.$.closeButton,
         end: links[links.length - 1],
       };
     },
 
-    _loggedInChanged: function(loggedIn) {
-      if (!loggedIn) { return; }
-      this.$.restAPI.getPreferences().then(function(prefs) {
-        if (prefs.download_scheme) {
-          // Note (issue 5180): normalize the download scheme with lower-case.
-          this._selectedScheme = prefs.download_scheme.toLowerCase();
-        }
-      }.bind(this));
-    },
-
-    _computeDownloadCommands: function(change, patchNum, _selectedScheme) {
-      var commandObj;
-      for (var rev in change.revisions) {
-        if (change.revisions[rev]._number == patchNum &&
-            change.revisions[rev].fetch.hasOwnProperty(_selectedScheme)) {
-          commandObj = change.revisions[rev].fetch[_selectedScheme].commands;
+    _computeDownloadCommands(change, patchNum, _selectedScheme) {
+      let commandObj;
+      for (const rev of Object.values(change.revisions || {})) {
+        if (this.patchNumEquals(rev._number, patchNum) &&
+            rev.fetch.hasOwnProperty(_selectedScheme)) {
+          commandObj = rev.fetch[_selectedScheme].commands;
           break;
         }
       }
-      var commands = [];
-      for (var title in commandObj) {
+      const commands = [];
+      for (const title in commandObj) {
+        if (!commandObj.hasOwnProperty(title)) { continue; }
         commands.push({
-          title: title,
+          title,
           command: commandObj[title],
         });
       }
       return commands;
     },
 
-    _computeZipDownloadLink: function(change, patchNum) {
+    /**
+     * @param {!Object} change
+     * @param {number|string} patchNum
+     *
+     * @return {string}
+     */
+    _computeZipDownloadLink(change, patchNum) {
       return this._computeDownloadLink(change, patchNum, true);
     },
 
-    _computeZipDownloadFilename: function(change, patchNum) {
+    /**
+     * @param {!Object} change
+     * @param {number|string} patchNum
+     *
+     * @return {string}
+     */
+    _computeZipDownloadFilename(change, patchNum) {
       return this._computeDownloadFilename(change, patchNum, true);
     },
 
-    _computeDownloadLink: function(change, patchNum, zip) {
+    /**
+     * @param {!Object} change
+     * @param {number|string} patchNum
+     * @param {boolean=} opt_zip
+     *
+     * @return {string} Not sure why there was a mismatch
+     */
+    _computeDownloadLink(change, patchNum, opt_zip) {
       return this.changeBaseURL(change._number, patchNum) + '/patch?' +
-          (zip ? 'zip' : 'download');
+          (opt_zip ? 'zip' : 'download');
     },
 
-    _computeDownloadFilename: function(change, patchNum, zip) {
-      var shortRev;
-      for (var rev in change.revisions) {
-        if (change.revisions[rev]._number == patchNum) {
+
+    /**
+     * @param {!Object} change
+     * @param {number|string} patchNum
+     * @param {boolean=} opt_zip
+     *
+     * @return {string}
+     */
+    _computeDownloadFilename(change, patchNum, opt_zip) {
+      let shortRev = '';
+      for (const rev in change.revisions) {
+        if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
           shortRev = rev.substr(0, 7);
           break;
         }
       }
-      return shortRev + '.diff.' + (zip ? 'zip' : 'base64');
+      return shortRev + '.diff.' + (opt_zip ? 'zip' : 'base64');
     },
 
-    _computeArchiveDownloadLink: function(change, patchNum, format) {
+    _computeArchiveDownloadLink(change, patchNum, format) {
       return this.changeBaseURL(change._number, patchNum) +
           '/archive?format=' + format;
     },
 
-    _computeSchemes: function(change, patchNum) {
-      for (var rev in change.revisions) {
-        if (change.revisions[rev]._number == patchNum) {
-          var fetch = change.revisions[rev].fetch;
+    _computeSchemes(change, patchNum) {
+      for (const rev of Object.values(change.revisions || {})) {
+        if (this.patchNumEquals(rev._number, patchNum)) {
+          const fetch = rev.fetch;
           if (fetch) {
             return Object.keys(fetch).sort();
           }
@@ -137,42 +153,21 @@
       return [];
     },
 
-    _computeSchemeSelected: function(scheme, selectedScheme) {
-      return scheme == selectedScheme;
+    _computePatchSetQuantity(revisions) {
+      if (!revisions) { return 0; }
+      return Object.keys(revisions).length;
     },
 
-    _handleSchemeTap: function(e) {
-      e.preventDefault();
-      var el = Polymer.dom(e).rootTarget;
-      this._selectedScheme = el.getAttribute('data-scheme');
-      if (this.loggedIn) {
-        this.$.restAPI.savePreferences({download_scheme: this._selectedScheme});
-      }
-    },
-
-    _handleInputTap: function(e) {
-      e.preventDefault();
-      Polymer.dom(e).rootTarget.select();
-    },
-
-    _handleCloseTap: function(e) {
+    _handleCloseTap(e) {
       e.preventDefault();
       this.fire('close', null, {bubbles: false});
     },
 
-    _schemesChanged: function(schemes) {
-      if (schemes.length == 0) { return; }
-      if (schemes.indexOf(this._selectedScheme) == -1) {
+    _schemesChanged(schemes) {
+      if (schemes.length === 0) { return; }
+      if (!schemes.includes(this._selectedScheme)) {
         this._selectedScheme = schemes.sort()[0];
       }
     },
-
-    _copyToClipboard: function(e) {
-      e.target.parentElement.querySelector('.copyCommand').select();
-      document.execCommand('copy');
-      getSelection().removeAllRanges();
-      e.target.textContent = 'done';
-      setTimeout(function() { e.target.textContent = 'copy'; }, 1000);
-    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 0635d6d..f6e1748 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-download-dialog.html">
 
 <script>void(0);</script>
@@ -48,8 +47,8 @@
           fetch: {
             repo: {
               commands: {
-                repo: 'repo download test-project 5/1'
-              }
+                repo: 'repo download test-project 5/1',
+              },
             },
             ssh: {
               commands: {
@@ -69,8 +68,8 @@
                 'Pull':
                   'git pull ' +
                   'ssh://andybons@localhost:29418/test-project ' +
-                  'refs/changes/05/5/1'
-              }
+                  'refs/changes/05/5/1',
+              },
             },
             http: {
               commands: {
@@ -90,12 +89,12 @@
                 'Pull':
                   'git pull ' +
                   'http://andybons@localhost:8080/a/test-project ' +
-                  'refs/changes/05/5/1'
-              }
-            }
-          }
-        }
-      }
+                  'refs/changes/05/5/1',
+              },
+            },
+          },
+        },
+      },
     };
   }
 
@@ -106,193 +105,82 @@
         '34685798fe548b6d17d1e8e5edc43a26d055cc72': {
           _number: 1,
           fetch: {},
-        }
-      }
+        },
+      },
     };
   }
 
-  suite('gr-download-dialog tests with no fetch options', function() {
-    var element;
+  suite('gr-download-dialog', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+
       element = fixture('basic');
-      element.change = getChangeObjectNoFetch();
-      element.patchNum = 1;
+      element.patchNum = '1';
       element.config = {
         schemes: {
           'anonymous http': {},
-          http: {},
-          repo: {},
-          ssh: {},
+          'http': {},
+          'repo': {},
+          'ssh': {},
         },
         archives: ['tgz', 'tar', 'tbz2', 'txz'],
       };
-    });
 
-    test('focuses on first download link if no copy links', function() {
       flushAsynchronousOperations();
-      var focusStub = sinon.stub(element.$.download, 'focus');
-      element.focus();
-      assert.isTrue(focusStub.called);
-      focusStub.restore();
-    });
-  });
-
-  suite('gr-download-dialog tests', function() {
-    var element;
-
-    setup(function() {
-      element = fixture('basic');
-      element.change = getChangeObject();
-      element.patchNum = 1;
-      element.config = {
-        schemes: {
-          'anonymous http': {},
-          http: {},
-          repo: {},
-          ssh: {},
-        },
-        archives: ['tgz', 'tar', 'tbz2', 'txz'],
-      };
     });
 
-    test('focuses on first copy link', function() {
-      flushAsynchronousOperations();
-      var focusStub = sinon.stub(element.$$('.copyToClipboard'), 'focus');
-      element.focus();
-      flushAsynchronousOperations();
-      assert.isTrue(focusStub.called);
-      focusStub.restore();
+    teardown(() => {
+      sandbox.restore();
     });
 
-    test('copy to clipboard', function() {
-      flushAsynchronousOperations();
-      var clipboardSpy = sinon.spy(element, '_copyToClipboard');
-      var copyBtn = element.$$('.copyToClipboard');
-      MockInteractions.tap(copyBtn);
-      assert.isTrue(clipboardSpy.called);
+    test('anchors use download attribute', () => {
+      const anchors = Polymer.dom(element.root).querySelectorAll('a');
+      assert.isTrue(!anchors.some(a => !a.hasAttribute('download')));
     });
 
-    test('element visibility', function() {
-      assert.isFalse(element.$$('ul').hasAttribute('hidden'));
-      assert.isFalse(element.$$('main').hasAttribute('hidden'));
-      assert.isFalse(element.$$('.archivesContainer').hasAttribute('hidden'));
-
-      element.set('config.archives', []);
-      assert.isTrue(element.$$('.archivesContainer').hasAttribute('hidden'));
-    });
-
-    test('computed fields', function() {
-      assert.equal(element._computeArchiveDownloadLink(
-          {_number: 123}, 2, 'tgz'),
-          '/changes/123/revisions/2/archive?format=tgz');
-    });
-
-    test('close event', function(done) {
-      element.addEventListener('close', function() {
-        done();
-      });
-      MockInteractions.tap(element.$$('.closeButtonContainer gr-button'));
-    });
-
-    test('tab selection', function() {
-      flushAsynchronousOperations();
-      var el = element.$$('[data-scheme="http"]').parentElement;
-      assert.isTrue(el.hasAttribute('selected'));
-      ['repo', 'ssh'].forEach(function(scheme) {
-        var el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
-        assert.isFalse(el.hasAttribute('selected'));
+    suite('gr-download-dialog tests with no fetch options', () => {
+      setup(() => {
+        element.change = getChangeObjectNoFetch();
+        flushAsynchronousOperations();
       });
 
-      MockInteractions.tap(element.$$('[data-scheme="ssh"]'));
-      el = element.$$('[data-scheme="ssh"]').parentElement;
-      assert.isTrue(el.hasAttribute('selected'));
-      ['http', 'repo'].forEach(function(scheme) {
-        var el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
-        assert.isFalse(el.hasAttribute('selected'));
+      test('focuses on first download link if no copy links', () => {
+        const focusStub = sandbox.stub(element.$.download, 'focus');
+        element.focus();
+        assert.isTrue(focusStub.called);
+        focusStub.restore();
       });
     });
 
-    test('loads scheme from preferences w/o initial login', function(done) {
-      stub('gr-rest-api-interface', {
-        getPreferences: function() {
-          return Promise.resolve({download_scheme: 'repo'});
-        },
+    suite('gr-download-dialog with fetch options', () => {
+      setup(() => {
+        element.change = getChangeObject();
+        flushAsynchronousOperations();
       });
 
-      element.loggedIn = true;
-
-      assert.isTrue(element.$.restAPI.getPreferences.called);
-      element.$.restAPI.getPreferences.lastCall.returnValue.then(function() {
-        assert.equal(element._selectedScheme, 'repo');
-        done();
-      });
-    });
-  });
-
-  suite('gr-download-dialog tests', function() {
-    var element;
-
-    setup(function() {
-      stub('gr-rest-api-interface', {
-        getPreferences: function() {
-          return Promise.resolve({download_scheme: 'repo'});
-        },
+      test('focuses on first copy link', () => {
+        const focusStub = sinon.stub(element.$.downloadCommands, 'focusOnCopy');
+        element.focus();
+        flushAsynchronousOperations();
+        assert.isTrue(focusStub.called);
+        focusStub.restore();
       });
 
-      element = fixture('loggedIn');
-      element.change = getChangeObject();
-      element.patchNum = 1;
-      element.config = {
-        schemes: {
-          'anonymous http': {},
-          http: {},
-          repo: {},
-          ssh: {},
-        },
-        archives: ['tgz', 'tar', 'tbz2', 'txz'],
-      };
-    });
-
-    test('loads scheme from preferences', function(done) {
-      element.$.restAPI.getPreferences.lastCall.returnValue.then(function() {
-        assert.equal(element._selectedScheme, 'repo');
-        done();
+      test('computed fields', () => {
+        assert.equal(element._computeArchiveDownloadLink(
+            {_number: 123}, 2, 'tgz'),
+            '/changes/123/revisions/2/archive?format=tgz');
       });
-    });
 
-    test('saves scheme to preferences', function() {
-      var savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences',
-          function() { return Promise.resolve(); });
-
-      Polymer.dom.flush();
-
-      var firstSchemeButton = element.$$('li gr-button[data-scheme]');
-
-      MockInteractions.tap(firstSchemeButton);
-
-      assert.isTrue(savePrefsStub.called);
-      assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
-          firstSchemeButton.getAttribute('data-scheme'));
-    });
-  });
-
-  test('normalize scheme from preferences', function(done) {
-    stub('gr-rest-api-interface', {
-      getPreferences: function() {
-        return Promise.resolve({download_scheme: 'REPO'});
-      },
-    });
-    element = fixture('loggedIn');
-    element.change = getChangeObject();
-    element.patchNum = 1;
-    element.config = {
-      schemes: {'anonymous http': {}, http: {}, repo: {}, ssh: {}},
-      archives: ['tgz', 'tar', 'tbz2', 'txz'],
-    };
-    element.$.restAPI.getPreferences.lastCall.returnValue.then(function() {
-      assert.equal(element._selectedScheme, 'repo');
-      done();
+      test('close event', done => {
+        element.addEventListener('close', () => {
+          done();
+        });
+        MockInteractions.tap(element.$$('.closeButtonContainer gr-button'));
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
new file mode 100644
index 0000000..fb86260
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.html
@@ -0,0 +1,234 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
+<link rel="import" href="../../diff/gr-patch-range-select/gr-patch-range-select.html">
+<link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+
+<dom-module id="gr-file-list-header">
+  <template>
+    <style include="shared-styles">
+      .prefsButton {
+        float: right;
+      }
+      .collapseToggleButton {
+        text-decoration: none;
+      }
+      .patchInfoEdit.patchInfo-header {
+        background-color: #fcfad6;
+      }
+      .patchInfoOldPatchSet.patchInfo-header {
+        background-color: #fff9c4;
+      }
+      .patchInfo-header {
+        background-color: #fafafa;
+        border-bottom: 1px solid #ddd;
+        border-top: 1px solid #ddd;
+        display: flex;
+        min-height: 3.2em;
+        padding: .5em var(--default-horizontal-margin);
+      }
+      .patchInfo-header-wrapper {
+        align-items: center;
+        display: flex;
+        width: 100%;
+      }
+      .patchInfo-left {
+        align-items: center;
+        display: flex;
+        flex-wrap: wrap;
+      }
+      .patchInfo-header-wrapper .container.latestPatchContainer {
+        display: none;
+      }
+      .patchInfoOldPatchSet .container.latestPatchContainer {
+        display: initial;
+      }
+      .latestPatchContainer a {
+        text-decoration: none;
+      }
+      gr-editable-label.descriptionLabel {
+        max-width: 100%;
+      }
+      .mobile {
+        display: none;
+      }
+      #diffPrefsContainer,
+      .rightControls {
+        align-self: flex-end;
+        margin: auto 0 auto auto;
+      }
+      .showOnEdit {
+        display: none;
+      }
+      .editLoaded .hideOnEdit {
+        display: none;
+      }
+      .editLoaded .showOnEdit {
+        display: initial;
+      }
+      .patchInfo-header-wrapper .container {
+        align-items: center;
+        display: flex;
+      }
+      #modeSelect {
+        margin-left: .1em;
+      }
+      .fileList-header {
+        align-items: center;
+        display: flex;
+        font-weight: bold;
+        height: 2.25em;
+        margin: 0 calc(var(--default-horizontal-margin) / 2);
+        padding: 0 .25em;
+      }
+      .rightControls {
+        align-items: center;
+        display: flex;
+        flex-wrap: wrap;
+        font-weight: normal;
+        justify-content: flex-end;
+      }
+      .separator {
+        background-color: rgba(0, 0, 0, .3);
+        height: 1.5em;
+        margin: 0 .6em;
+        width: 1px;
+      }
+      .separator.transparent {
+        background-color: transparent;
+      }
+      .expandInline {
+        padding-right: .25em;
+      }
+      .editLoaded .hideOnEdit {
+        display: none;
+      }
+      .editLoaded .showOnEdit {
+        display: initial;
+      }
+      .label {
+        font-family: var(--font-family-bold);
+        margin-right: 1em;
+      }
+      .container.includedInContainer.hide {
+        display: none;
+      }
+      @media screen and (max-width: 50em) {
+        .patchInfo-header .desktop {
+          display: none;
+        }
+      }
+    </style>
+    <div class$="patchInfo-header [[_computeEditLoadedClass(editLoaded)]] [[_computePatchInfoClass(patchNum, allPatchSets)]]">
+      <div class="patchInfo-header-wrapper">
+        <div class="patchInfo-left">
+          <h3 class="label">Files</h3>
+          <gr-patch-range-select
+              id="rangeSelect"
+              comments="[[comments]]"
+              change-num="[[changeNum]]"
+              patch-num="[[patchNum]]"
+              base-patch-num="[[basePatchNum]]"
+              available-patches="[[allPatchSets]]"
+              revisions="[[change.revisions]]"
+              on-patch-range-change="_handlePatchChange">
+          </gr-patch-range-select>
+          <span class="separator"></span>
+          <gr-commit-info
+              change="[[change]]"
+              server-config="[[serverConfig]]"
+              commit-info="[[commitInfo]]"></gr-commit-info>
+          <span class="container latestPatchContainer">
+            <span class="separator"></span>
+            <a href$="[[changeUrl]]">Go to latest patch set</a>
+          </span>
+          <span class="container downloadContainer desktop">
+            <span class="separator"></span>
+            <gr-button link
+                class="download"
+                on-tap="_handleDownloadTap">Download</gr-button>
+          </span>
+        <span class$="container includedInContainer [[_hideIncludedIn(change)]] desktop">
+          <span class="separator"></span>
+          <gr-button link
+              class="includedIn"
+              on-tap="_handleIncludedInTap">Included In</gr-button>
+        </span>
+          <span class="container descriptionContainer hideOnEdit">
+            <span class="separator"></span>
+            <gr-editable-label
+                id="descriptionLabel"
+                class="descriptionLabel"
+                label-text="Add patchset description"
+                value="[[_computePatchSetDescription(change, patchNum)]]"
+                placeholder="[[_computeDescriptionPlaceholder(_descriptionReadOnly)]]"
+                read-only="[[_descriptionReadOnly]]"
+                on-changed="_handleDescriptionChanged"></gr-editable-label>
+          </span>
+        </div>
+        <span id="diffPrefsContainer"
+            class="hideOnEdit"
+            hidden$="[[_computePrefsButtonHidden(diffPrefs, loggedIn)]]"
+            hidden>
+          <gr-button link
+              class="prefsButton desktop"
+              on-tap="_handlePrefsTap">Diff Preferences</gr-button>
+        </span>
+      </div>
+    </div>
+    <div class="fileList-header">
+      <div class="rightControls">
+        <template is="dom-if"
+            if="[[_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
+          <gr-button
+              id="expandBtn"
+              link
+              on-tap="_expandAllDiffs">Show diffs</gr-button>
+          <span class="separator"></span>
+          <gr-button
+              id="collapseBtn"
+              link
+              on-tap="_collapseAllDiffs">Hide diffs</gr-button>
+        </template>
+        <template is="dom-if"
+            if="[[!_fileListActionsVisible(shownFileCount, _maxFilesForBulkActions)]]">
+          <div class="warning">
+            Bulk actions disabled because there are too many files.
+          </div>
+        </template>
+        <span class="separator"></span>
+        <gr-select
+            id="modeSelect"
+            bind-value="{{diffViewMode}}">
+          <select>
+            <option value="SIDE_BY_SIDE">Side By Side</option>
+            <option value="UNIFIED_DIFF">Unified</option>
+          </select>
+        </gr-select>
+      </div>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-file-list-header.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
new file mode 100644
index 0000000..c55e8c4
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header.js
@@ -0,0 +1,181 @@
+// 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.
+(function() {
+  'use strict';
+
+  // Maximum length for patch set descriptions.
+  const PATCH_DESC_MAX_LENGTH = 500;
+
+  Polymer({
+    is: 'gr-file-list-header',
+
+    properties: {
+      account: Object,
+      allPatchSets: Array,
+      /** @type {?} */
+      change: Object,
+      changeNum: String,
+      changeUrl: String,
+      comments: Object,
+      commitInfo: Object,
+      editLoaded: Boolean,
+      loggedIn: Boolean,
+      serverConfig: Object,
+      shownFileCount: Number,
+      diffPrefs: Object,
+      diffViewMode: {
+        type: String,
+        notify: true,
+      },
+      patchNum: String,
+      basePatchNum: String,
+      revisions: Array,
+      // Caps the number of files that can be shown and have the 'show diffs' /
+      // 'hide diffs' buttons still be functional.
+      _maxFilesForBulkActions: {
+        type: Number,
+        readOnly: true,
+        value: 225,
+      },
+      _descriptionReadOnly: {
+        type: Boolean,
+        computed: '_computeDescriptionReadOnly(loggedIn, change, account)',
+      },
+    },
+
+    behaviors: [
+      Gerrit.PatchSetBehavior,
+    ],
+
+    _expandAllDiffs() {
+      this.fire('expand-diffs');
+    },
+
+    _collapseAllDiffs() {
+      this.fire('collapse-diffs');
+    },
+
+    _computeDescriptionPlaceholder(readOnly) {
+      return (readOnly ? 'No' : 'Add') + ' patchset description';
+    },
+
+    _computeDescriptionReadOnly(loggedIn, change, account) {
+      return !(loggedIn && (account._account_id === change.owner._account_id));
+    },
+
+    _computePatchSetDescription(change, patchNum) {
+      const rev = this.getRevisionByPatchNum(change.revisions, patchNum);
+      return (rev && rev.description) ?
+          rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
+    },
+
+    /**
+     * @param {!Object} revisions The revisions object keyed by revision hashes
+     * @param {?Object} patchSet A revision already fetched from {revisions}
+     * @return {string|undefined} the SHA hash corresponding to the revision.
+     */
+    _getPatchsetHash(revisions, patchSet) {
+      for (const rev in revisions) {
+        if (revisions.hasOwnProperty(rev) &&
+            revisions[rev] === patchSet) {
+          return rev;
+        }
+      }
+    },
+
+    _handleDescriptionChanged(e) {
+      const desc = e.detail.trim();
+      const rev = this.getRevisionByPatchNum(this.change.revisions,
+          this.patchNum);
+      const sha = this._getPatchsetHash(this.change.revisions, rev);
+      this.$.restAPI.setDescription(this.changeNum,
+          this.patchNum, desc)
+          .then(res => {
+            if (res.ok) {
+              this.set(['change', 'revisions', sha, 'description'], desc);
+            }
+          });
+    },
+
+    _computeBasePatchDisabled(patchNum, currentPatchNum) {
+      return this.findSortedIndex(patchNum, this.revisions) >=
+          this.findSortedIndex(currentPatchNum, this.revisions);
+    },
+
+    _computePrefsButtonHidden(prefs, loggedIn) {
+      return !loggedIn || !prefs;
+    },
+
+
+    _fileListActionsVisible(shownFileCount, maxFilesForBulkActions) {
+      return shownFileCount <= maxFilesForBulkActions;
+    },
+
+    /**
+     * Determines if a patch number should be disabled based on value of the
+     * basePatchNum from gr-file-list.
+     * @param {number} patchNum Patch number available in dropdown
+     * @param {number|string} basePatchNum Base patch number from file list
+     * @return {boolean}
+     */
+    _computePatchSetDisabled(patchNum, basePatchNum) {
+      if (basePatchNum === 'PARENT') { return false; }
+
+      return this.findSortedIndex(patchNum, this.revisions) <=
+          this.findSortedIndex(basePatchNum, this.revisions);
+    },
+
+    _handlePatchChange(e) {
+      const {basePatchNum, patchNum} = e.detail;
+      if (this.patchNumEquals(basePatchNum, this.basePatchNum) &&
+          this.patchNumEquals(patchNum, this.patchNum)) { return; }
+      Gerrit.Nav.navigateToChange(this.change, patchNum, basePatchNum);
+    },
+
+    _handlePrefsTap(e) {
+      e.preventDefault();
+      this.fire('open-diff-prefs');
+    },
+
+    _handleIncludedInTap(e) {
+      e.preventDefault();
+      this.fire('open-included-in-dialog');
+    },
+
+    _handleDownloadTap(e) {
+      e.preventDefault();
+      this.fire('open-download-dialog');
+    },
+
+    _computeEditLoadedClass(editLoaded) {
+      return editLoaded ? 'editLoaded' : '';
+    },
+
+    _computePatchInfoClass(patchNum, allPatchSets) {
+      if (this.patchNumEquals(patchNum, this.EDIT_NAME)) {
+        return 'patchInfoEdit';
+      }
+
+      const latestNum = this.computeLatestPatchNum(allPatchSets);
+      if (this.patchNumEquals(patchNum, latestNum)) {
+        return '';
+      }
+      return 'patchInfoOldPatchSet';
+    },
+
+    _hideIncludedIn(change) {
+      return change && change.status === 'MERGED' ? '' : 'hide';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
new file mode 100644
index 0000000..45a23fa
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-file-list-header/gr-file-list-header_test.html
@@ -0,0 +1,263 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-file-list-header</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<script src="../../../bower_components/page/page.js"></script>
+
+<link rel="import" href="gr-file-list-header.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-file-list-header></gr-file-list-header>
+  </template>
+</test-fixture>
+
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-file-list-header tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({test: 'config'}); },
+        getAccount() { return Promise.resolve(null); },
+        _fetchSharedCacheURL() { return Promise.resolve({}); },
+      });
+      element = fixture('basic');
+    });
+
+    teardown(done => {
+      flush(() => {
+        sandbox.restore();
+        done();
+      });
+    });
+
+    test('Diff preferences hidden when no prefs or logged out', () => {
+      element.loggedIn = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element.loggedIn = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element.loggedIn = false;
+      element.diffPrefs = {font_size: '12'};
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element.loggedIn = true;
+      flushAsynchronousOperations();
+      assert.isFalse(element.$.diffPrefsContainer.hidden);
+    });
+
+    test('_computeDescriptionReadOnly', () => {
+      assert.equal(element._computeDescriptionReadOnly(false,
+          {owner: {_account_id: 1}}, {_account_id: 1}), true);
+      assert.equal(element._computeDescriptionReadOnly(true,
+          {owner: {_account_id: 0}}, {_account_id: 1}), true);
+      assert.equal(element._computeDescriptionReadOnly(true,
+          {owner: {_account_id: 1}}, {_account_id: 1}), false);
+    });
+
+    test('_computeDescriptionPlaceholder', () => {
+      assert.equal(element._computeDescriptionPlaceholder(true),
+          'No patchset description');
+      assert.equal(element._computeDescriptionPlaceholder(false),
+          'Add patchset description');
+    });
+
+    test('_computePatchSetDisabled', () => {
+      element.revisions = [
+        {_number: 1},
+        {_number: 2},
+        {_number: element.EDIT_NAME, basePatchNum: 2},
+        {_number: 3},
+      ];
+      let basePatchNum = 'PARENT';
+      let patchNum = 1;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          false);
+      basePatchNum = 1;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          true);
+      patchNum = 2;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          false);
+      basePatchNum = element.EDIT_NAME;
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          true);
+      patchNum = '3';
+      assert.equal(element._computePatchSetDisabled(patchNum, basePatchNum),
+          false);
+    });
+
+    test('_handleDescriptionChanged', () => {
+      const putDescStub = sandbox.stub(element.$.restAPI, 'setDescription')
+          .returns(Promise.resolve({ok: true}));
+      sandbox.stub(element, '_computeDescriptionReadOnly');
+
+      element.changeNum = '42';
+      element.basePatchNum = 'PARENT';
+      element.patchNum = 1;
+
+      element.change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev1: {_number: 1, description: 'test', commit: {commit: 'rev1'}},
+        },
+        current_revision: 'rev1',
+        status: 'NEW',
+        labels: {},
+        actions: {},
+        owner: {_account_id: 1},
+      };
+      element.account = {_account_id: 1};
+      element.loggedIn = true;
+
+      flushAsynchronousOperations();
+      const label = element.$.descriptionLabel;
+      assert.equal(label.value, 'test');
+      label.editing = true;
+      label._inputText = 'test2';
+      label._save();
+      flushAsynchronousOperations();
+      assert.isTrue(putDescStub.called);
+      assert.equal(putDescStub.args[0][2], 'test2');
+      assert.equal(element.change.revisions.rev1.description, 'test');
+    });
+
+    test('expandAllDiffs called when expand button clicked', () => {
+      element.shownFileCount = 1;
+      flushAsynchronousOperations();
+      sandbox.stub(element, '_expandAllDiffs');
+      MockInteractions.tap(Polymer.dom(element.root).querySelector(
+          '#expandBtn'));
+      assert.isTrue(element._expandAllDiffs.called);
+    });
+
+    test('collapseAllDiffs called when expand button clicked', () => {
+      element.shownFileCount = 1;
+      flushAsynchronousOperations();
+      sandbox.stub(element, '_collapseAllDiffs');
+      MockInteractions.tap(Polymer.dom(element.root).querySelector(
+          '#collapseBtn'));
+      assert.isTrue(element._collapseAllDiffs.called);
+    });
+
+    test('show/hide diffs disabled for large amounts of files', done => {
+      const computeSpy = sandbox.spy(element, '_fileListActionsVisible');
+      element._files = [];
+      element.changeNum = '42';
+      element.basePatchNum = 'PARENT';
+      element.patchNum = '2';
+      element.shownFileCount = 1;
+      flush(() => {
+        assert.isTrue(computeSpy.lastCall.returnValue);
+        _.times(element._maxFilesForBulkActions + 1, () => {
+          element.shownFileCount = element.shownFileCount + 1;
+        });
+        assert.isFalse(computeSpy.lastCall.returnValue);
+        done();
+      });
+    });
+
+    test('diff mode selector is set correctly', () => {
+      const select = element.$.modeSelect;
+      element.diffViewMode = 'SIDE_BY_SIDE';
+      flushAsynchronousOperations();
+      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
+
+      element.diffViewMode = 'UNIFIED_DIFF';
+      flushAsynchronousOperations();
+      assert.equal(select.nativeSelect.value, 'UNIFIED_DIFF');
+    });
+
+    test('navigateToChange called when range select changes', () => {
+      const navigateToChangeStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+      element.change = {
+        change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+        revisions: {
+          rev2: {_number: 2},
+          rev1: {_number: 1},
+          rev13: {_number: 13},
+          rev3: {_number: 3},
+        },
+        status: 'NEW',
+        labels: {},
+      };
+      element.basePatchNum = 1;
+      element.patchNum = 2;
+
+      element._handlePatchChange({detail: {basePatchNum: 1, patchNum: 3}});
+      assert.equal(navigateToChangeStub.callCount, 1);
+      assert.isTrue(navigateToChangeStub.lastCall
+          .calledWithExactly(element.change, 3, 1));
+    });
+
+    test('class is applied to file list on old patch set', () => {
+      const allPatchSets = [{num: 1}, {num: 2}, {num: 4}];
+      assert.equal(element._computePatchInfoClass('1', allPatchSets),
+          'patchInfoOldPatchSet');
+      assert.equal(element._computePatchInfoClass('2', allPatchSets),
+          'patchInfoOldPatchSet');
+      assert.equal(element._computePatchInfoClass('4', allPatchSets), '');
+    });
+
+    suite('editLoaded behavior', () => {
+      setup(() => {
+        element.loggedIn = true;
+        element.diffPrefs = {};
+      });
+
+      const isVisible = el => {
+        assert.ok(el);
+        return getComputedStyle(el).getPropertyValue('display') !== 'none';
+      };
+
+      test('patch specific elements', () => {
+        element.editLoaded = true;
+        sandbox.stub(element, 'computeLatestPatchNum').returns('2');
+        flushAsynchronousOperations();
+
+        assert.isFalse(isVisible(element.$.diffPrefsContainer));
+        assert.isFalse(isVisible(element.$$('.descriptionContainer')));
+
+        element.editLoaded = false;
+        flushAsynchronousOperations();
+
+        assert.isTrue(isVisible(element.$$('.descriptionContainer')));
+        assert.isTrue(isVisible(element.$.diffPrefsContainer));
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index e324078..7e71f48 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -14,12 +14,13 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/async-foreach-behavior/async-foreach-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../../diff/gr-diff/gr-diff.html">
 <link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -27,31 +28,27 @@
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-file-list">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
       .row {
+        align-items: center;
+        border-top: 1px solid #ddd;
         display: flex;
-        padding: .1em .25em;
+        min-height: 2.25em;
+        padding: .2em var(--default-horizontal-margin);
       }
-      header {
-        display: flex;
-        font-weight: bold;
-        justify-content: space-between;
-        margin-bottom: .5em;
-      }
-      .rightControls {
-        display: flex;
-        flex-wrap: wrap;
-        font-weight: normal;
-        justify-content: flex-end;
-      }
-      .separator {
-        margin: 0 .25em;
+      :host(.loading) .row {
+        opacity: .5;
+      };
+      :host(.editLoaded) .hideOnEdit {
+        display: none;
       }
       .reviewed,
       .status {
@@ -71,6 +68,7 @@
         background-color: #ebf5fb;
       }
       .path {
+        cursor: pointer;
         flex: 1;
         padding-left: .35em;
         text-decoration: none;
@@ -92,7 +90,7 @@
         text-align: right;
       }
       .comments {
-        min-width: 10em;
+        padding-left: 2em;
       }
       .stats {
         min-width: 7em;
@@ -112,22 +110,18 @@
       }
       .drafts {
         color: #C62828;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .show-hide {
-        margin-left: .4em;
+        width: 1em;
       }
       .fileListButton {
         margin: .5em;
       }
       .totalChanges {
         justify-content: flex-end;
-        padding-right: 2.6em;
         text-align: right;
       }
-      .expandInline {
-        padding-right: .25em;
-      }
       .warning {
         color: #666;
       }
@@ -135,20 +129,17 @@
         display: none;
       }
       label.show-hide {
-        color: #00f;
+        color: var(--color-link);
         cursor: pointer;
         display: block;
         font-size: .8em;
         min-width: 2em;
-        margin-top: .1em;
       }
       gr-diff {
         box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         display: block;
         margin: .25em 0 1em;
-      }
-      .patchSetSelect {
-        max-width: 8em;
+        overflow-x: auto;
       }
       .truncatedFileName {
         display: none;
@@ -160,6 +151,36 @@
       .mobile {
         display: none;
       }
+      .reviewed {
+        margin-left: 2em;
+        width: 15em;
+      }
+      .reviewed label {
+        color: #2A66D9;
+        opacity: 0;
+        justify-content: flex-end;
+        width: 100%;
+      }
+      .reviewed label:hover {
+        cursor: pointer;
+        opacity: 100;
+      }
+      .row:hover .reviewed label,
+      .row:focus .reviewed label {
+        opacity: 100;
+      }
+      .reviewed input {
+        display: none;
+      }
+      .reviewedLabel {
+        color: rgba(0, 0, 0, .54);
+        margin-right: 1em;
+        opacity: 0;
+      }
+      .reviewedLabel.isReviewed {
+        display: initial;
+        opacity: 100;
+      }
       @media screen and (max-width: 50em) {
         .desktop {
           display: none;
@@ -190,140 +211,115 @@
         }
       }
     </style>
-    <header>
-      <div>Files</div>
-      <div class="rightControls">
-        <template is="dom-if"
-            if="[[_fileListActionsVisible(_shownFiles.*, _maxFilesForBulkActions)]]">
-          <gr-button link on-tap="_expandAllDiffs">Show diffs</gr-button>
-          <span class="separator">/</span>
-          <gr-button link on-tap="_collapseAllDiffs">Hide diffs</gr-button>
-        </template>
-        <template is="dom-if"
-            if="[[!_fileListActionsVisible(_shownFiles.*, _maxFilesForBulkActions)]]">
-          <div class="warning">
-            Bulk actions disabled because there are too many files.
+    <div
+        id="container"
+        on-tap="_handleFileListTap">
+      <template is="dom-repeat"
+          items="[[_shownFiles]]"
+          id="files"
+          as="file"
+          initial-count="[[fileListIncrement]]"
+          target-framerate="1">
+        <div class="file-row row" data-path$="[[file.__path]]" tabindex="-1">
+          <div class="show-hide" hidden$="[[_userPrefs.expand_inline_diffs]]">
+            <label class="show-hide" data-path$="[[file.__path]]"
+                data-expand=true>
+              <input type="checkbox" class="show-hide"
+                  checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
+                  data-path$="[[file.__path]]" data-expand=true>
+              [[_computeShowHideText(file.__path, _expandedFilePaths.*)]]
+            </label>
           </div>
-        </template>
-        <span class="separator">/</span>
-        <select
-            id="modeSelect"
-            is="gr-select"
-            bind-value="{{diffViewMode}}">
-          <option value="SIDE_BY_SIDE">Side By Side</option>
-          <option value="UNIFIED_DIFF">Unified</option>
-        </select>
-        <span class="separator">/</span>
-        <label>
-          Diff against
-          <select id="patchChange" bind-value="{{_diffAgainst}}" is="gr-select"
-              class="patchSetSelect" on-change="_handlePatchChange">
-            <option value="PARENT">Base</option>
-            <template
-                is="dom-repeat"
-                items="[[_computePatchSets(revisions.*, patchRange.*)]]"
-                as="patchNum">
-              <option value$="[[patchNum.num]]"
-                  disabled$="[[_computePatchSetDisabled(patchNum.num, patchRange.patchNum)]]">
-                [[patchNum.num]]
-                [[patchNum.desc]]
-              </option>
-            </template>
-          </select>
-        </label>
-      </div>
-    </header>
-    <template is="dom-repeat"
-        items="[[_shownFiles]]"
-        as="file"
-        initial-count="[[_fileListIncrement]]">
-      <div class="file-row row">
-        <div class="reviewed" hidden$="[[!_loggedIn]]" hidden>
-          <input type="checkbox" checked="[[file.isReviewed]]"
-              data-path$="[[file.__path]]" on-change="_handleReviewedChange"
-              class="reviewed" aria-label="Reviewed checkbox">
-        </div>
-        <div class$="[[_computeClass('status', file.__path)]]"
-            tabindex="0"
-            aria-label$="[[_computeFileStatusLabel(file.status)]]">
-          [[_computeFileStatus(file.status)]]
-        </div>
-        <a class$="[[_computePathClass(file.__path, _expandedFilePaths.*)]]"
-            href$="[[_computeDiffURL(changeNum, patchRange, file.__path)]]"
-            on-tap="_handleFileTap">
-          <div title$="[[_computeFileDisplayName(file.__path)]]"
-              class="fullFileName">
-            [[_computeFileDisplayName(file.__path)]]
+          <div class$="[[_computeClass('status', file.__path)]]"
+              tabindex="0"
+              aria-label$="[[_computeFileStatusLabel(file.status)]]">
+            [[_computeFileStatus(file.status)]]
           </div>
-          <div title$="[[_computeFileDisplayName(file.__path)]]"
-              class="truncatedFileName">
-            [[_computeTruncatedFileDisplayName(file.__path)]]
-          </div>
-          <div class="oldPath" hidden$="[[!file.old_path]]" hidden
-              title$="[[file.old_path]]">
-            [[file.old_path]]
-          </div>
-        </a>
-        <div class="comments desktop">
-          <span class="drafts">
-            [[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]
+          <span
+              data-url="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path)]]"
+              class$="[[_computePathClass(file.__path, _expandedFilePaths.*)]]">
+            <a href$="[[_computeDiffURL(change, patchRange.patchNum, patchRange.basePatchNum, file.__path)]]">
+              <span title$="[[computeDisplayPath(file.__path)]]"
+                  class="fullFileName">
+                [[computeDisplayPath(file.__path)]]
+              </span>
+              <span title$="[[computeDisplayPath(file.__path)]]"
+                  class="truncatedFileName">
+                [[computeTruncatedPath(file.__path)]]
+              </span>
+            </a>
+            <div class="oldPath" hidden$="[[!file.old_path]]" hidden
+                title$="[[file.old_path]]">
+              [[file.old_path]]
+            </div>
           </span>
-          [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
-          [[_computeUnresolvedString(comments, drafts, patchRange.patchNum, file.__path)]]
-        </div>
-        <div class="comments mobile">
-          <span class="drafts">
-            [[_computeDraftsStringMobile(drafts, patchRange.patchNum,
+          <div class="comments desktop">
+            <span class="drafts">
+              [[_computeDraftsString(drafts, patchRange.patchNum, file.__path)]]
+            </span>
+            [[_computeCommentsString(comments, patchRange.patchNum, file.__path)]]
+            [[_computeUnresolvedString(comments, drafts, patchRange.patchNum, file.__path)]]
+          </div>
+          <div class="comments mobile">
+            <span class="drafts">
+              [[_computeDraftsStringMobile(drafts, patchRange.patchNum,
+                  file.__path)]]
+            </span>
+            [[_computeCommentsStringMobile(comments, patchRange.patchNum,
                 file.__path)]]
-          </span>
-          [[_computeCommentsStringMobile(comments, patchRange.patchNum,
-              file.__path)]]
+          </div>
+          <div class$="[[_computeClass('stats', file.__path)]]">
+            <span
+                class="added"
+                tabindex="0"
+                aria-label$="[[file.lines_inserted]] lines added"
+                hidden$=[[file.binary]]>
+              +[[file.lines_inserted]]
+            </span>
+            <span
+                class="removed"
+                tabindex="0"
+                aria-label$="[[file.lines_deleted]] lines removed"
+                hidden$=[[file.binary]]>
+              -[[file.lines_deleted]]
+            </span>
+            <span class$="[[_computeBinaryClass(file.size_delta)]]"
+                hidden$=[[!file.binary]]>
+              [[_formatBytes(file.size_delta)]]
+              [[_formatPercentage(file.size, file.size_delta)]]
+            </span>
+          </div>
+          <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden>
+            <span class$="reviewedLabel [[_computeReviewedClass(file.isReviewed)]]">Reviewed</span>
+            <label>
+              <input class="reviewed" type="checkbox" checked="[[file.isReviewed]]">
+              <span class="markReviewed" title="Mark as reviewed (shortcut: r)">[[_computeReviewedText(file.isReviewed)]]</span>
+            </label>
+          </div>
         </div>
-        <div class$="[[_computeClass('stats', file.__path)]]">
-          <span
-              class="added"
-              tabindex="0"
-              aria-label$="[[file.lines_inserted]] lines added"
-              hidden$=[[file.binary]]>
-            +[[file.lines_inserted]]
-          </span>
-          <span
-              class="removed"
-              tabindex="0"
-              aria-label$="[[file.lines_deleted]] lines removed"
-              hidden$=[[file.binary]]>
-            -[[file.lines_deleted]]
-          </span>
-          <span class$="[[_computeBinaryClass(file.size_delta)]]"
-              hidden$=[[!file.binary]]>
-            [[_formatBytes(file.size_delta)]]
-            [[_formatPercentage(file.size, file.size_delta)]]
-          </span>
-        </div>
-        <div class="show-hide" hidden$="[[_userPrefs.expand_inline_diffs]]">
-          <label class="show-hide">
-            <input type="checkbox" class="show-hide"
-                checked$="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-                data-path$="[[file.__path]]"
-                on-change="_handleHiddenChange">
-            [[_computeShowHideText(file.__path, _expandedFilePaths.*)]]
-          </label>
-        </div>
-      </div>
-      <gr-diff
-          no-auto-render
-          hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
-          project="[[change.project]]"
-          commit="[[change.current_revision]]"
-          change-num="[[changeNum]]"
-          patch-range="[[patchRange]]"
-          path="[[file.__path]]"
-          prefs="[[_diffPrefs]]"
-          project-config="[[projectConfig]]"
-          view-mode="[[_getDiffViewMode(diffViewMode, _userPrefs)]]"></gr-diff>
-    </template>
-    <div class$="row totalChanges [[_computeExpandInlineClass(_userPrefs)]]">
-      <div class="total-stats" hidden$="[[_hideChangeTotals]]">
+        <template is="dom-if"
+            if="[[_isFileExpanded(file.__path, _expandedFilePaths.*)]]">
+          <gr-diff
+              no-auto-render
+              display-line="[[_displayLine]]"
+              inline-index=[[index]]
+              hidden="[[!_isFileExpanded(file.__path, _expandedFilePaths.*)]]"
+              change-num="[[changeNum]]"
+              patch-range="[[patchRange]]"
+              path="[[file.__path]]"
+              prefs="[[diffPrefs]]"
+              project-name="[[change.project]]"
+              project-config="[[projectConfig]]"
+              on-line-selected="_onLineSelected"
+              no-render-on-prefs-change
+              view-mode="[[diffViewMode]]"></gr-diff>
+        </template>
+      </template>
+    </div>
+    <div
+        class="row totalChanges"
+        hidden$="[[_hideChangeTotals]]">
+      <div class="total-stats">
         <span
             class="added"
             tabindex="0"
@@ -337,9 +333,13 @@
           -[[_patchChange.deleted]]
         </span>
       </div>
+      <!-- Empty div here exists to keep spacing in sync with file rows. -->
+      <div class="reviewed hideOnEdit" hidden$="[[!_loggedIn]]" hidden></div>
     </div>
-    <div class$="row totalChanges [[_computeExpandInlineClass(_userPrefs)]]">
-      <div class="total-stats" hidden$="[[_hideBinaryChangeTotals]]">
+    <div
+        class="row totalChanges"
+        hidden$="[[_hideBinaryChangeTotals]]">
+      <div class="total-stats">
         <span class="added" aria-label="Total lines added">
           [[_formatBytes(_patchChange.size_delta_inserted)]]
           [[_formatPercentage(_patchChange.total_size,
@@ -355,25 +355,36 @@
     <gr-button
         class="fileListButton"
         id="incrementButton"
-        hidden$="[[_computeFileListButtonHidden(_numFilesShown, _files)]]"
+        hidden$="[[_computeFileListButtonHidden(numFilesShown, _files)]]"
         link on-tap="_incrementNumFilesShown">
-      [[_computeIncrementText(_numFilesShown, _files)]]
+      [[_computeIncrementText(numFilesShown, _files)]]
     </gr-button>
-    <gr-button
-        class="fileListButton"
-        id="showAllButton"
-        hidden$="[[_computeFileListButtonHidden(_numFilesShown, _files)]]"
-        link on-tap="_showAllFiles">
-      [[_computeShowAllText(_files)]]
-    </gr-button>
+    <gr-tooltip-content
+        has-tooltip="[[_computeWarnShowAll(_files)]]"
+        show-icon="[[_computeWarnShowAll(_files)]]"
+        title$="[[_computeShowAllWarning(_files)]]">
+      <gr-button
+          class="fileListButton"
+          id="showAllButton"
+          hidden$="[[_computeFileListButtonHidden(numFilesShown, _files)]]"
+          link on-tap="_showAllFiles">
+        [[_computeShowAllText(_files)]]
+      </gr-button><!--
+ --></gr-tooltip-content>
+    <gr-diff-preferences
+        id="diffPreferences"
+        prefs="{{diffPrefs}}"
+        local-prefs="{{_localPrefs}}"></gr-diff-preferences>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor id="diffCursor"></gr-diff-cursor>
     <gr-cursor-manager
         id="fileCursor"
         scroll-behavior="keep-visible"
+        focus-on-move
         cursor-target-class="selected"></gr-cursor-manager>
     <gr-reporting id="reporting"></gr-reporting>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
   <script src="gr-file-list.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index e91c28dd..bdb437e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -14,13 +14,14 @@
 (function() {
   'use strict';
 
+  const ERR_EDIT_LOADED = 'You cannot change the review status of an edit.';
+
   // Maximum length for patch set descriptions.
-  var PATCH_DESC_MAX_LENGTH = 500;
+  const PATCH_DESC_MAX_LENGTH = 500;
+  const WARN_SHOW_ALL_THRESHOLD = 1000;
+  const LOADING_DEBOUNCE_INTERVAL = 100;
 
-  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
-  var MERGE_LIST_PATH = '/MERGE_LIST';
-
-  var FileStatus = {
+  const FileStatus = {
     A: 'Added',
     C: 'Copied',
     D: 'Deleted',
@@ -32,15 +33,14 @@
     is: 'gr-file-list',
 
     properties: {
-      patchRange: {
-        type: Object,
-        observer: '_updateSelected',
-      },
+      /** @type {?} */
+      patchRange: Object,
       patchNum: String,
       changeNum: String,
       comments: Object,
       drafts: Object,
-      revisions: Object,
+      // Already sorted by the change-view.
+      revisions: Array,
       projectConfig: Object,
       selectedIndex: {
         type: Number,
@@ -48,17 +48,23 @@
       },
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
+      /** @type {?} */
       change: Object,
       diffViewMode: {
         type: String,
         notify: true,
+        observer: '_updateDiffPreferences',
+      },
+      editLoaded: {
+        type: Boolean,
+        observer: '_editLoadedChanged',
       },
       _files: {
         type: Array,
         observer: '_filesChanged',
-        value: function() { return []; },
+        value() { return []; },
       },
       _loggedIn: {
         type: Boolean,
@@ -66,26 +72,27 @@
       },
       _reviewed: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
-      _diffAgainst: String,
-      _diffPrefs: Object,
+      diffPrefs: {
+        type: Object,
+        notify: true,
+        observer: '_updateDiffPreferences',
+      },
+      /** @type {?} */
       _userPrefs: Object,
       _localPrefs: Object,
       _showInlineDiffs: Boolean,
-      _numFilesShown: {
+      numFilesShown: {
         type: Number,
-        value: 75,
+        notify: true,
       },
+      /** @type {?} */
       _patchChange: {
         type: Object,
         computed: '_calculatePatchChange(_files)',
       },
-      _fileListIncrement: {
-        type: Number,
-        readOnly: true,
-        value: 75,
-      },
+      fileListIncrement: Number,
       _hideChangeTotals: {
         type: Boolean,
         computed: '_shouldHideChangeTotals(_patchChange)',
@@ -96,26 +103,25 @@
       },
       _shownFiles: {
         type: Array,
-        computed: '_computeFilesShown(_numFilesShown, _files.*)',
-      },
-      // Caps the number of files that can be shown and have the 'show diffs' /
-      // 'hide diffs' buttons still be functional.
-      _maxFilesForBulkActions: {
-        type: Number,
-        readOnly: true,
-        value: 225,
+        computed: '_computeFilesShown(numFilesShown, _files.*)',
       },
       _expandedFilePaths: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
+      _displayLine: Boolean,
+      _loading: {
+        type: Boolean,
+        observer: '_loadingChanged',
+      },
+      _sortedRevisions: Array,
     },
 
     behaviors: [
-      Gerrit.BaseUrlBehavior,
+      Gerrit.AsyncForeachBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.PatchSetBehavior,
-      Gerrit.URLEncodingBehavior,
+      Gerrit.PathListBehavior,
     ],
 
     observers: [
@@ -133,62 +139,92 @@
       'c': '_handleCKey',
       '[': '_handleLeftBracketKey',
       ']': '_handleRightBracketKey',
-      'o enter': '_handleEnterKey',
+      'o': '_handleOKey',
       'n': '_handleNKey',
       'p': '_handlePKey',
+      'r': '_handleRKey',
       'shift+a': '_handleCapitalAKey',
+      'esc': '_handleEscKey',
     },
 
-    reload: function() {
+    listeners: {
+      keydown: '_scopedKeydownHandler',
+    },
+
+    /**
+     * Iron-a11y-keys-behavior catches keyboard events globally. Some keyboard
+     * events must be scoped to a component level (e.g. `enter`) in order to not
+     * override native browser functionality.
+     *
+     * Context: Issue 7277
+     */
+    _scopedKeydownHandler(e) {
+      if (e.keyCode === 13) {
+        // Enter.
+        this._handleOKey(e);
+      }
+    },
+
+    reload() {
       if (!this.changeNum || !this.patchRange.patchNum) {
         return Promise.resolve();
       }
-      this._collapseAllDiffs();
-      var promises = [];
-      var _this = this;
 
-      promises.push(this._getFiles().then(function(files) {
-        _this._files = files;
+      this._loading = true;
+
+      this.collapseAllDiffs();
+      const promises = [];
+
+      promises.push(this._getFiles().then(files => {
+        this._files = files;
       }));
-      promises.push(this._getLoggedIn().then(function(loggedIn) {
-        return _this._loggedIn = loggedIn;
-      }).then(function(loggedIn) {
+      promises.push(this._getLoggedIn().then(loggedIn => {
+        return this._loggedIn = loggedIn;
+      }).then(loggedIn => {
         if (!loggedIn) { return; }
 
-        return _this._getReviewedFiles().then(function(reviewed) {
-          _this._reviewed = reviewed;
+        return this._getReviewedFiles().then(reviewed => {
+          this._reviewed = reviewed;
         });
       }));
 
-      this._localPrefs = this.$.storage.getPreferences();
-      promises.push(this._getDiffPreferences().then(function(prefs) {
-        this._diffPrefs = prefs;
-      }.bind(this)));
+      // Load all comments for the change.
+      promises.push(this.$.commentAPI.loadAll(this.changeNum));
 
-      promises.push(this._getPreferences().then(function(prefs) {
+      this._localPrefs = this.$.storage.getPreferences();
+      promises.push(this._getDiffPreferences().then(prefs => {
+        this.diffPrefs = prefs;
+      }));
+
+      promises.push(this._getPreferences().then(prefs => {
         this._userPrefs = prefs;
-        if (!this.diffViewMode) {
-          this.set('diffViewMode', prefs.default_diff_view);
-        }
-      }.bind(this)));
+      }));
+
+      return Promise.all(promises).then(() => {
+        this._loading = false;
+      });
     },
 
     get diffs() {
       return Polymer.dom(this.root).querySelectorAll('gr-diff');
     },
 
-    _calculatePatchChange: function(files) {
-      var filesNoCommitMsg = files.filter(function(files) {
+    openDiffPrefs() {
+      this.$.diffPreferences.open();
+    },
+
+    _calculatePatchChange(files) {
+      const filesNoCommitMsg = files.filter(files => {
         return files.__path !== '/COMMIT_MSG';
       });
 
-      return filesNoCommitMsg.reduce(function(acc, obj) {
-        var inserted = obj.lines_inserted ? obj.lines_inserted : 0;
-        var deleted = obj.lines_deleted ? obj.lines_deleted : 0;
-        var total_size = (obj.size && obj.binary) ? obj.size : 0;
-        var size_delta_inserted =
+      return filesNoCommitMsg.reduce((acc, obj) => {
+        const inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+        const deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+        const total_size = (obj.size && obj.binary) ? obj.size : 0;
+        const size_delta_inserted =
             obj.binary && obj.size_delta > 0 ? obj.size_delta : 0;
-        var size_delta_deleted =
+        const size_delta_deleted =
             obj.binary && obj.size_delta < 0 ? obj.size_delta : 0;
 
         return {
@@ -202,40 +238,18 @@
         size_delta_deleted: 0, total_size: 0});
     },
 
-    _getDiffPreferences: function() {
+    _getDiffPreferences() {
       return this.$.restAPI.getDiffPreferences();
     },
 
-    _getPreferences: function() {
+    _getPreferences() {
       return this.$.restAPI.getPreferences();
     },
 
-    _computePatchSets: function(revisionRecord) {
-      var revisions = revisionRecord.base;
-      var patchNums = [];
-      for (var commit in revisions) {
-        if (revisions.hasOwnProperty(commit)) {
-          patchNums.push({
-            num: revisions[commit]._number,
-            desc: revisions[commit].description,
-          });
-        }
-      }
-      return patchNums.sort(function(a, b) { return a.num - b.num; });
-    },
-
-    _computePatchSetDisabled: function(patchNum, currentPatchNum) {
-      return parseInt(patchNum, 10) >= parseInt(currentPatchNum, 10);
-    },
-
-    _handleHiddenChange: function(e) {
-      this._togglePathExpanded(e.model.file.__path);
-    },
-
-    _togglePathExpanded: function(path) {
+    _togglePathExpanded(path) {
       // Is the path in the list of expanded diffs? IF so remove it, otherwise
       // add it to the list.
-      var pathIndex = this._expandedFilePaths.indexOf(path);
+      const pathIndex = this._expandedFilePaths.indexOf(path);
       if (pathIndex === -1) {
         this.push('_expandedFilePaths', path);
       } else {
@@ -243,79 +257,91 @@
       }
     },
 
-    _togglePathExpandedByIndex: function(index) {
+    _togglePathExpandedByIndex(index) {
       this._togglePathExpanded(this._files[index].__path);
     },
 
-    _handlePatchChange: function(e) {
-      var patchRange = Object.assign({}, this.patchRange);
-      patchRange.basePatchNum = Polymer.dom(e).rootTarget.value;
-      page.show(this.encodeURL('/c/' + this.changeNum + '/' +
-          this._patchRangeStr(patchRange), true));
+    _updateDiffPreferences() {
+      if (!this.diffs.length) { return; }
+      // Re-render all expanded diffs sequentially.
+      const timerName = 'Update ' + this._expandedFilePaths.length +
+          ' diffs with new prefs';
+      this._renderInOrder(this._expandedFilePaths, this.diffs,
+          this._expandedFilePaths.length)
+          .then(() => {
+            this.$.reporting.timeEnd(timerName);
+            this.$.diffCursor.handleDiffUpdate();
+          });
     },
 
-    _forEachDiff: function(fn) {
-      var diffs = this.diffs;
-      for (var i = 0; i < diffs.length; i++) {
+    _forEachDiff(fn) {
+      const diffs = this.diffs;
+      for (let i = 0; i < diffs.length; i++) {
         fn(diffs[i]);
       }
     },
 
-    _expandAllDiffs: function(e) {
+    expandAllDiffs() {
       this._showInlineDiffs = true;
 
       // Find the list of paths that are in the file list, but not in the
       // expanded list.
-      var newPaths = [];
-      var path;
-      for (var i = 0; i < this._shownFiles.length; i++) {
+      const newPaths = [];
+      let path;
+      for (let i = 0; i < this._shownFiles.length; i++) {
         path = this._shownFiles[i].__path;
-        if (this._expandedFilePaths.indexOf(path) === -1) {
+        if (!this._expandedFilePaths.includes(path)) {
           newPaths.push(path);
         }
       }
 
-      this.splice.apply(this, ['_expandedFilePaths', 0, 0].concat(newPaths));
+      this.splice(...['_expandedFilePaths', 0, 0].concat(newPaths));
     },
 
-    _collapseAllDiffs: function(e) {
+    collapseAllDiffs() {
       this._showInlineDiffs = false;
       this._expandedFilePaths = [];
       this.$.diffCursor.handleDiffUpdate();
     },
 
-    _computeCommentsString: function(comments, patchNum, path) {
+    _computeCommentsString(comments, patchNum, path) {
       return this._computeCountString(comments, patchNum, path, 'comment');
     },
 
-    _computeDraftsString: function(drafts, patchNum, path) {
+    _computeDraftsString(drafts, patchNum, path) {
       return this._computeCountString(drafts, patchNum, path, 'draft');
     },
 
-    _computeDraftsStringMobile: function(drafts, patchNum, path) {
-      var draftCount = this._computeCountString(drafts, patchNum, path);
+    _computeDraftsStringMobile(drafts, patchNum, path) {
+      const draftCount = this._computeCountString(drafts, patchNum, path);
       return draftCount ? draftCount + 'd' : '';
     },
 
-    _computeCommentsStringMobile: function(comments, patchNum, path) {
-      var commentCount = this._computeCountString(comments, patchNum, path);
+    _computeCommentsStringMobile(comments, patchNum, path) {
+      const commentCount = this._computeCountString(comments, patchNum, path);
       return commentCount ? commentCount + 'c' : '';
     },
 
-    _getCommentsForPath: function(comments, patchNum, path) {
-      return (comments[path] || []).filter(function(c) {
-        return parseInt(c.patch_set, 10) === parseInt(patchNum, 10);
+    getCommentsForPath(comments, patchNum, path) {
+      return (comments[path] || []).filter(c => {
+        return this.patchNumEquals(c.patch_set, patchNum);
       });
     },
 
-    _computeCountString: function(comments, patchNum, path, opt_noun) {
+    /**
+     * @param {!Array} comments
+     * @param {number} patchNum
+     * @param {string} path
+     * @param {string=} opt_noun
+     */
+    _computeCountString(comments, patchNum, path, opt_noun) {
       if (!comments) { return ''; }
 
-      var patchComments = this._getCommentsForPath(comments, patchNum, path);
-      var num = patchComments.length;
+      const patchComments = this.getCommentsForPath(comments, patchNum, path);
+      const num = patchComments.length;
       if (num === 0) { return ''; }
       if (!opt_noun) { return num; }
-      var output = num + ' ' + opt_noun + (num > 1 ? 's' : '');
+      const output = num + ' ' + opt_noun + (num > 1 ? 's' : '');
       return output;
     },
 
@@ -323,21 +349,27 @@
      * Computes a string counting the number of unresolved comment threads in a
      * given file and path.
      *
-     * @param {Object} comments
-     * @param {Object} drafts
+     * @param {!Object} comments
+     * @param {!Object} drafts
      * @param {number} patchNum
      * @param {string} path
      * @return {string}
      */
-    _computeUnresolvedString: function(comments, drafts, patchNum, path) {
-      comments = this._getCommentsForPath(comments, patchNum, path);
-      drafts = this._getCommentsForPath(drafts, patchNum, path);
+    _computeUnresolvedString(comments, drafts, patchNum, path) {
+      const unresolvedNum = this.computeUnresolvedNum(
+          comments, drafts, patchNum, path);
+      return unresolvedNum === 0 ? '' : '(' + unresolvedNum + ' unresolved)';
+    },
+
+    computeUnresolvedNum(comments, drafts, patchNum, path) {
+      comments = this.getCommentsForPath(comments, patchNum, path);
+      drafts = this.getCommentsForPath(drafts, patchNum, path);
       comments = comments.concat(drafts);
 
       // Create an object where every comment ID is the key of an unresolved
       // comment.
 
-      var idMap = comments.reduce(function(acc, comment) {
+      const idMap = comments.reduce((acc, comment) => {
         if (comment.unresolved) {
           acc[comment.id] = true;
         }
@@ -345,30 +377,29 @@
       }, {});
 
       // Set false for the comments that are marked as parents.
-      comments.forEach(function(comment) {
+      for (const comment of comments) {
         idMap[comment.in_reply_to] = false;
-      });
+      }
 
       // The unresolved comments are the comments that still have true.
-      var unresolvedLeaves = Object.keys(idMap).filter(function(key) {
+      const unresolvedLeaves = Object.keys(idMap).filter(key => {
         return idMap[key];
       });
 
-      return unresolvedLeaves.length === 0 ?
-          '' : '(' + unresolvedLeaves.length + ' unresolved)';
+      return unresolvedLeaves.length;
     },
 
-    _computeReviewed: function(file, _reviewed) {
-      return _reviewed.indexOf(file.__path) !== -1;
+    _computeReviewed(file, _reviewed) {
+      return _reviewed.includes(file.__path);
     },
 
-    _handleReviewedChange: function(e) {
-      this._reviewFile(Polymer.dom(e).rootTarget.getAttribute('data-path'));
-    },
-
-    _reviewFile: function(path) {
-      var index = this._reviewed.indexOf(path);
-      var reviewed = index !== -1;
+    _reviewFile(path) {
+      if (this.editLoaded) {
+        this.fire('show-alert', {message: ERR_EDIT_LOADED});
+        return;
+      }
+      const index = this._reviewed.indexOf(path);
+      const reviewed = index !== -1;
       if (reviewed) {
         this.splice('_reviewed', index, 1);
       } else {
@@ -378,43 +409,71 @@
       this._saveReviewedState(path, !reviewed);
     },
 
-    _saveReviewedState: function(path, reviewed) {
+    _saveReviewedState(path, reviewed) {
       return this.$.restAPI.saveFileReviewed(this.changeNum,
           this.patchRange.patchNum, path, reviewed);
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _getReviewedFiles: function() {
+    _getReviewedFiles() {
+      if (this.editLoaded) { return Promise.resolve([]); }
       return this.$.restAPI.getReviewedFiles(this.changeNum,
           this.patchRange.patchNum);
     },
 
-    _getFiles: function() {
+    _getFiles() {
+      if (this.editLoaded) {
+        return this.$.restAPI.getChangeEditFilesAsSpeciallySortedArray(
+            this.changeNum, this.patchRange);
+      }
       return this.$.restAPI.getChangeFilesAsSpeciallySortedArray(
-          this.changeNum, this.patchRange).then(function(files) {
-            // Append UI-specific properties.
-            return files.map(function(file) {
-              return file;
-            });
-          });
+          this.changeNum, this.patchRange);
     },
 
-    _handleFileTap: function(e) {
+    /**
+     * Handle all events from the file list dom-repeat so event handleers don't
+     * have to get registered for potentially very long lists.
+     */
+    _handleFileListTap(e) {
+      // Traverse upwards to find the row element if the target is not the row.
+      let row = e.target;
+      while (!row.classList.contains('row') && row.parentElement) {
+        row = row.parentElement;
+      }
+      const path = row.dataset.path;
+      // Handle checkbox mark as reviewed.
+      if (e.target.classList.contains('markReviewed')) {
+        e.preventDefault();
+        return this._reviewFile(path);
+      }
+
       // If the user prefers to expand inline diffs rather than opening the diff
       // view, intercept the click event.
-      if (e.detail.sourceEvent.metaKey || e.detail.sourceEvent.ctrlKey) {
-          return;
+      if (!path || e.detail.sourceEvent.metaKey ||
+          e.detail.sourceEvent.ctrlKey) {
+        return;
       }
-      if (this._userPrefs && this._userPrefs.expand_inline_diffs) {
+
+      if (e.target.dataset.expand ||
+          this._userPrefs && this._userPrefs.expand_inline_diffs) {
         e.preventDefault();
-        this._handleHiddenChange(e);
+        this._togglePathExpanded(path);
+        return;
+      }
+
+      // If we clicked the row but not the link, then simulate a click on the
+      // anchor.
+      if (e.target.classList.contains('path') ||
+          e.target.classList.contains('oldPath')) {
+        const a = row.querySelector('a');
+        if (a) { a.click(); }
       }
     },
 
-    _handleShiftLeftKey: function(e) {
+    _handleShiftLeftKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (!this._showInlineDiffs) { return; }
 
@@ -422,7 +481,7 @@
       this.$.diffCursor.moveLeft();
     },
 
-    _handleShiftRightKey: function(e) {
+    _handleShiftRightKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (!this._showInlineDiffs) { return; }
 
@@ -430,7 +489,7 @@
       this.$.diffCursor.moveRight();
     },
 
-    _handleIKey: function(e) {
+    _handleIKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e) ||
           this.$.fileCursor.index === -1) { return; }
@@ -439,41 +498,54 @@
       this._togglePathExpandedByIndex(this.$.fileCursor.index);
     },
 
-    _handleCapitalIKey: function(e) {
+    _handleCapitalIKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this._toggleInlineDiffs();
     },
 
-    _handleDownKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
-      e.preventDefault();
+    _handleDownKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+        return;
+      }
+
       if (this._showInlineDiffs) {
+        e.preventDefault();
         this.$.diffCursor.moveDown();
+        this._displayLine = true;
       } else {
+        // Down key
+        if (this.getKeyboardEvent(e).keyCode === 40) { return; }
+        e.preventDefault();
         this.$.fileCursor.next();
         this.selectedIndex = this.$.fileCursor.index;
       }
     },
 
-    _handleUpKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    _handleUpKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+        return;
+      }
 
-      e.preventDefault();
       if (this._showInlineDiffs) {
+        e.preventDefault();
         this.$.diffCursor.moveUp();
+        this._displayLine = true;
       } else {
+        // Up key
+        if (this.getKeyboardEvent(e).keyCode === 38) { return; }
+        e.preventDefault();
         this.$.fileCursor.previous();
         this.selectedIndex = this.$.fileCursor.index;
       }
     },
 
-    _handleCKey: function(e) {
+    _handleCKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
-      var isRangeSelected = this.diffs.some(function(diff) {
+      const isRangeSelected = this.diffs.some(diff => {
         return diff.isRangeSelected();
       }, this);
       if (this._showInlineDiffs && !isRangeSelected) {
@@ -482,193 +554,194 @@
       }
     },
 
-    _handleLeftBracketKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    _handleLeftBracketKey(e) {
+      // Check for meta key to avoid overriding native chrome shortcut.
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.getKeyboardEvent(e).metaKey) { return; }
 
       e.preventDefault();
       this._openSelectedFile(this._files.length - 1);
     },
 
-    _handleRightBracketKey: function(e) {
-      if (this.shouldSuppressKeyboardShortcut(e)) { return; }
+    _handleRightBracketKey(e) {
+      // Check for meta key to avoid overriding native chrome shortcut.
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.getKeyboardEvent(e).metaKey) { return; }
 
       e.preventDefault();
       this._openSelectedFile(0);
     },
 
-    _handleEnterKey: function(e) {
+    _handleOKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
-      // Use native handling if an anchor is selected. @see Issue 5754
-      if (e.detail && e.detail.keyboardEvent && e.detail.keyboardEvent.target &&
-          e.detail.keyboardEvent.target.tagName === 'A') { return; }
-
       e.preventDefault();
       if (this._showInlineDiffs) {
         this._openCursorFile();
+      } else if (this._userPrefs && this._userPrefs.expand_inline_diffs) {
+        if (this.$.fileCursor.index === -1) { return; }
+        this._togglePathExpandedByIndex(this.$.fileCursor.index);
       } else {
         this._openSelectedFile();
       }
     },
 
-    _handleNKey: function(e) {
+    _handleNKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+        return;
+      }
       if (!this._showInlineDiffs) { return; }
 
       e.preventDefault();
-      if (e.shiftKey) {
+      if (this.isModifierPressed(e, 'shiftKey')) {
         this.$.diffCursor.moveToNextCommentThread();
       } else {
         this.$.diffCursor.moveToNextChunk();
       }
     },
 
-    _handlePKey: function(e) {
+    _handlePKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          this.modifierPressed(e) && !this.isModifierPressed(e, 'shiftKey')) {
+        return;
+      }
       if (!this._showInlineDiffs) { return; }
 
       e.preventDefault();
-      if (e.shiftKey) {
+      if (this.isModifierPressed(e, 'shiftKey')) {
         this.$.diffCursor.moveToPreviousCommentThread();
       } else {
         this.$.diffCursor.moveToPreviousChunk();
       }
     },
 
-    _handleCapitalAKey: function(e) {
+    _handleRKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) || this.modifierPressed(e)) {
+        return;
+      }
+
+      e.preventDefault();
+      if (!this._files[this.$.fileCursor.index]) { return; }
+      this._reviewFile(this._files[this.$.fileCursor.index].__path);
+    },
+
+    _handleCapitalAKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
-      this._forEachDiff(function(diff) {
+      this._forEachDiff(diff => {
         diff.toggleLeftDiff();
       });
     },
 
-    _toggleInlineDiffs: function() {
+    _toggleInlineDiffs() {
       if (this._showInlineDiffs) {
-        this._collapseAllDiffs();
+        this.collapseAllDiffs();
       } else {
-        this._expandAllDiffs();
+        this.expandAllDiffs();
       }
     },
 
-    _openCursorFile: function() {
-      var diff = this.$.diffCursor.getTargetDiffElement();
-      page.show(this._computeDiffURL(diff.changeNum, diff.patchRange,
-          diff.path));
+    _openCursorFile() {
+      const diff = this.$.diffCursor.getTargetDiffElement();
+      Gerrit.Nav.navigateToDiff(this.change, diff.path,
+          diff.patchRange.patchNum, this.patchRange.basePatchNum);
     },
 
-    _openSelectedFile: function(opt_index) {
+    /**
+     * @param {number=} opt_index
+     */
+    _openSelectedFile(opt_index) {
       if (opt_index != null) {
         this.$.fileCursor.setCursorAtIndex(opt_index);
       }
-      page.show(this._computeDiffURL(this.changeNum, this.patchRange,
-          this._files[this.$.fileCursor.index].__path));
+      if (!this._files[this.$.fileCursor.index]) { return; }
+      Gerrit.Nav.navigateToDiff(this.change,
+          this._files[this.$.fileCursor.index].__path, this.patchRange.patchNum,
+          this.patchRange.basePatchNum);
     },
 
-    _addDraftAtTarget: function() {
-      var diff = this.$.diffCursor.getTargetDiffElement();
-      var target = this.$.diffCursor.getTargetLineElement();
+    _addDraftAtTarget() {
+      const diff = this.$.diffCursor.getTargetDiffElement();
+      const target = this.$.diffCursor.getTargetLineElement();
       if (diff && target) {
         diff.addDraftAtLine(target);
       }
     },
 
-    _shouldHideChangeTotals: function(_patchChange) {
+    _shouldHideChangeTotals(_patchChange) {
       return _patchChange.inserted === 0 && _patchChange.deleted === 0;
     },
 
-    _shouldHideBinaryChangeTotals: function(_patchChange) {
+    _shouldHideBinaryChangeTotals(_patchChange) {
       return _patchChange.size_delta_inserted === 0 &&
           _patchChange.size_delta_deleted === 0;
     },
 
-    _computeFileStatus: function(status) {
+    _computeFileStatus(status) {
       return status || 'M';
     },
 
-    _computeDiffURL: function(changeNum, patchRange, path) {
-      return this.encodeURL(this.getBaseUrl() + '/c/' + changeNum + '/' +
-          this._patchRangeStr(patchRange) + '/' + path, true);
+    _computeDiffURL(change, patchNum, basePatchNum, path) {
+      return Gerrit.Nav.getUrlForDiff(change, path, patchNum, basePatchNum);
     },
 
-    _patchRangeStr: function(patchRange) {
-      return patchRange.basePatchNum !== 'PARENT' ?
-          patchRange.basePatchNum + '..' + patchRange.patchNum :
-          patchRange.patchNum + '';
-    },
-
-    _computeFileDisplayName: function(path) {
-      if (path === COMMIT_MESSAGE_PATH) {
-        return 'Commit message';
-      } else if (path === MERGE_LIST_PATH) {
-        return 'Merge list';
-      }
-      return path;
-    },
-
-    _computeTruncatedFileDisplayName: function(path) {
-      return util.truncatePath(this._computeFileDisplayName(path));
-    },
-
-    _formatBytes: function(bytes) {
+    _formatBytes(bytes) {
       if (bytes == 0) return '+/-0 B';
-      var bits = 1024;
-      var decimals = 1;
-      var sizes = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
-      var exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
-      var prepend = bytes > 0 ? '+' : '';
+      const bits = 1024;
+      const decimals = 1;
+      const sizes =
+          ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'];
+      const exponent = Math.floor(Math.log(Math.abs(bytes)) / Math.log(bits));
+      const prepend = bytes > 0 ? '+' : '';
       return prepend + parseFloat((bytes / Math.pow(bits, exponent))
           .toFixed(decimals)) + ' ' + sizes[exponent];
     },
 
-    _formatPercentage: function(size, delta) {
-      var oldSize = size - delta;
+    _formatPercentage(size, delta) {
+      const oldSize = size - delta;
 
       if (oldSize === 0) { return ''; }
 
-      var percentage = Math.round(Math.abs(delta * 100 / oldSize));
+      const percentage = Math.round(Math.abs(delta * 100 / oldSize));
       return '(' + (delta > 0 ? '+' : '-') + percentage + '%)';
     },
 
-    _computeBinaryClass: function(delta) {
+    _computeBinaryClass(delta) {
       if (delta === 0) { return; }
       return delta >= 0 ? 'added' : 'removed';
     },
 
-    _computeClass: function(baseClass, path) {
-      var classes = [baseClass];
-      if (path === COMMIT_MESSAGE_PATH || path === MERGE_LIST_PATH) {
+    _computeClass(baseClass, path) {
+      const classes = [baseClass];
+      if (path === this.COMMIT_MESSAGE_PATH || path === this.MERGE_LIST_PATH) {
         classes.push('invisible');
       }
       return classes.join(' ');
     },
 
-    _computeExpandInlineClass: function(userPrefs) {
-      return userPrefs.expand_inline_diffs ? 'expandInline' : '';
-    },
-
-    _computePathClass: function(path, expandedFilesRecord) {
+    _computePathClass(path, expandedFilesRecord) {
       return this._isFileExpanded(path, expandedFilesRecord) ? 'path expanded' :
           'path';
     },
 
-    _computeShowHideText: function(path, expandedFilesRecord) {
-      return this._isFileExpanded(path, expandedFilesRecord) ? '▼' : '◀';
+    _computeShowHideText(path, expandedFilesRecord) {
+      return this._isFileExpanded(path, expandedFilesRecord) ? '▼' : '▶';
     },
 
-    _computeFilesShown: function(numFilesShown, files) {
-      return files.base.slice(0, numFilesShown);
+    _computeFilesShown(numFilesShown, files) {
+      const filesShown = files.base.slice(0, numFilesShown);
+      this.fire('files-shown-changed', {length: filesShown.length});
+      return filesShown;
     },
 
-    _setReviewedFiles: function(shownFiles, files, reviewedRecord, loggedIn) {
+    _setReviewedFiles(shownFiles, files, reviewedRecord, loggedIn) {
       if (!loggedIn) { return; }
-      var reviewed = reviewedRecord.base;
-      var fileReviewed;
-      for (var i = 0; i < files.length; i++) {
+      const reviewed = reviewedRecord.base;
+      let fileReviewed;
+      for (let i = 0; i < files.length; i++) {
         fileReviewed = this._computeReviewed(files[i], reviewed);
         this._files[i].isReviewed = fileReviewed;
         if (i < shownFiles.length) {
@@ -677,87 +750,74 @@
       }
     },
 
-    _filesChanged: function() {
-      this.async(function() {
-        var diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
+    _updateDiffCursor() {
+      const diffElements = Polymer.dom(this.root).querySelectorAll('gr-diff');
 
-        // Overwrite the cursor's list of diffs:
-        this.$.diffCursor.splice.apply(this.$.diffCursor,
-            ['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
-
-        var files = Polymer.dom(this.root).querySelectorAll('.file-row');
-        this.$.fileCursor.stops = files;
-        this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
-      }.bind(this), 1);
+      // Overwrite the cursor's list of diffs:
+      this.$.diffCursor.splice(
+          ...['diffs', 0, this.$.diffCursor.diffs.length].concat(diffElements));
     },
 
-    _incrementNumFilesShown: function() {
-      this._numFilesShown += this._fileListIncrement;
+    _filesChanged() {
+      Polymer.dom.flush();
+      const files = Polymer.dom(this.root).querySelectorAll('.file-row');
+      this.$.fileCursor.stops = files;
+      this.$.fileCursor.setCursorAtIndex(this.selectedIndex, true);
     },
 
-    _computeFileListButtonHidden: function(numFilesShown, files) {
+    _incrementNumFilesShown() {
+      this.numFilesShown += this.fileListIncrement;
+    },
+
+    _computeFileListButtonHidden(numFilesShown, files) {
       return numFilesShown >= files.length;
     },
 
-    _computeIncrementText: function(numFilesShown, files) {
+    _computeIncrementText(numFilesShown, files) {
       if (!files) { return ''; }
-      var text =
-          Math.min(this._fileListIncrement, files.length - numFilesShown);
+      const text =
+          Math.min(this.fileListIncrement, files.length - numFilesShown);
       return 'Show ' + text + ' more';
     },
 
-    _computeShowAllText: function(files) {
+    _computeShowAllText(files) {
       if (!files) { return ''; }
       return 'Show all ' + files.length + ' files';
     },
 
-    _showAllFiles: function() {
-      this._numFilesShown = this._files.length;
+    _computeWarnShowAll(files) {
+      return files.length > WARN_SHOW_ALL_THRESHOLD;
     },
 
-    _updateSelected: function(patchRange) {
-      this._diffAgainst = patchRange.basePatchNum;
+    _computeShowAllWarning(files) {
+      if (!this._computeWarnShowAll(files)) { return ''; }
+      return 'Warning: showing all ' + files.length +
+          ' files may take several seconds.';
     },
 
-    /**
-     * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
-     * the current state.
-     *
-     * The expected behavior is to use the mode specified in the user's
-     * preferences unless they have manually chosen the alternative view.
-     *
-     * Use side-by-side if there is no view mode or preferences.
-     *
-     * @return {String}
-     */
-    _getDiffViewMode: function(diffViewMode, userPrefs) {
-      if (diffViewMode) {
-        return diffViewMode;
-      } else if (userPrefs) {
-        return this.diffViewMode = userPrefs.default_diff_view;
-      }
-      return 'SIDE_BY_SIDE';
+    _showAllFiles() {
+      this.numFilesShown = this._files.length;
     },
 
-    _fileListActionsVisible: function(shownFilesRecord,
-        maxFilesForBulkActions) {
-      return shownFilesRecord.base.length <= maxFilesForBulkActions;
-    },
-
-    _computePatchSetDescription: function(revisions, patchNum) {
-      var rev = this.getRevisionByPatchNum(revisions, patchNum);
+    _computePatchSetDescription(revisions, patchNum) {
+      const rev = this.getRevisionByPatchNum(revisions, patchNum);
       return (rev && rev.description) ?
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
 
-    _computeFileStatusLabel: function(status) {
-      var statusCode = this._computeFileStatus(status);
+    _computeFileStatusLabel(status) {
+      const statusCode = this._computeFileStatus(status);
       return FileStatus.hasOwnProperty(statusCode) ?
           FileStatus[statusCode] : 'Status Unknown';
     },
 
-    _isFileExpanded: function(path, expandedFilesRecord) {
-      return expandedFilesRecord.base.indexOf(path) !== -1;
+    _isFileExpanded(path, expandedFilesRecord) {
+      return expandedFilesRecord.base.includes(path);
+    },
+
+    _onLineSelected(e, detail) {
+      this.$.diffCursor.moveToLineNumber(detail.number, detail.side,
+          detail.path);
     },
 
     /**
@@ -765,68 +825,113 @@
      * entries in the expanded list, then render each diff corresponding in
      * order by waiting for the previous diff to finish before starting the next
      * one.
-     * @param  {splice} record The splice record in the expanded paths list.
+     * @param {!Array} record The splice record in the expanded paths list.
      */
-    _expandedPathsChanged: function(record) {
+    _expandedPathsChanged(record) {
       if (!record) { return; }
 
       // Find the paths introduced by the new index splices:
-      var newPaths = record.indexSplices
-          .map(function(splice) {
+      const newPaths = record.indexSplices
+          .map(splice => {
             return splice.object.slice(splice.index,
                 splice.index + splice.addedCount);
           })
-          .reduce(function(acc, paths) { return acc.concat(paths); }, []);
+          .reduce((acc, paths) => { return acc.concat(paths); }, []);
 
-      var timerName = 'Expand ' + newPaths.length + ' diffs';
+      const timerName = 'Expand ' + newPaths.length + ' diffs';
       this.$.reporting.time(timerName);
 
+      // Required so that the newly created diff view is included in this.diffs.
+      Polymer.dom.flush();
+
       this._renderInOrder(newPaths, this.diffs, newPaths.length)
-          .then(function() {
+          .then(() => {
             this.$.reporting.timeEnd(timerName);
             this.$.diffCursor.handleDiffUpdate();
-          }.bind(this));
+          });
+      this._updateDiffCursor();
+      this.$.diffCursor.handleDiffUpdate();
     },
 
     /**
      * Given an array of paths and a NodeList of diff elements, render the diff
      * for each path in order, awaiting the previous render to complete before
      * continung.
-     * @param  {!Array<!String>} paths
-     * @param  {!NodeList<!GrDiffElement>} diffElements
-     * @param  {Number} initialCount The total number of paths in the pass. This
+     * @param  {!Array<string>} paths
+     * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
+     * @param  {number} initialCount The total number of paths in the pass. This
      *   is used to generate log messages.
      * @return {!Promise}
      */
-    _renderInOrder: function(paths, diffElements, initialCount) {
-      if (!paths.length) {
-        console.log('Finished expanding', initialCount, 'diff(s)');
-        return Promise.resolve();
-      }
-      console.log('Expanding diff', 1 + initialCount - paths.length, 'of',
-          initialCount, ':', paths[0]);
-      var diffElem = this._findDiffByPath(paths[0], diffElements);
-      var promises = [diffElem.reload()];
-      if (this._isLoggedIn) {
-        promises.push(this._reviewFile(paths[0]));
-      }
-      return Promise.all(promises).then(function() {
-        return this._renderInOrder(paths.slice(1), diffElements, initialCount);
-      }.bind(this));
+    _renderInOrder(paths, diffElements, initialCount) {
+      let iter = 0;
+
+      return this.$.commentAPI.loadAll(this.changeNum)
+          .then(() => {
+            return this.asyncForeach(paths, path => {
+              iter++;
+              console.log('Expanding diff', iter, 'of', initialCount, ':',
+                  path);
+              const diffElem = this._findDiffByPath(path, diffElements);
+              diffElem.comments = this.$.commentAPI.getCommentsForPath(path,
+                  this.patchRange, this.projectConfig);
+              const promises = [diffElem.reload()];
+              if (this._isLoggedIn) {
+                promises.push(this._reviewFile(path));
+              }
+              return Promise.all(promises);
+            });
+          })
+          .then(() => {
+            console.log('Finished expanding', initialCount, 'diff(s)');
+          });
     },
 
     /**
      * In the given NodeList of diff elements, find the diff for the given path.
-     * @param  {!String} path
-     * @param  {!NodeList<!GrDiffElement>} diffElements
-     * @return {!GrDiffElement}
+     * @param  {string} path
+     * @param  {!NodeList<!Object>} diffElements (GrDiffElement)
+     * @return {!Object|undefined} (GrDiffElement)
      */
-    _findDiffByPath: function(path, diffElements) {
-      for (var i = 0; i < diffElements.length; i++) {
+    _findDiffByPath(path, diffElements) {
+      for (let i = 0; i < diffElements.length; i++) {
         if (diffElements[i].path === path) {
           return diffElements[i];
         }
       }
     },
+
+    _handleEscKey(e) {
+      if (this.shouldSuppressKeyboardShortcut(e) ||
+          this.modifierPressed(e)) { return; }
+      e.preventDefault();
+      this._displayLine = false;
+    },
+
+    /**
+     * Update the loading class for the file list rows. The update is inside a
+     * debouncer so that the file list doesn't flash gray when the API requests
+     * are reasonably fast.
+     * @param {boolean} loading
+     */
+    _loadingChanged(loading) {
+      this.debounce('loading-change', () => {
+        // Only show set the loading if there have been files loaded to show. In
+        // this way, the gray loading style is not shown on initial loads.
+        this.classList.toggle('loading', loading && !!this._files.length);
+      }, LOADING_DEBOUNCE_INTERVAL);
+    },
+
+    _editLoadedChanged(editLoaded) {
+      this.classList.toggle('editLoaded', editLoaded);
+    },
+
+    _computeReviewedClass(isReviewed) {
+      return isReviewed ? 'isReviewed' : '';
+    },
+
+    _computeReviewedText(isReviewed) {
+      return isReviewed ? 'MARK UNREVIEWED' : 'MARK REVIEWED';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index bd4619f..a5fd521 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -20,10 +20,11 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-file-list.html">
 
 <script>void(0);</script>
@@ -34,43 +35,56 @@
   </template>
 </test-fixture>
 
-<test-fixture id="blank">
-  <template>
-    <div></div>
-  </template>
-</test-fixture>
-
 <script>
-  suite('gr-file-list tests', function() {
-    var element;
-    var sandbox;
-    var saveStub;
+  suite('gr-file-list tests', () => {
+    let element;
+    let sandbox;
+    let saveStub;
+    let loadCommentStub;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(true); },
-        getPreferences: function() { return Promise.resolve({}); },
-        fetchJSON: function() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+        fetchJSON() { return Promise.resolve({}); },
       });
       stub('gr-date-formatter', {
-        _loadTimeFormat: function() { return Promise.resolve(''); },
+        _loadTimeFormat() { return Promise.resolve(''); },
       });
       stub('gr-diff', {
-        reload: function() { return Promise.resolve(); },
+        reload() { return Promise.resolve(); },
       });
+      stub('gr-comment-api', {
+        getPaths() { return {}; },
+        getCommentsForPath() { return {meta: {}, left: [], right: []}; },
+      });
+
       element = fixture('basic');
+      element.numFilesShown = 200;
       saveStub = sandbox.stub(element, '_saveReviewedState',
-          function() { return Promise.resolve(); });
+          () => { return Promise.resolve(); });
+      loadCommentStub = sandbox.stub(element.$.commentAPI, 'loadAll',
+          () => { return Promise.resolve(); });
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('get file list', function(done) {
-      var getChangeFilesStub = sandbox.stub(element.$.restAPI, 'getChangeFiles',
-          function() {
+    test('correct number of files are shown', () => {
+      element._files = _.times(500, i => {
+        return {__path: '/file' + i, lines_inserted: 9};
+      });
+      flushAsynchronousOperations();
+      assert.equal(
+          Polymer.dom(element.root).querySelectorAll('.file-row').length,
+          element.numFilesShown);
+    });
+
+    test('get file list', done => {
+      const getChangeFilesStub = sandbox.stub(element.$.restAPI, 'getChangeFiles',
+          () => {
             return Promise.resolve({
               '/COMMIT_MSG': {lines_inserted: 9},
               'tags.html': {lines_deleted: 123},
@@ -78,8 +92,8 @@
             });
           });
 
-      element._getFiles().then(function(files) {
-        var filenames = files.map(function(f) { return f.__path; });
+      element._getFiles().then(files => {
+        const filenames = files.map(f => { return f.__path; });
         assert.deepEqual(filenames, ['/COMMIT_MSG', 'about.txt', 'tags.html']);
         assert.deepEqual(files[0], {
           lines_inserted: 9,
@@ -102,7 +116,49 @@
       });
     });
 
-    test('calculate totals for patch number', function() {
+    test('get file list with change edit', done => {
+      element.editLoaded = true;
+
+      sandbox.stub(element.$.restAPI,
+          'getChangeEditFiles', () => {
+            return Promise.resolve({
+              commit: {},
+              files: {
+                '/COMMIT_MSG': {
+                  lines_inserted: 9,
+                },
+                'tags.html': {
+                  lines_deleted: 123,
+                },
+                'about.txt': {},
+              },
+            });
+          });
+
+      element._getFiles().then(files => {
+        const filenames = files.map(f => { return f.__path; });
+        assert.deepEqual(filenames, ['/COMMIT_MSG', 'about.txt', 'tags.html']);
+        assert.deepEqual(files[0], {
+          lines_inserted: 9,
+          lines_deleted: 0,
+          __path: '/COMMIT_MSG',
+        });
+        assert.deepEqual(files[1], {
+          lines_inserted: 0,
+          lines_deleted: 0,
+          __path: 'about.txt',
+        });
+        assert.deepEqual(files[2], {
+          lines_inserted: 0,
+          lines_deleted: 123,
+          __path: 'tags.html',
+        });
+
+        done();
+      });
+    });
+
+    test('calculate totals for patch number', () => {
       element._files = [
         {__path: '/COMMIT_MSG', lines_inserted: 9},
         {
@@ -177,7 +233,7 @@
       assert.isFalse(element._hideChangeTotals);
     });
 
-    test('binary only files', function() {
+    test('binary only files', () => {
       element._files = [
         {__path: '/COMMIT_MSG', lines_inserted: 9},
         {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
@@ -194,7 +250,7 @@
       assert.isTrue(element._hideChangeTotals);
     });
 
-    test('binary and regular files', function() {
+    test('binary and regular files', () => {
       element._files = [
         {__path: '/COMMIT_MSG', lines_inserted: 9},
         {__path: 'file_binary', binary: true, size_delta: 10, size: 100},
@@ -213,64 +269,64 @@
       assert.isFalse(element._hideChangeTotals);
     });
 
-    test('_formatBytes function', function() {
-      var table = {
-        64: '+64 B',
-        1023: '+1023 B',
-        1024: '+1 KiB',
-        4096: '+4 KiB',
-        1073741824: '+1 GiB',
+    test('_formatBytes function', () => {
+      const table = {
+        '64': '+64 B',
+        '1023': '+1023 B',
+        '1024': '+1 KiB',
+        '4096': '+4 KiB',
+        '1073741824': '+1 GiB',
         '-64': '-64 B',
         '-1023': '-1023 B',
         '-1024': '-1 KiB',
         '-4096': '-4 KiB',
         '-1073741824': '-1 GiB',
-        0: '+/-0 B',
+        '0': '+/-0 B',
       };
 
-      for (var bytes in table) {
+      for (const bytes in table) {
         if (table.hasOwnProperty(bytes)) {
           assert.equal(element._formatBytes(bytes), table[bytes]);
         }
       }
     });
 
-    test('_formatPercentage function', function() {
-      var table = [
-        { size: 100,
+    test('_formatPercentage function', () => {
+      const table = [
+        {size: 100,
           delta: 100,
           display: '',
         },
-        { size: 195060,
+        {size: 195060,
           delta: 64,
           display: '(+0%)',
         },
-        { size: 195060,
+        {size: 195060,
           delta: -64,
           display: '(-0%)',
         },
-        { size: 394892,
+        {size: 394892,
           delta: -7128,
           display: '(-2%)',
         },
-        { size: 90,
+        {size: 90,
           delta: -10,
           display: '(-10%)',
         },
-        { size: 110,
+        {size: 110,
           delta: 10,
           display: '(+10%)',
         },
       ];
 
-      table.forEach(function(item) {
+      for (const item of table) {
         assert.equal(element._formatPercentage(
             item.size, item.delta), item.display);
-      });
+      }
     });
 
-    suite('keyboard shortcuts', function() {
-      setup(function() {
+    suite('keyboard shortcuts', () => {
+      setup(() => {
         element._files = [
           {__path: '/COMMIT_MSG'},
           {__path: 'file_added_in_rev2.txt'},
@@ -281,15 +337,16 @@
           basePatchNum: 'PARENT',
           patchNum: '2',
         };
+        element.change = {_number: 42};
         element.$.fileCursor.setCursorAtIndex(0);
       });
 
-      test('toggle left diff via shortcut', function() {
-        var toggleLeftDiffStub = sandbox.stub();
+      test('toggle left diff via shortcut', () => {
+        const toggleLeftDiffStub = sandbox.stub();
         // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
         // https://github.com/sinonjs/sinon/issues/781
-        var diffsStub = sinon.stub(element, 'diffs', {
-          get: function() {
+        const diffsStub = sinon.stub(element, 'diffs', {
+          get() {
             return [{toggleLeftDiff: toggleLeftDiffStub}];
           },
         });
@@ -298,33 +355,47 @@
         diffsStub.restore();
       });
 
-      test('keyboard shortcuts', function() {
+      test('keyboard shortcuts', () => {
         flushAsynchronousOperations();
 
-        var items = Polymer.dom(element.root).querySelectorAll('.file-row');
+        const items = Polymer.dom(element.root).querySelectorAll('.file-row');
         element.$.fileCursor.stops = items;
         element.$.fileCursor.setCursorAtIndex(0);
         assert.equal(items.length, 3);
         assert.isTrue(items[0].classList.contains('selected'));
         assert.isFalse(items[1].classList.contains('selected'));
         assert.isFalse(items[2].classList.contains('selected'));
+        // j with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 74, 'shift', 'j');
+        assert.equal(element.$.fileCursor.index, 0);
+        // down should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 40, null, 'down');
+        assert.equal(element.$.fileCursor.index, 0);
+
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
         assert.equal(element.$.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
 
-        var showStub = sandbox.stub(page, 'show');
+        const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
         assert.equal(element.$.fileCursor.index, 2);
         assert.equal(element.selectedIndex, 2);
-        MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
-        assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
-            'Should navigate to /c/42/2/myfile.txt');
+
+        // k with a modifier should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 75, 'shift', 'k');
+        assert.equal(element.$.fileCursor.index, 2);
+
+        // up should not move the cursor.
+        MockInteractions.pressAndReleaseKeyOn(element, 38, null, 'down');
+        assert.equal(element.$.fileCursor.index, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
         assert.equal(element.$.fileCursor.index, 1);
         assert.equal(element.selectedIndex, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 79, null, 'o');
-        assert(showStub.lastCall.calledWith('/c/42/2/file_added_in_rev2.txt'),
+
+        assert(navStub.lastCall.calledWith(element.change,
+            'file_added_in_rev2.txt', '2'),
             'Should navigate to /c/42/2/file_added_in_rev2.txt');
 
         MockInteractions.pressAndReleaseKeyOn(element, 75, null, 'k');
@@ -334,10 +405,11 @@
         assert.equal(element.selectedIndex, 0);
       });
 
-      test('i key shows/hides selected inline diff', function() {
+      test('i key shows/hides selected inline diff', () => {
         sandbox.stub(element, '_expandedPathsChanged');
         flushAsynchronousOperations();
-        element.$.fileCursor.stops = element.diffs;
+        const files = Polymer.dom(element.root).querySelectorAll('.file-row');
+        element.$.fileCursor.stops = files;
         element.$.fileCursor.setCursorAtIndex(0);
         MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
         flushAsynchronousOperations();
@@ -352,58 +424,95 @@
 
         MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
         flushAsynchronousOperations();
-        for (var index in element.diffs) {
+        for (const index in element.diffs) {
+          if (!element.diffs.hasOwnProperty(index)) { continue; }
           assert.include(element._expandedFilePaths, element.diffs[index].path);
         }
         MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
         flushAsynchronousOperations();
-        for (var index in element.diffs) {
+        for (const index in element.diffs) {
+          if (!element.diffs.hasOwnProperty(index)) { continue; }
           assert.notInclude(element._expandedFilePaths,
               element.diffs[index].path);
         }
       });
 
-      test('_handleEnterKey navigates', function() {
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        sandbox.stub(element, 'modifierPressed').returns(false);
-        var expandStub = sandbox.stub(element, '_openCursorFile');
-        var navStub = sandbox.stub(element, '_openSelectedFile');
-        var e = new CustomEvent('fake-keyboard-event');
-        sinon.stub(e, 'preventDefault');
-        element._showInlineDiffs = false;
-        element._handleEnterKey(e);
-        assert.isTrue(e.preventDefault.called);
-        assert.isTrue(navStub.called);
-        assert.isFalse(expandStub.called);
+      test('r key toggles reviewed flag', () => {
+        flushAsynchronousOperations();
+
+        // Default state should be unreviewed.
+        assert.equal(element._reviewed.length, 0);
+
+        // Press the review key to toggle it (set the flag).
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.equal(element._reviewed.length, 1);
+
+        // Press the review key to toggle it (clear the flag).
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.equal(element._reviewed.length, 0);
       });
 
-      test('_handleEnterKey expands', function() {
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        sandbox.stub(element, 'modifierPressed').returns(false);
-        var expandStub = sandbox.stub(element, '_openCursorFile');
-        var navStub = sandbox.stub(element, '_openSelectedFile');
-        var e = new CustomEvent('fake-keyboard-event');
-        sinon.stub(e, 'preventDefault');
-        element._showInlineDiffs = true;
-        element._handleEnterKey(e);
-        assert.isTrue(e.preventDefault.called);
-        assert.isFalse(navStub.called);
-        assert.isTrue(expandStub.called);
-      });
+      suite('_handleOKey', () => {
+        let interact;
 
-      test('_handleEnterKey noop when anchor focused', function() {
-        sandbox.stub(element, 'shouldSuppressKeyboardShortcut').returns(false);
-        sandbox.stub(element, 'modifierPressed').returns(false);
-        var e = new CustomEvent('fake-keyboard-event',
-            {detail: {keyboardEvent: {target: document.createElement('a')}}});
-        sinon.stub(e, 'preventDefault');
-        element._handleEnterKey(e);
-        assert.isFalse(e.preventDefault.called);
+        setup(() => {
+          sandbox.stub(element, 'shouldSuppressKeyboardShortcut')
+              .returns(false);
+          sandbox.stub(element, 'modifierPressed').returns(false);
+          const openCursorStub = sandbox.stub(element, '_openCursorFile');
+          const openSelectedStub = sandbox.stub(element, '_openSelectedFile');
+          const expandStub = sandbox.stub(element, '_togglePathExpanded');
+
+          interact = function(opt_payload) {
+            openCursorStub.reset();
+            openSelectedStub.reset();
+            expandStub.reset();
+
+            const e = new CustomEvent('fake-keyboard-event', opt_payload);
+            sinon.stub(e, 'preventDefault');
+            element._handleOKey(e);
+            assert.isTrue(e.preventDefault.called);
+            const result = {};
+            if (openCursorStub.called) {
+              result.opened_cursor = true;
+            }
+            if (openSelectedStub.called) {
+              result.opened_selected = true;
+            }
+            if (expandStub.called) {
+              result.expanded = true;
+            }
+            return result;
+          };
+        });
+
+        test('open from selected file', () => {
+          element._showInlineDiffs = false;
+          assert.deepEqual(interact(), {opened_selected: true});
+        });
+
+        test('open from diff cursor', () => {
+          element._showInlineDiffs = true;
+          assert.deepEqual(interact(), {opened_cursor: true});
+
+          // "Show diffs" mode overrides userPrefs.expand_inline_diffs
+          element._userPrefs = {expand_inline_diffs: true};
+          assert.deepEqual(interact(), {opened_cursor: true});
+        });
+
+        test('expand when user prefers', () => {
+          element._showInlineDiffs = false;
+          assert.deepEqual(interact(), {opened_selected: true});
+          element._userPrefs = {};
+          assert.deepEqual(interact(), {opened_selected: true});
+          element._userPrefs.expand_inline_diffs = true;
+          assert.deepEqual(interact(), {expanded: true});
+        });
       });
     });
 
-    test('comment filtering', function() {
-      var comments = {
+    test('comment filtering', () => {
+      const comments = {
         '/COMMIT_MSG': [
           {patch_set: 1, message: 'Done', updated: '2017-02-08 16:40:49'},
           {patch_set: 1, message: 'oh hay', updated: '2017-02-09 16:40:49'},
@@ -439,7 +548,7 @@
           },
         ],
       };
-      var drafts = {
+      const drafts = {
         'unresolved.file': [
           {
             patch_set: 2,
@@ -471,13 +580,13 @@
           '1d');
       assert.equal(
           element._computeCountString(comments, '1',
-          'file_added_in_rev2.txt', 'comment'), '');
+              'file_added_in_rev2.txt', 'comment'), '');
       assert.equal(
           element._computeCommentsStringMobile(comments, '1',
-          'file_added_in_rev2.txt'), '');
+              'file_added_in_rev2.txt'), '');
       assert.equal(
           element._computeDraftsStringMobile(comments, '1',
-          'file_added_in_rev2.txt'), '');
+              'file_added_in_rev2.txt'), '');
       assert.equal(
           element._computeCountString(comments, '2', '/COMMIT_MSG', 'comment'),
           '1 comment');
@@ -498,39 +607,34 @@
           '2d');
       assert.equal(
           element._computeCountString(comments, '2',
-          'file_added_in_rev2.txt', 'comment'), '');
+              'file_added_in_rev2.txt', 'comment'), '');
       assert.equal(element._computeCountString(comments, '2',
           'unresolved.file', 'comment'), '3 comments');
       assert.equal(
           element._computeUnresolvedString(comments, [], 2, 'myfile.txt'), '');
       assert.equal(
+          element.computeUnresolvedNum(comments, [], 2, 'myfile.txt'), 0);
+      assert.equal(
           element._computeUnresolvedString(comments, [], 2, 'unresolved.file'),
           '(1 unresolved)');
       assert.equal(
+          element.computeUnresolvedNum(comments, [], 2, 'unresolved.file'), 1);
+      assert.equal(
           element._computeUnresolvedString(comments, drafts, 2,
-          'unresolved.file'), '');
+              'unresolved.file'), '');
     });
 
-    test('computed properties', function() {
+    test('computed properties', () => {
       assert.equal(element._computeFileStatus('A'), 'A');
       assert.equal(element._computeFileStatus(undefined), 'M');
       assert.equal(element._computeFileStatus(null), 'M');
 
-      assert.equal(element._computeFileDisplayName('/foo/bar/baz'),
-          '/foo/bar/baz');
-      assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
-          'Commit message');
-
       assert.equal(element._computeClass('clazz', '/foo/bar/baz'), 'clazz');
       assert.equal(element._computeClass('clazz', '/COMMIT_MSG'),
           'clazz invisible');
-      assert.equal(element._computeExpandInlineClass(
-          {expand_inline_diffs: true}), 'expandInline');
-      assert.equal(element._computeExpandInlineClass(
-        {expand_inline_diffs: false}), '');
     });
 
-    test('file review status', function() {
+    test('file review status', () => {
       element._files = [
         {__path: '/COMMIT_MSG'},
         {__path: 'file_added_in_rev2.txt'},
@@ -546,34 +650,45 @@
       element.$.fileCursor.setCursorAtIndex(0);
 
       flushAsynchronousOperations();
-      var fileRows =
+      const fileRows =
           Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
-      var commitMsg = fileRows[0].querySelector(
-          'input.reviewed[type="checkbox"]');
-      var fileAdded = fileRows[1].querySelector(
-          'input.reviewed[type="checkbox"]');
-      var myFile = fileRows[2].querySelector(
-          'input.reviewed[type="checkbox"]');
+      const checkSelector = 'input.reviewed[type="checkbox"]';
+      const commitMsg = fileRows[0].querySelector(checkSelector);
+      const fileAdded = fileRows[1].querySelector(checkSelector);
+      const myFile = fileRows[2].querySelector(checkSelector);
 
       assert.isTrue(commitMsg.checked);
       assert.isFalse(fileAdded.checked);
       assert.isTrue(myFile.checked);
 
-      MockInteractions.tap(commitMsg);
+      const commitReviewLabel = fileRows[0].querySelector('.reviewedLabel');
+      const markReviewLabel = commitMsg.nextElementSibling;
+      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
+
+      const tapSpy = sandbox.spy(element, '_handleFileListTap');
+      MockInteractions.tap(markReviewLabel);
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', false));
-      MockInteractions.tap(commitMsg);
+      assert.isFalse(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK REVIEWED');
+      assert.isTrue(tapSpy.lastCall.args[0].defaultPrevented);
+
+      MockInteractions.tap(markReviewLabel);
       assert.isTrue(saveStub.lastCall.calledWithExactly('/COMMIT_MSG', true));
+      assert.isTrue(commitReviewLabel.classList.contains('isReviewed'));
+      assert.equal(markReviewLabel.textContent, 'MARK UNREVIEWED');
+      assert.isTrue(tapSpy.lastCall.args[0].defaultPrevented);
     });
 
-    test('patch set from revisions', function() {
-      var expected = [
+    test('patch set from revisions', () => {
+      const expected = [
         {num: 1, desc: 'test'},
         {num: 2, desc: 'test'},
         {num: 3, desc: 'test'},
         {num: 4, desc: 'test'},
       ];
-      var patchNums = element._computePatchSets({
-        base: {
+      const patchNums = element.computeAllPatchSets({
+        revisions: {
           rev3: {_number: 3, description: 'test'},
           rev1: {_number: 1, description: 'test'},
           rev4: {_number: 4, description: 'test'},
@@ -581,49 +696,12 @@
         },
       });
       assert.equal(patchNums.length, expected.length);
-      for (var i = 0; i < expected.length; i++) {
+      for (let i = 0; i < expected.length; i++) {
         assert.deepEqual(patchNums[i], expected[i]);
       }
     });
 
-    test('patch range string', function() {
-      assert.equal(
-          element._patchRangeStr({basePatchNum: 'PARENT', patchNum: '1'}),
-          '1');
-      assert.equal(
-          element._patchRangeStr({basePatchNum: '1', patchNum: '3'}),
-          '1..3');
-    });
-
-    test('diff against dropdown', function(done) {
-      var showStub = sandbox.stub(page, 'show');
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '3',
-      };
-      element.revisions = {
-        rev1: {_number: 1},
-        rev2: {_number: 2},
-        rev3: {_number: 3},
-      };
-      flush(function() {
-        var selectEl = element.$.patchChange;
-        assert.equal(selectEl.value, 'PARENT');
-        assert.isTrue(element.$$('option[value="3"]').hasAttribute('disabled'));
-        selectEl.addEventListener('change', function() {
-          assert.equal(selectEl.value, '2');
-          assert(showStub.lastCall.calledWithExactly('/c/42/2..3'),
-              'Should navigate to /c/42/2..3');
-          showStub.restore();
-          done();
-        });
-        selectEl.value = '2';
-        element.fire('change', {}, {node: selectEl});
-      });
-    });
-
-    test('checkbox shows/hides diff inline', function() {
+    test('checkbox shows/hides diff inline', () => {
       element._files = [
         {__path: 'myfile.txt'},
       ];
@@ -635,35 +713,20 @@
       element.$.fileCursor.setCursorAtIndex(0);
       sandbox.stub(element, '_expandedPathsChanged');
       flushAsynchronousOperations();
-      var fileRows =
+      const fileRows =
           Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
-      var showHideCheck = fileRows[0].querySelector(
+      // Because the label surrounds the input, the tap event is triggered
+      // there first.
+      const showHideLabel = fileRows[0].querySelector('label.show-hide');
+      const showHideCheck = fileRows[0].querySelector(
           'input.show-hide[type="checkbox"]');
       assert.isNotOk(showHideCheck.checked);
-      MockInteractions.tap(showHideCheck);
+      MockInteractions.tap(showHideLabel);
       assert.isOk(showHideCheck.checked);
       assert.notEqual(element._expandedFilePaths.indexOf('myfile.txt'), -1);
     });
 
-    test('path should be properly escaped', function() {
-      element._files = [
-        {__path: 'foo bar/my+file.txt%'},
-      ];
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      flushAsynchronousOperations();
-      // Slashes should be preserved, and spaces should be translated to `+`.
-      // @see Issue 4255 regarding double-encoding.
-      // @see Issue 4577 regarding more readable URLs.
-      assert.equal(
-          element.$$('a').getAttribute('href'),
-          '/c/42/2/foo+bar/my%252Bfile.txt%2525');
-    });
-
-    test('diff mode correctly toggles the diffs', function() {
+    test('diff mode correctly toggles the diffs', () => {
       element._files = [
         {__path: 'myfile.txt'},
       ];
@@ -672,82 +735,32 @@
         basePatchNum: 'PARENT',
         patchNum: '2',
       };
+      sandbox.spy(element, '_updateDiffPreferences');
       element.$.fileCursor.setCursorAtIndex(0);
       flushAsynchronousOperations();
-      var diffDisplay = element.diffs[0];
+
+      // Tap on a file to generate the diff.
+      const row = Polymer.dom(element.root)
+          .querySelectorAll('.row:not(.header) label.show-hide')[0];
+
+      MockInteractions.tap(row);
+      flushAsynchronousOperations();
+      const diffDisplay = element.diffs[0];
       element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
-      assert.equal(element.diffViewMode, 'SIDE_BY_SIDE');
-      assert.equal(diffDisplay.viewMode, 'SIDE_BY_SIDE');
       element.set('diffViewMode', 'UNIFIED_DIFF');
       assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
+      assert.isTrue(element._updateDiffPreferences.called);
     });
 
-    test('diff mode selector initializes from preferences', function() {
-      var resolvePrefs;
-      var prefsPromise = new Promise(function(resolve) {
-        resolvePrefs = resolve;
-      });
-      sandbox.stub(element, '_getPreferences').returns(prefsPromise);
 
-      // Attach a new gr-file-list so we can intercept the preferences fetch.
-      var view = document.createElement('gr-file-list');
-      var select = view.$.modeSelect;
-      fixture('blank').appendChild(view);
-      flushAsynchronousOperations();
-
-      // At this point the diff mode doesn't yet have the user's preference.
-      assert.equal(select.value, 'SIDE_BY_SIDE');
-
-      // Receive the overriding preference.
-      resolvePrefs({default_diff_view: 'UNIFIED'});
-      flushAsynchronousOperations();
-      assert.equal(select.value, 'SIDE_BY_SIDE');
-      document.getElementById('blank').restore();
-    });
-
-    test('show/hide diffs disabled for large amounts of files', function(done) {
-      var computeSpy = sandbox.spy(element, '_fileListActionsVisible');
-      element._files = [];
-      element.changeNum = '42';
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '2',
-      };
-      element.$.fileCursor.setCursorAtIndex(0);
-      flush(function() {
-        assert.isTrue(computeSpy.lastCall.returnValue);
-        var arr = [];
-        _.times(element._maxFilesForBulkActions + 1, function() {
-          arr.push({__path: 'myfile.txt'});
-        });
-        element._files = arr;
-        element._numFilesShown = arr.length;
-        assert.isFalse(computeSpy.lastCall.returnValue);
-        done();
-      });
-    });
-
-    test('expanded attribute not set on path when not expanded', function() {
+    test('expanded attribute not set on path when not expanded', () => {
       element._files = [
         {__path: '/COMMIT_MSG'},
       ];
       assert.isNotOk(element.$$('.expanded'));
     });
 
-    test('_getDiffViewMode', function() {
-      // No user prefs or diff view mode set.
-      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
-      // User prefs but no diff view mode set.
-      element.diffViewMode = null;
-      element._userPrefs = {default_diff_view: 'UNIFIED_DIFF'};
-      assert.equal(
-          element._getDiffViewMode(null, element._userPrefs), 'UNIFIED_DIFF');
-      // User prefs and diff view mode set.
-      element.diffViewMode = 'SIDE_BY_SIDE';
-      assert.equal(element._getDiffViewMode(
-          element.diffViewMode, element._userPrefs), 'SIDE_BY_SIDE');
-    });
-    test('expand_inline_diffs user preference', function() {
+    test('expand_inline_diffs user preference', () => {
       element._files = [
         {__path: '/COMMIT_MSG'},
       ];
@@ -758,30 +771,30 @@
       };
       sandbox.stub(element, '_expandedPathsChanged');
       flushAsynchronousOperations();
-      var commitMsgFile = Polymer.dom(element.root)
+      const commitMsgFile = Polymer.dom(element.root)
           .querySelectorAll('.row:not(.header) a')[0];
 
       // Remove href attribute so the app doesn't route to a diff view
       commitMsgFile.removeAttribute('href');
-      var hiddenChangeSpy = sandbox.spy(element, '_handleHiddenChange');
+      const togglePathSpy = sandbox.spy(element, '_togglePathExpanded');
 
       MockInteractions.tap(commitMsgFile);
       flushAsynchronousOperations();
-      assert(hiddenChangeSpy.notCalled, 'file is opened as diff view');
+      assert(togglePathSpy.notCalled, 'file is opened as diff view');
       assert.isNotOk(element.$$('.expanded'));
 
       element._userPrefs = {expand_inline_diffs: true};
       flushAsynchronousOperations();
       MockInteractions.tap(commitMsgFile);
       flushAsynchronousOperations();
-      assert(hiddenChangeSpy.calledOnce, 'file is expanded');
+      assert(togglePathSpy.calledOnce, 'file is expanded');
       assert.isOk(element.$$('.expanded'));
     });
 
-    test('_togglePathExpanded', function() {
-      var path = 'path/to/my/file.txt';
+    test('_togglePathExpanded', () => {
+      const path = 'path/to/my/file.txt';
       element.files = [{__path: path}];
-      var renderStub = sandbox.stub(element, '_renderInOrder')
+      const renderStub = sandbox.stub(element, '_renderInOrder')
           .returns(Promise.resolve());
 
       assert.equal(element._expandedFilePaths.length, 0);
@@ -797,111 +810,486 @@
       assert.notInclude(element._expandedFilePaths, path);
     });
 
-    test('_expandedPathsChanged', function(done) {
+    test('collapseAllDiffs', () => {
+      sandbox.stub(element, '_renderInOrder')
+          .returns(Promise.resolve());
+      const cursorUpdateStub = sandbox.stub(element.$.diffCursor,
+          'handleDiffUpdate');
+
+      const path = 'path/to/my/file.txt';
+      element.files = [{__path: path}];
+      element._expandedFilePaths = [path];
+      element._showInlineDiffs = true;
+
+      element.collapseAllDiffs();
+      flushAsynchronousOperations();
+      assert.equal(element._expandedFilePaths.length, 0);
+      assert.isFalse(element._showInlineDiffs);
+      assert.isTrue(cursorUpdateStub.calledOnce);
+    });
+
+    test('_expandedPathsChanged', done => {
       sandbox.stub(element, '_reviewFile');
-      var path = 'path/to/my/file.txt';
-      var diffs = [{
-        path: path,
-        reload: function() {
+      const path = 'path/to/my/file.txt';
+      const diffs = [{
+        path,
+        reload() {
           done();
         },
       }];
-      var diffsStub = sinon.stub(element, 'diffs', {
-        get: function() { return diffs; },
+      sinon.stub(element, 'diffs', {
+        get() { return diffs; },
       });
       element.push('_expandedFilePaths', path);
     });
 
-    suite('_handleFileTap', function() {
+    suite('_handleFileListTap', () => {
       function testForModifier(modifier) {
-        var e = {preventDefault: function() {}};
+        const e = {preventDefault() {}};
         e.detail = {sourceEvent: {}};
+        e.target = {
+          dataset: {path: '/test'},
+          classList: element.classList,
+        };
+
         e.detail.sourceEvent[modifier] = true;
 
-        var hiddenChangeStub = sandbox.stub(element, '_handleHiddenChange');
-        element._userPrefs = { expand_inline_diffs: true };
+        const togglePathStub = sandbox.stub(element, '_togglePathExpanded');
+        element._userPrefs = {expand_inline_diffs: true};
 
-        element._handleFileTap(e);
-        assert.isFalse(hiddenChangeStub.called);
+        element._handleFileListTap(e);
+        assert.isFalse(togglePathStub.called);
 
         e.detail.sourceEvent[modifier] = false;
-        element._handleFileTap(e);
-        assert.equal(hiddenChangeStub.callCount, 1);
+        element._handleFileListTap(e);
+        assert.equal(togglePathStub.callCount, 1);
 
-        element._userPrefs = { expand_inline_diffs: false };
-        element._handleFileTap(e);
-        assert.equal(hiddenChangeStub.callCount, 1);
+        element._userPrefs = {expand_inline_diffs: false};
+        element._handleFileListTap(e);
+        assert.equal(togglePathStub.callCount, 1);
       }
 
-      test('_handleFileTap meta', function() {
+      test('_handleFileListTap meta', () => {
         testForModifier('metaKey');
       });
 
-      test('_handleFileTap ctrl', function() {
+      test('_handleFileListTap ctrl', () => {
         testForModifier('ctrlKey');
       });
     });
 
-    test('_renderInOrder', function(done) {
-      var reviewStub = sandbox.stub(element, '_reviewFile');
-      var callCount = 0;
-      var diffs = [{
+    test('_renderInOrder', done => {
+      const reviewStub = sandbox.stub(element, '_reviewFile');
+      let callCount = 0;
+      const diffs = [{
         path: 'p0',
-        reload: function() {
+        reload() {
           assert.equal(callCount++, 2);
           return Promise.resolve();
         },
       }, {
         path: 'p1',
-        reload: function() {
+        reload() {
           assert.equal(callCount++, 1);
           return Promise.resolve();
         },
       }, {
         path: 'p2',
-        reload: function() {
+        reload() {
           assert.equal(callCount++, 0);
           return Promise.resolve();
         },
       }];
       element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
-        .then(function() {
-          assert.isFalse(reviewStub.called);
-          done();
-        });
+          .then(() => {
+            assert.isFalse(reviewStub.called);
+            assert.isTrue(loadCommentStub.called);
+            done();
+          });
     });
 
-    test('_renderInOrder logged in', function(done) {
+    test('_renderInOrder logged in', done => {
       element._isLoggedIn = true;
-      var reviewStub = sandbox.stub(element, '_reviewFile');
-      var callCount = 0;
-      var diffs = [{
+      const reviewStub = sandbox.stub(element, '_reviewFile');
+      let callCount = 0;
+      const diffs = [{
         path: 'p0',
-        reload: function() {
+        reload() {
           assert.equal(reviewStub.callCount, 2);
           assert.equal(callCount++, 2);
           return Promise.resolve();
         },
       }, {
         path: 'p1',
-        reload: function() {
+        reload() {
           assert.equal(reviewStub.callCount, 1);
           assert.equal(callCount++, 1);
           return Promise.resolve();
         },
       }, {
         path: 'p2',
-        reload: function() {
+        reload() {
           assert.equal(reviewStub.callCount, 0);
           assert.equal(callCount++, 0);
           return Promise.resolve();
         },
       }];
       element._renderInOrder(['p2', 'p1', 'p0'], diffs, 3)
-        .then(function() {
-          assert.equal(reviewStub.callCount, 3);
+          .then(() => {
+            assert.equal(reviewStub.callCount, 3);
+            done();
+          });
+    });
+
+    test('_loadingChanged fired from reload in debouncer', done => {
+      element.changeNum = 123;
+      element.patchRange = {patchNum: 12};
+      element._files = [{__path: 'foo.bar'}];
+
+      element.reload().then(() => {
+        assert.isFalse(element._loading);
+        element.flushDebouncer('loading-change');
+        assert.isFalse(element.classList.contains('loading'));
+        done();
+      });
+      assert.isTrue(element._loading);
+      assert.isFalse(element.classList.contains('loading'));
+      element.flushDebouncer('loading-change');
+      assert.isTrue(element.classList.contains('loading'));
+    });
+
+    test('_loadingChanged does not set class when there are no files', () => {
+      element.changeNum = 123;
+      element.patchRange = {patchNum: 12};
+      element.reload();
+      assert.isTrue(element._loading);
+      element.flushDebouncer('loading-change');
+      assert.isFalse(element.classList.contains('loading'));
+    });
+
+    test('no execute _computeDiffURL before patchNum is knwon', done => {
+      const urlStub = sandbox.stub(element, '_computeDiffURL');
+      element.change = {_number: 123};
+      element.patchRange = {patchNum: undefined, basePatchNum: 'PARENT'};
+      element._files = [{__path: 'foo/bar.cpp'}];
+      flush(() => {
+        assert.isFalse(urlStub.called);
+        element.set('patchRange.patchNum', 4);
+        flush(() => {
+          assert.isTrue(urlStub.called);
           done();
         });
+      });
     });
   });
+
+  suite('gr-file-list inline diff tests', () => {
+    let element;
+    let sandbox;
+
+    const setupDiff = function(diff) {
+      const mock = document.createElement('mock-diff-response');
+      diff._diff = mock.diffResponse;
+      diff.comments = {
+        left: [],
+        right: [],
+      };
+      diff.prefs = {
+        context: 10,
+        tab_size: 8,
+        font_size: 12,
+        line_length: 100,
+        cursor_blink_rate: 0,
+        line_wrapping: false,
+        intraline_difference: true,
+        show_line_endings: true,
+        show_tabs: true,
+        show_whitespace_errors: true,
+        syntax_highlighting: true,
+        auto_hide_diff_table_header: true,
+        theme: 'DEFAULT',
+        ignore_whitespace: 'IGNORE_NONE',
+      };
+      diff._renderDiffTable();
+    };
+
+    const renderAndGetNewDiffs = function(index) {
+      const diffs =
+          Polymer.dom(element.root).querySelectorAll('gr-diff');
+
+      for (let i = index; i < diffs.length; i++) {
+        setupDiff(diffs[i]);
+      }
+
+      element._updateDiffCursor();
+      element.$.diffCursor.handleDiffUpdate();
+      return diffs;
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(true); },
+        getPreferences() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat() { return Promise.resolve(''); },
+      });
+      stub('gr-diff', {
+        reload() { return Promise.resolve(); },
+      });
+      stub('gr-comment-api', {
+        loadAll() { return Promise.resolve(); },
+        getPaths() { return {}; },
+        getCommentsForPath() { return {meta: {}, left: [], right: []}; },
+      });
+      element = fixture('basic');
+      element.numFilesShown = 75;
+      element.selectedIndex = 0;
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {
+          __path: 'file_added_in_rev2.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+        {
+          __path: 'myfile.txt',
+          lines_inserted: 1,
+          lines_deleted: 1,
+          size_delta: 10,
+          size: 100,
+        },
+      ];
+      element._reviewed = ['/COMMIT_MSG', 'myfile.txt'];
+      element._loggedIn = true;
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      sandbox.stub(window, 'fetch', () => {
+        return Promise.resolve();
+      });
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('cursor with individually opened files', () => {
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      flushAsynchronousOperations();
+      let diffs = renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 1);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(Polymer.dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+      assert.isFalse(diffStops[11].classList.contains('target-row'));
+
+      // The file cusor is now at 1.
+      assert.equal(element.$.fileCursor.index, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 73, null, 'i');
+      flushAsynchronousOperations();
+
+      diffs = renderAndGetNewDiffs(1);
+      // Two diffs should be rendered.
+      assert.equal(diffs.length, 2);
+      const diffStopsFirst = diffs[0].getCursorStops();
+      const diffStopsSecond = diffs[1].getCursorStops();
+
+      // The line on the first diff is stil selected
+      assert.isTrue(diffStopsFirst[10].classList.contains('target-row'));
+      assert.isFalse(diffStopsSecond[10].classList.contains('target-row'));
+    });
+
+    test('cursor with toggle all files', () => {
+      MockInteractions.pressAndReleaseKeyOn(element, 73, 'shift', 'i');
+      flushAsynchronousOperations();
+
+      const diffs = renderAndGetNewDiffs(0);
+      const diffStops = diffs[0].getCursorStops();
+
+      // 1 diff should be rendered.
+      assert.equal(diffs.length, 3);
+
+      // No line number is selected.
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+
+      // Tapping content on a line selects the line number.
+      MockInteractions.tap(Polymer.dom(
+          diffStops[10]).querySelectorAll('.contentText')[0]);
+      flushAsynchronousOperations();
+      assert.isTrue(diffStops[10].classList.contains('target-row'));
+
+      // Keyboard shortcuts are still moving the file cursor, not the diff
+      // cursor.
+      MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
+      flushAsynchronousOperations();
+      assert.isFalse(diffStops[10].classList.contains('target-row'));
+      assert.isTrue(diffStops[11].classList.contains('target-row'));
+
+      // The file cusor is still at 0.
+      assert.equal(element.$.fileCursor.index, 0);
+    });
+
+    suite('n key presses', () => {
+      let nKeySpy;
+      let nextCommentStub;
+      let nextChunkStub;
+      let fileRows;
+      setup(() => {
+        sandbox.stub(element, '_renderInOrder').returns(Promise.resolve());
+        nKeySpy = sandbox.spy(element, '_handleNKey');
+        nextCommentStub = sandbox.stub(element.$.diffCursor,
+            'moveToNextCommentThread');
+        nextChunkStub = sandbox.stub(element.$.diffCursor,
+            'moveToNextChunk');
+        fileRows =
+            Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
+      });
+      test('n key with all files expanded and no shift key', () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        flushAsynchronousOperations();
+
+        // Handle N key should return before calling diff cursor functions.
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isFalse(!!element._showInlineDiffs);
+      });
+
+      test('n key with all files expanded and shift key', () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, null, 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isFalse(!!element._showInlineDiffs);
+      });
+
+      test('n key without all files expanded and shift key', () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isFalse(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 2);
+        assert.isTrue(element._showInlineDiffs);
+      });
+
+      test('n key without all files expanded and no shift key', () => {
+        MockInteractions.pressAndReleaseKeyOn(fileRows[0], 73, 'shift', 'i');
+        flushAsynchronousOperations();
+
+        MockInteractions.pressAndReleaseKeyOn(element, 78, 'shift', 'n');
+        assert.isTrue(nKeySpy.called);
+        assert.isTrue(nextCommentStub.called);
+
+        // This is also called in diffCursor.moveToFirstChunk.
+        assert.equal(nextChunkStub.callCount, 1);
+        assert.isTrue(element._showInlineDiffs);
+      });
+    });
+
+    test('_openSelectedFile behavior', () => {
+      const _files = element._files;
+      element.set('_files', []);
+      const navStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      // Noop when there are no files.
+      element._openSelectedFile();
+      assert.isFalse(navStub.called);
+
+      element.set('_files', _files);
+      flushAsynchronousOperations();
+       // Navigates when a file is selected.
+      element._openSelectedFile();
+      assert.isTrue(navStub.called);
+    });
+
+    test('_displayLine', () => {
+      sandbox.stub(element, 'shouldSuppressKeyboardShortcut', () => false);
+      sandbox.stub(element, 'modifierPressed', () => false);
+      element._showInlineDiffs = true;
+      const mockEvent = {preventDefault() {}};
+
+      element._displayLine = false;
+      element._handleDownKey(mockEvent);
+      assert.isTrue(element._displayLine);
+
+      element._displayLine = false;
+      element._handleUpKey(mockEvent);
+      assert.isTrue(element._displayLine);
+
+      element._displayLine = true;
+      element._handleEscKey(mockEvent);
+      assert.isFalse(element._displayLine);
+    });
+
+    suite('editLoaded behavior', () => {
+      const isVisible = el => {
+        assert.ok(el);
+        return getComputedStyle(el).getPropertyValue('display') !== 'none';
+      };
+
+      test('reviewed checkbox', () => {
+        const alertStub = sandbox.stub();
+        const saveReviewStub = sandbox.stub(element, '_saveReviewedState');
+
+        element.addEventListener('show-alert', alertStub);
+        element.editLoaded = false;
+        // Reviewed checkbox should be shown.
+        assert.isTrue(isVisible(element.$$('.reviewed')));
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.isFalse(alertStub.called);
+        assert.isTrue(saveReviewStub.calledOnce);
+
+        element.editLoaded = true;
+        flushAsynchronousOperations();
+
+        assert.isFalse(isVisible(element.$$('.reviewed')));
+        MockInteractions.pressAndReleaseKeyOn(element, 82, null, 'r');
+        assert.isTrue(alertStub.called);
+        assert.isTrue(saveReviewStub.calledOnce);
+      });
+
+      test('_getReviewedFiles does not call API', () => {
+        const apiSpy = sandbox.spy(element.$.restAPI, 'getReviewedFiles');
+        element.editLoaded = true;
+        return element._getReviewedFiles().then(files => {
+          assert.equal(files.length, 0);
+          assert.isFalse(apiSpy.called);
+        });
+      });
+    });
+  });
+  a11ySuite('basic');
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
new file mode 100644
index 0000000..c3d5808
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.html
@@ -0,0 +1,100 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-included-in-dialog">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        max-height: 80vh;
+        overflow-y: auto;
+        padding: 4.5em 1em 1em 1em;
+      }
+      header {
+        background: #fff;
+        border-bottom: 1px solid #cdcdcd;
+        left: 0;
+        padding: 1em;
+        position: absolute;
+        right: 0;
+        top: 0;
+      }
+      #title {
+        display: inline-block;
+        font-size: 1.2rem;
+        margin-top: .2em;
+      }
+      h2 {
+        font-size: 1rem;
+      }
+      #filterInput {
+        display: inline-block;
+        float: right;
+        margin: 0 1em;
+        padding: .2em;
+      }
+      .closeButtonContainer {
+        float: right;
+      }
+      ul {
+        margin-bottom: 1em;
+      }
+      ul li {
+        border-radius: .2em;
+        background: #eee;
+        display: inline-block;
+        margin: 0 .2em .4em .2em;
+        padding: .2em .4em;
+      }
+      .loading.loaded {
+        display: none;
+      }
+    </style>
+    <header>
+      <h1 id="title">Included In:</h1>
+      <span class="closeButtonContainer">
+        <gr-button id="closeButton"
+            link
+            on-tap="_handleCloseTap">Close</gr-button>
+      </span>
+      <input
+          id="filterInput"
+          is="iron-input"
+          placeholder="Filter"
+          on-bind-value-changed="_onFilterChanged">
+    </header>
+    <div class$="[[_computeLoadingClass(_loaded)]]">Loading...</div>
+    <template
+        is="dom-repeat"
+        items="[[_computeGroups(_includedIn, _filterText)]]"
+        as="group">
+      <div>
+        <h2>[[group.title]]:</h2>
+        <ul>
+          <template is="dom-repeat" items="[[group.items]]">
+            <li>[[item]]</li>
+          </template>
+        </ul>
+      </div>
+    </template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-included-in-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
new file mode 100644
index 0000000..93e644e
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.js
@@ -0,0 +1,96 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-included-in-dialog',
+
+    /**
+     * Fired when the user presses the close button.
+     *
+     * @event close
+     */
+
+    properties: {
+      /** @type {?} */
+      changeNum: {
+        type: Object,
+        observer: '_resetData',
+      },
+      /** @type {?} */
+      _includedIn: Object,
+      _loaded: {
+        type: Boolean,
+        value: false,
+      },
+      _filterText: {
+        type: String,
+        value: '',
+      },
+    },
+
+    loadData() {
+      if (!this.changeNum) { return; }
+      this._filterText = '';
+      return this.$.restAPI.getChangeIncludedIn(this.changeNum).then(
+          configs => {
+            if (!configs) { return; }
+            this._includedIn = configs;
+            this._loaded = true;
+          });
+    },
+
+    _resetData() {
+      this._includedIn = null;
+      this._loaded = false;
+    },
+
+    _computeGroups(includedIn, filterText) {
+      if (!includedIn) { return []; }
+
+      const filter = item => !filterText.length ||
+          item.toLowerCase().indexOf(filterText.toLowerCase()) !== -1;
+
+      const groups = [
+        {title: 'Branches', items: includedIn.branches.filter(filter)},
+        {title: 'Tags', items: includedIn.tags.filter(filter)},
+      ];
+      if (includedIn.external) {
+        for (const externalKey of Object.keys(includedIn.external)) {
+          groups.push({
+            title: externalKey,
+            items: includedIn.external[externalKey].filter(filter),
+          });
+        }
+      }
+      return groups.filter(g => g.items.length);
+    },
+
+    _handleCloseTap(e) {
+      e.preventDefault();
+      this.fire('close', null, {bubbles: false});
+    },
+
+    _computeLoadingClass(loaded) {
+      return loaded ? 'loading loaded' : 'loading';
+    },
+
+    _onFilterChanged() {
+      this.debounce('filter-change', () => {
+        this._filterText = this.$.filterInput.bindValue;
+      }, 100);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
new file mode 100644
index 0000000..1f7af38
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog_test.html
@@ -0,0 +1,83 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-included-in-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-included-in-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-included-in-dialog></gr-included-in-dialog>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-included-in-dialog', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('_computeGroups', () => {
+      const includedIn = {branches: [], tags: []};
+      let filterText = '';
+      assert.deepEqual(element._computeGroups(includedIn, filterText), []);
+
+      includedIn.branches.push('master', 'development', 'stable-2.0');
+      includedIn.tags.push('v1.9', 'v2.0', 'v2.1');
+      assert.deepEqual(element._computeGroups(includedIn, filterText), [
+        {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+        {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+      ]);
+
+      includedIn.external = {};
+      assert.deepEqual(element._computeGroups(includedIn, filterText), [
+        {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+        {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+      ]);
+
+      includedIn.external.foo = ['abc', 'def', 'ghi'];
+      assert.deepEqual(element._computeGroups(includedIn, filterText), [
+        {title: 'Branches', items: ['master', 'development', 'stable-2.0']},
+        {title: 'Tags', items: ['v1.9', 'v2.0', 'v2.1']},
+        {title: 'foo', items: ['abc', 'def', 'ghi']},
+      ]);
+
+      filterText = 'v2';
+      assert.deepEqual(element._computeGroups(includedIn, filterText), [
+        {title: 'Tags', items: ['v2.0', 'v2.1']},
+      ]);
+
+      // Filtering is case-insensitive.
+      filterText = 'V2';
+      assert.deepEqual(element._computeGroups(includedIn, filterText), [
+        {title: 'Tags', items: ['v2.0', 'v2.1']},
+      ]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
new file mode 100644
index 0000000..6496091
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.html
@@ -0,0 +1,123 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-label-score-row">
+  <template>
+    <style include="shared-styles">
+      .labelContainer {
+        margin-bottom: .5em;
+      }
+      .labelContainer:last-child {
+        margin-bottom: 0;
+      }
+      .labelName {
+        display: inline-block;
+        margin-right: .5em;
+        min-width: 7em;
+        text-align: right;
+        white-space: nowrap;
+        width: 25%;
+      }
+      .labelMessage {
+        color: #666;
+      }
+      .placeholder::before {
+        content: ' ';
+      }
+      .selectedValueText {
+        color: #666;
+        font-style: italic;
+        margin-bottom: .5em;
+        margin-left: calc(25% + .5em);
+      }
+      .selectedValueText.hidden {
+        display: none;
+      }
+      gr-button {
+        min-width: 40px;
+        --gr-button: {
+          border: 1px solid #d1d2d3;
+          border-radius: 12px;
+          box-shadow: none;
+          padding: .2em .85em;
+        }
+        --gr-button-background: #f5f5f5;
+        --gr-button-color: black;
+        --gr-button-hover-color: black;
+
+      }
+      iron-selector > gr-button.iron-selected {
+        --gr-button-background:#ddd;
+        --gr-button-color: black;
+        --gr-button-hover-background-color: #ddd;
+        --gr-button-hover-color: black;
+      }
+      .placeholder {
+        display: inline-block;
+        width: 40px;
+      }
+      @media only screen and (max-width: 25em) {
+        .labelName {
+          margin: 0;
+          text-align: center;
+          width: 100%;
+        }
+        .selectedValueText {
+          display: none;
+        }
+      }
+    </style>
+    <div class="labelContainer">
+      <span class="labelName">[[label.name]]</span>
+      <template is="dom-repeat"
+          items="[[_computeBlankItems(permittedLabels, label.name, 'start')]]"
+          as="value">
+        <span class="placeholder" data-label$="[[label.name]]"></span>
+      </template>
+      <iron-selector
+          attr-for-selected="value"
+          selected="[[_computeLabelValue(labels, permittedLabels, label)]]"
+          hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]"
+          on-selected-item-changed="_setSelectedValueText">
+        <template is="dom-repeat"
+            items="[[_computePermittedLabelValues(permittedLabels, label.name)]]"
+            as="value">
+          <gr-button has-tooltip value$="[[value]]"
+            title$="[[_computeLabelValueTitle(labels, label.name, value)]]">
+          [[value]]</gr-button>
+        </template>
+      </iron-selector>
+      <template is="dom-repeat"
+          items="[[_computeBlankItems(permittedLabels, label.name, 'end')]]"
+          as="value">
+        <span class="placeholder" data-label$="[[label.name]]"></span>
+      </template>
+      <span class="labelMessage"
+          hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
+        You don't have permission to edit this label.
+      </span>
+      <div class$="selectedValueText [[_computeHiddenClass(permittedLabels, label.name)]]">
+        <span id="selectedValueLabel">[[_selectedValueText]]</span>
+      </div>
+    </div>
+  </template>
+  <script src="gr-label-score-row.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
new file mode 100644
index 0000000..82664f8
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row.js
@@ -0,0 +1,131 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-label-score-row',
+
+    /**
+     * Fired when any label is changed.
+     *
+     * @event labels-changed
+     */
+
+    properties: {
+      /**
+       * @type {{ name: string }}
+       */
+      label: Object,
+      labels: Object,
+      name: {
+        type: String,
+        reflectToAttribute: true,
+      },
+      permittedLabels: Object,
+      labelValues: Object,
+      _selectedValueText: {
+        type: String,
+        value: 'No value selected',
+      },
+    },
+
+    get selectedItem() {
+      if (!this._ironSelector) { return; }
+      return this._ironSelector.selectedItem;
+    },
+
+    get selectedValue() {
+      if (!this._ironSelector) { return; }
+      return this._ironSelector.selected;
+    },
+
+    setSelectedValue(value) {
+      // The selector may not be present if it’s not at the latest patch set.
+      if (!this._ironSelector) { return; }
+      this._ironSelector.select(value);
+    },
+
+    get _ironSelector() {
+      return this.$$('iron-selector');
+    },
+
+    _computeBlankItems(permittedLabels, label, side) {
+      if (!permittedLabels || !permittedLabels[label] || !this.labelValues ||
+          !Object.keys(this.labelValues).length) {
+        return [];
+      }
+      const startPosition = this.labelValues[parseInt(
+          permittedLabels[label][0], 10)];
+      if (side === 'start') {
+        return new Array(startPosition);
+      }
+      const endPosition = this.labelValues[parseInt(
+          permittedLabels[label][permittedLabels[label].length - 1], 10)];
+      return new Array(Object.keys(this.labelValues).length - endPosition - 1);
+    },
+
+    _getLabelValue(labels, permittedLabels, label) {
+      if (label.value) {
+        return label.value;
+      } else if (labels[label.name].hasOwnProperty('default_value') &&
+                 permittedLabels.hasOwnProperty(label.name)) {
+        // default_value is an int, convert it to string label, e.g. "+1".
+        return permittedLabels[label.name].find(
+            value => parseInt(value, 10) === labels[label.name].default_value);
+      }
+    },
+
+    _computeLabelValue(labels, permittedLabels, label) {
+      if (!labels[label.name]) { return null; }
+      const labelValue = this._getLabelValue(labels, permittedLabels, label);
+      const len = permittedLabels[label.name] != null ?
+          permittedLabels[label.name].length : 0;
+      for (let i = 0; i < len; i++) {
+        const val = permittedLabels[label.name][i];
+        if (val === labelValue) {
+          return val;
+        }
+      }
+      return null;
+    },
+
+    _setSelectedValueText(e) {
+      // Needed because when the selected item changes, it first changes to
+      // nothing and then to the new item.
+      if (!e.target.selectedItem) { return; }
+      this._selectedValueText = e.target.selectedItem.getAttribute('title');
+      // Needed to update the style of the selected button.
+      this.updateStyles();
+      this.fire('labels-changed');
+    },
+
+    _computeAnyPermittedLabelValues(permittedLabels, label) {
+      return permittedLabels.hasOwnProperty(label);
+    },
+
+    _computeHiddenClass(permittedLabels, label) {
+      return !this._computeAnyPermittedLabelValues(permittedLabels, label) ?
+          'hidden' : '';
+    },
+
+    _computePermittedLabelValues(permittedLabels, label) {
+      return permittedLabels[label];
+    },
+
+    _computeLabelValueTitle(labels, label, value) {
+      return labels[label] && labels[label].values[value];
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
new file mode 100644
index 0000000..891afbc
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-score-row/gr-label-score-row_test.html
@@ -0,0 +1,323 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-label-score-row</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-label-score-row.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-label-score-row></gr-label-score-row>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-label-row-score tests', () => {
+    let element;
+    let sandbox;
+
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.labels = {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+          value: 1,
+          all: [{
+            _account_id: 123,
+            value: 1,
+          }],
+        },
+        'Verified': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+          value: 1,
+          all: [{
+            _account_id: 123,
+            value: 1,
+          }],
+        },
+      };
+
+      element.permittedLabels = {
+        'Code-Review': [
+          '-2',
+          '-1',
+          ' 0',
+          '+1',
+          '+2',
+        ],
+        'Verified': [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+
+      element.labelValues = {'0': 2, '1': 3, '2': 4, '-2': 0, '-1': 1};
+
+      element.label = {
+        name: 'Verified',
+        value: '+1',
+      };
+
+      flush(done);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('label picker', () => {
+      const labelsChangedHandler = sandbox.stub();
+      element.addEventListener('labels-changed', labelsChangedHandler);
+      assert.ok(element.$$('iron-selector'));
+      MockInteractions.tap(element.$$(
+          'gr-button[value="-1"]'));
+      flushAsynchronousOperations();
+      assert.strictEqual(element.selectedValue, '-1');
+      assert.strictEqual(element.selectedItem
+          .textContent.trim(), '-1');
+      assert.strictEqual(
+          element.$.selectedValueLabel.textContent.trim(), 'bad');
+      assert.isTrue(labelsChangedHandler.called);
+    });
+
+    test('correct item is selected', () => {
+      // 1 should be the value of the selected item
+      assert.strictEqual(element.$$('iron-selector').selected, '+1');
+      assert.strictEqual(
+          element.$$('iron-selector').selectedItem
+              .textContent.trim(), '+1');
+      assert.strictEqual(
+          element.$.selectedValueLabel.textContent.trim(), 'good');
+    });
+
+    test('do not display tooltips on touch devices', () => {
+      const verifiedBtn = element.$$(
+          'iron-selector > gr-button[value="-1"]');
+
+      // On touch devices, tooltips should not be shown.
+      verifiedBtn._isTouchDevice = true;
+      verifiedBtn._handleShowTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+      verifiedBtn._handleHideTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+
+      // On other devices, tooltips should be shown.
+      verifiedBtn._isTouchDevice = false;
+      verifiedBtn._handleShowTooltip();
+      assert.isOk(verifiedBtn._tooltip);
+      verifiedBtn._handleHideTooltip();
+      assert.isNotOk(verifiedBtn._tooltip);
+    });
+
+    test('_computeLabelValue', () => {
+      assert.strictEqual(element._computeLabelValue(element.labels,
+          element.permittedLabels,
+          element.label), '+1');
+    });
+
+    test('_computeBlankItems', () => {
+      element.labelValues = {
+        '-2': 0,
+        '-1': 1,
+        '0': 2,
+        '1': 3,
+        '2': 4,
+      };
+
+      assert.strictEqual(element._computeBlankItems(element.permittedLabels,
+          'Code-Review').length, 0);
+
+      assert.strictEqual(element._computeBlankItems(element.permittedLabels,
+          'Verified').length, 1);
+    });
+
+    test('labelValues returns no keys', () => {
+      element.labelValues = {};
+
+      assert.deepEqual(element._computeBlankItems(element.permittedLabels,
+          'Code-Review'), []);
+    });
+
+    test('changes in label score are reflected in the DOM', () => {
+      element.labels = {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        },
+        'Verified': {
+          values: {
+            ' 0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: 0,
+        },
+      };
+      const selector = element.$$('iron-selector');
+      element.set('label', {name: 'Verified', value: ' 0'});
+      flushAsynchronousOperations();
+      assert.strictEqual(selector.selected, ' 0');
+      assert.strictEqual(
+          element.$.selectedValueLabel.textContent.trim(), 'No score');
+    });
+
+    test('without permitted labels', () => {
+      element.permittedLabels = {
+        Verified: [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('iron-selector'));
+      assert.isFalse(element.$$('iron-selector').hidden);
+
+      element.permittedLabels = {};
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('iron-selector'));
+      assert.isTrue(element.$$('iron-selector').hidden);
+    });
+
+    test('asymetrical labels', () => {
+      element.permittedLabels = {
+        'Code-Review': [
+          '-2',
+          '-1',
+          ' 0',
+          '+1',
+          '+2',
+        ],
+        'Verified': [
+          ' 0',
+          '+1',
+        ],
+      };
+      flushAsynchronousOperations();
+      assert.strictEqual(element.$$('iron-selector')
+          .items.length, 2);
+      assert.strictEqual(Polymer.dom(element.root).
+          querySelectorAll('.placeholder').length, 3);
+
+      element.permittedLabels = {
+        'Code-Review': [
+          ' 0',
+          '+1',
+        ],
+        'Verified': [
+          '-2',
+          '-1',
+          ' 0',
+          '+1',
+          '+2',
+        ],
+      };
+      flushAsynchronousOperations();
+      assert.strictEqual(element.$$('iron-selector')
+          .items.length, 5);
+      assert.strictEqual(Polymer.dom(element.root).
+          querySelectorAll('.placeholder').length, 0);
+    });
+
+    test('default_value', () => {
+      element.permittedLabels = {
+        Verified: [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+      element.labels = {
+        Verified: {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: -1,
+        },
+      };
+      element.label = {
+        name: 'Verified',
+        value: null,
+      };
+      flushAsynchronousOperations();
+      assert.strictEqual(element.selectedValue, '-1');
+    });
+
+    test('default_value is null if not permitted', () => {
+      element.permittedLabels = {
+        Verified: [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+      element.labels = {
+        'Code-Review': {
+          values: {
+            '0': 'No score',
+            '+1': 'good',
+            '+2': 'excellent',
+            '-1': 'bad',
+            '-2': 'terrible',
+          },
+          default_value: -1,
+        },
+      };
+      element.label = {
+        name: 'Code-Review',
+        value: null,
+      };
+      flushAsynchronousOperations();
+      assert.isNull(element.selectedValue);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
new file mode 100644
index 0000000..2532c77
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.html
@@ -0,0 +1,50 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../gr-label-score-row/gr-label-score-row.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-label-scores">
+  <template>
+    <style include="shared-styles">
+      .mergedMessage {
+        font-style: italic;
+        text-align: center;
+        width: 100%;
+      }
+      @media only screen and (max-width: 25em) {
+        :host {
+          text-align: center;
+        }
+      }
+    </style>
+    <template is="dom-repeat" items="[[_labels]]" as="label">
+      <gr-label-score-row
+          label="[[label]]"
+          name="[[label.name]]"
+          labels="[[change.labels]]"
+          permitted-labels="[[permittedLabels]]"
+          label-values="[[_labelValues]]"></gr-label-score-row>
+    </template>
+    <div class="mergedMessage"
+        hidden$="[[!_changeIsMerged(change.status)]]">
+      Because this change has been merged, votes may not be decreased.
+    </div>
+  </template>
+  <script src="gr-label-scores.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
new file mode 100644
index 0000000..b3642a5
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores.js
@@ -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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-label-scores',
+    properties: {
+      _labels: {
+        type: Array,
+        computed: '_computeLabels(change.labels.*, account)',
+      },
+      permittedLabels: {
+        type: Object,
+        observer: '_computeColumns',
+      },
+      /** @type {?} */
+      change: Object,
+      _labelValues: Object,
+    },
+
+    getLabelValues() {
+      const labels = {};
+      for (const label in this.permittedLabels) {
+        if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
+
+        const selectorEl = this.$$(`gr-label-score-row[name="${label}"]`);
+        if (!selectorEl) { continue; }
+
+        // The user may have not voted on this label.
+        if (!selectorEl.selectedItem) { continue; }
+
+        const selectedVal = parseInt(selectorEl.selectedValue, 10);
+
+        // Only send the selection if the user changed it.
+        let prevVal = this._getVoteForAccount(this.change.labels, label,
+            this.account);
+        if (prevVal !== null) {
+          prevVal = parseInt(prevVal, 10);
+        }
+        if (selectedVal !== prevVal) {
+          labels[label] = selectedVal;
+        }
+      }
+      return labels;
+    },
+
+    _getStringLabelValue(labels, labelName, numberValue) {
+      for (const k in labels[labelName].values) {
+        if (parseInt(k, 10) === numberValue) {
+          return k;
+        }
+      }
+      return numberValue;
+    },
+
+    _getVoteForAccount(labels, labelName, account) {
+      const votes = labels[labelName];
+      if (votes.all && votes.all.length > 0) {
+        for (let i = 0; i < votes.all.length; i++) {
+          if (votes.all[i]._account_id == account._account_id) {
+            return this._getStringLabelValue(
+                labels, labelName, votes.all[i].value);
+          }
+        }
+      }
+      return null;
+    },
+
+    _computeLabels(labelRecord) {
+      const labelsObj = labelRecord.base;
+      if (!labelsObj) { return []; }
+      return Object.keys(labelsObj).sort().map(key => {
+        return {
+          name: key,
+          value: this._getVoteForAccount(labelsObj, key, this.account),
+        };
+      });
+    },
+
+    _computeColumns(permittedLabels) {
+      const labels = Object.keys(permittedLabels);
+      const values = {};
+      for (const label of labels) {
+        for (const value of permittedLabels[label]) {
+          values[parseInt(value, 10)] = true;
+        }
+      }
+
+      const orderedValues = Object.keys(values).sort((a, b) => {
+        return a - b;
+      });
+
+      for (let i = 0; i < orderedValues.length; i++) {
+        values[orderedValues[i]] = i;
+      }
+      this._labelValues = values;
+    },
+
+    _changeIsMerged(changeStatus) {
+      return changeStatus === 'MERGED';
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
new file mode 100644
index 0000000..e3a0d8c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-label-scores/gr-label-scores_test.html
@@ -0,0 +1,175 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-label-scores</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-label-scores.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-label-scores></gr-label-scores>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-label-scores tests', () => {
+    let element;
+    let sandbox;
+
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-rest-api-interface', {
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+      element = fixture('basic');
+      element.change = {
+        _number: '123',
+        labels: {
+          'Code-Review': {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+            value: 1,
+            all: [{
+              _account_id: 123,
+              value: 1,
+            }],
+          },
+          'Verified': {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+            value: 1,
+            all: [{
+              _account_id: 123,
+              value: 1,
+            }],
+          },
+        },
+      };
+
+      element.account = {
+        _account_id: 123,
+      };
+
+      element.permittedLabels = {
+        'Code-Review': [
+          '-2',
+          '-1',
+          ' 0',
+          '+1',
+          '+2',
+        ],
+        'Verified': [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+      flush(done);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('get and set label scores', () => {
+      for (const label in element.permittedLabels) {
+        if (element.permittedLabels.hasOwnProperty(label)) {
+          const row = element.$$('gr-label-score-row[name="' + label + '"]');
+          row.setSelectedValue(-1);
+        }
+      }
+      assert.deepEqual(element.getLabelValues(), {
+        'Code-Review': -1,
+        'Verified': -1,
+      });
+    });
+
+    test('_getVoteForAccount', () => {
+      const labelName = 'Code-Review';
+      assert.strictEqual(element._getVoteForAccount(
+          element.change.labels, labelName, element.account),
+          '+1');
+    });
+
+    test('_computeColumns', () => {
+      element._computeColumns(element.permittedLabels);
+      assert.deepEqual(element._labelValues, {
+        '-2': 0,
+        '-1': 1,
+        '0': 2,
+        '1': 3,
+        '2': 4,
+      });
+    });
+
+    test('changes in label score are reflected in _labels', () => {
+      element.change = {
+        _number: '123',
+        labels: {
+          'Code-Review': {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+          },
+          'Verified': {
+            values: {
+              '0': 'No score',
+              '+1': 'good',
+              '+2': 'excellent',
+              '-1': 'bad',
+              '-2': 'terrible',
+            },
+            default_value: 0,
+          },
+        },
+      };
+      assert.deepEqual(element._labels [
+          {name: 'Code-Review', value: null},
+          {name: 'Verified', value: null}
+      ]);
+      element.set(['change', 'labels', 'Verified', 'all'],
+         [{_account_id: 123, value: 1}]);
+      assert.deepEqual(element._labels, [
+          {name: 'Code-Review', value: null},
+          {name: 'Verified', value: '+1'},
+      ]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 831914e..e04eeb7 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -15,19 +15,20 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-account-label/gr-account-label.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <link rel="import" href="../gr-comment-list/gr-comment-list.html">
 
 <dom-module id="gr-message">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
-        border-top: 1px solid #ddd;
+        border-bottom: 1px solid #ddd;
         display: block;
         position: relative;
         cursor: pointer;
@@ -35,6 +36,9 @@
       :host(.expanded) {
         cursor: auto;
       }
+      :host > div {
+        padding: 0 var(--default-horizontal-margin);
+      }
       gr-avatar {
         position: absolute;
         left: var(--default-horizontal-margin);
@@ -45,7 +49,7 @@
         display: flex;
         white-space: nowrap;
       }
-      .showAvatar.expanded .contentContainer {
+      .contentContainer {
         margin-left: calc(var(--default-horizontal-margin) + 2.5em);
         padding: 10px 0;
       }
@@ -72,17 +76,17 @@
         width: 2.5em;
       }
       .name {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .message {
-        max-width: 80ch;
+        --gr-formatted-text-prose-max-width: 80ch;
       }
       .collapsed .message {
         max-width: none;
         overflow: hidden;
         text-overflow: ellipsis;
       }
-      .collapsed .name,
+      .collapsed .author,
       .collapsed .content,
       .collapsed .message,
       .collapsed .updateCategory,
@@ -108,11 +112,11 @@
       .collapsed .date {
         position: static;
       }
-      .collapsed .name {
+      .collapsed .author {
         color: var(--default-text-color);
         margin-right: .4em;
       }
-      .expanded .name {
+      .expanded .author {
         cursor: pointer;
       }
       .date {
@@ -122,25 +126,49 @@
         top: 10px;
       }
       .replyContainer {
-        padding: .5em 0 1em;
+        padding: .5em 0 0 0;
+      }
+      .positiveVote {
+        box-shadow: inset 0 3.8em #d4ffd4;
+      }
+      .negativeVote {
+        box-shadow: inset 0 3.8em #ffd4d4;
+      }
+      gr-account-label {
+        --gr-account-label-text-style: {
+          font-family: var(--font-family-bold);
+        };
       }
     </style>
-    <div class$="[[_computeClass(_expanded, showAvatar)]]">
+    <div class$="[[_computeClass(_expanded, showAvatar, message)]]">
       <gr-avatar account="[[author]]" image-size="100"></gr-avatar>
       <div class="contentContainer">
-        <div class="name" on-tap="_handleNameTap">[[author.name]]</div>
+        <div class="author" on-tap="_handleAuthorTap">
+          <span hidden$="[[!showOnBehalfOf]]">
+            <span class="name">[[message.real_author.name]]</span>
+            on behalf of
+          </span>
+          <gr-account-label
+              account="[[author]]"
+              hide-avatar></gr-account-label>
+        </div>
         <template is="dom-if" if="[[message.message]]">
           <div class="content">
             <div class="message hideOnOpen">[[message.message]]</div>
             <gr-formatted-text
+                no-trailing-margin
                 class="message hideOnCollapsed"
                 content="[[message.message]]"
-                config="[[projectConfig.commentlinks]]"></gr-formatted-text>
+                config="[[_commentLinks]]"></gr-formatted-text>
+            <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
+              <gr-button link small on-tap="_handleReplyTap">Reply</gr-button>
+            </div>
             <gr-comment-list
                 comments="[[comments]]"
                 change-num="[[changeNum]]"
                 patch-num="[[message._revision_number]]"
-                project-config="[[projectConfig]]"></gr-comment-list>
+                project-name="[[projectName]]"
+                comment-links="[[_commentLinks]]"></gr-comment-list>
           </div>
         </template>
         <template is="dom-if" if="[[_computeIsReviewerUpdate(message)]]">
@@ -172,9 +200,6 @@
           </a>
         </template>
       </div>
-      <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
-        <gr-button small on-tap="_handleReplyTap">Reply</gr-button>
-      </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index e782943..d907f3b 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -14,6 +14,10 @@
 (function() {
   'use strict';
 
+  const CI_LABELS = ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'];
+  const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+: /;
+  const LABEL_TITLE_SCORE_PATTERN = /([A-Za-z0-9-]+)([+-]\d+)/;
+
   Polymer({
     is: 'gr-message',
 
@@ -30,11 +34,12 @@
      */
 
     listeners: {
-      'tap': '_handleTap',
+      tap: '_handleTap',
     },
 
     properties: {
       changeNum: Number,
+      /** @type {?} */
       message: Object,
       author: {
         type: Object,
@@ -62,10 +67,22 @@
         type: Boolean,
         computed: '_computeShowAvatar(author, config)',
       },
+      showOnBehalfOf: {
+        type: Boolean,
+        computed: '_computeShowOnBehalfOf(message)',
+      },
       showReplyButton: {
         type: Boolean,
         computed: '_computeShowReplyButton(message, _loggedIn)',
       },
+      projectName: {
+        type: String,
+        observer: '_projectNameChanged',
+      },
+      _commentLinks: Object,
+      /**
+       * @type {{ commentlinks: Array }}
+       */
       projectConfig: Object,
       // Computed property needed to trigger Polymer value observing.
       _expanded: {
@@ -82,16 +99,16 @@
       '_updateExpandedClass(message.expanded)',
     ],
 
-    ready: function() {
-      this.$.restAPI.getConfig().then(function(config) {
+    ready() {
+      this.$.restAPI.getConfig().then(config => {
         this.config = config;
-      }.bind(this));
-      this.$.restAPI.getLoggedIn().then(function(loggedIn) {
+      });
+      this.$.restAPI.getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
-      }.bind(this));
+      });
     },
 
-    _updateExpandedClass: function(expanded) {
+    _updateExpandedClass(expanded) {
       if (expanded) {
         this.classList.add('expanded');
       } else {
@@ -99,19 +116,26 @@
       }
     },
 
-    _computeAuthor: function(message) {
+    _computeAuthor(message) {
       return message.author || message.updated_by;
     },
 
-    _computeShowAvatar: function(author, config) {
+    _computeShowAvatar(author, config) {
       return !!(author && config && config.plugin && config.plugin.has_avatars);
     },
 
-    _computeShowReplyButton: function(message, loggedIn) {
-      return !!message.message && loggedIn;
+    _computeShowOnBehalfOf(message) {
+      const author = message.author || message.updated_by;
+      return !!(author && message.real_author &&
+          author._account_id != message.real_author._account_id);
     },
 
-    _computeExpanded: function(expanded) {
+    _computeShowReplyButton(message, loggedIn) {
+      return !!message.message && loggedIn &&
+          !this._computeIsAutomated(message);
+    },
+
+    _computeExpanded(expanded) {
       return expanded;
     },
 
@@ -120,54 +144,86 @@
      * should be true or not, then _expanded is set to true if there are
      * inline comments (otherwise false).
      */
-    _commentsChanged: function(value) {
+    _commentsChanged(value) {
       if (this.message && this.message.expanded === undefined) {
         this.set('message.expanded', Object.keys(value || {}).length > 0);
       }
     },
 
-    _handleTap: function(e) {
+    _handleTap(e) {
       if (this.message.expanded) { return; }
       e.stopPropagation();
       this.set('message.expanded', true);
     },
 
-    _handleNameTap: function(e) {
+    _handleAuthorTap(e) {
       if (!this.message.expanded) { return; }
       e.stopPropagation();
       this.set('message.expanded', false);
     },
 
-    _computeIsAutomated: function(message) {
+    _computeIsAutomated(message) {
       return !!(message.reviewer ||
-          (message.tag && message.tag.indexOf('autogenerated') === 0));
+          this._computeIsReviewerUpdate(message) ||
+          (message.tag && message.tag.startsWith('autogenerated')));
     },
 
-    _computeIsHidden: function(hideAutomated, isAutomated) {
+    _computeIsHidden(hideAutomated, isAutomated) {
       return hideAutomated && isAutomated;
     },
 
-    _computeIsReviewerUpdate: function(event) {
+    _computeIsReviewerUpdate(event) {
       return event.type === 'REVIEWER_UPDATE';
     },
 
-    _computeClass: function(expanded, showAvatar) {
-      var classes = [];
+    _isMessagePositive(message) {
+      if (!message.message) { return null; }
+      const line = message.message.split('\n', 1)[0];
+      const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
+      if (!line.match(patchSetPrefix)) { return null; }
+      const scoresRaw = line.split(patchSetPrefix)[1];
+      if (!scoresRaw) { return null; }
+      const scores = scoresRaw.split(' ');
+      if (!scores.length) { return null; }
+      const {min, max} = scores
+          .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
+          .filter(ms => ms && ms.length === 3)
+          .filter(([, label]) => !CI_LABELS.includes(label))
+          .map(([, , score]) => score)
+          .map(s => parseInt(s, 10))
+          .reduce(({min, max}, s) =>
+              ({min: (s < min ? s : min), max: (s > max ? s : max)}),
+              {min: 0, max: 0});
+      if (max - min === 0) {
+        return 0;
+      } else {
+        return (max + min) > 0 ? 1 : -1;
+      }
+    },
+
+    _computeClass(expanded, showAvatar, message) {
+      const classes = [];
       classes.push(expanded ? 'expanded' : 'collapsed');
       classes.push(showAvatar ? 'showAvatar' : 'hideAvatar');
+      const scoreQuality = this._isMessagePositive(message);
+      if (scoreQuality === 1) {
+        classes.push('positiveVote');
+      } else if (scoreQuality === -1) {
+        classes.push('negativeVote');
+      }
       return classes.join(' ');
     },
 
-    _computeMessageHash: function(message) {
+    _computeMessageHash(message) {
       return '#message-' + message.id;
     },
 
-    _handleLinkTap: function(e) {
+    _handleLinkTap(e) {
       e.preventDefault();
 
       this.fire('scroll-to', {message: this.message}, {bubbles: false});
 
-      var hash = this._computeMessageHash(this.message);
+      const hash = this._computeMessageHash(this.message);
       // Don't add the hash to the window history if it's already there.
       // Otherwise you mess up expected back button behavior.
       if (window.location.hash == hash) { return; }
@@ -176,9 +232,15 @@
       page.show(window.location.pathname + hash, null, false);
     },
 
-    _handleReplyTap: function(e) {
+    _handleReplyTap(e) {
       e.preventDefault();
       this.fire('reply', {message: this.message});
     },
+
+    _projectNameChanged(name) {
+      this.$.restAPI.getProjectConfig(name).then(config => {
+        this._commentLinks = config.commentlinks;
+      });
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index 89c7173..aa18c4e 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-message.html">
 
 <script>void(0);</script>
@@ -33,30 +32,32 @@
 </test-fixture>
 
 <script>
-  suite('gr-message tests', function() {
-    var element;
+  suite('gr-message tests', () => {
+    let element;
 
-    setup(function() {
+    setup(done => {
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
+      flush(done);
     });
 
-    test('reply event', function(done) {
+    test('reply event', done => {
       element.message = {
-        'id': '47c43261_55aa2c41',
-        'author': {
-          '_account_id': 1115495,
-          'name': 'Andrew Bonventre',
-          'email': 'andybons@chromium.org',
+        id: '47c43261_55aa2c41',
+        author: {
+          _account_id: 1115495,
+          name: 'Andrew Bonventre',
+          email: 'andybons@chromium.org',
         },
-        'date': '2016-01-12 20:24:49.448000000',
-        'message': 'Uploaded patch set 1.',
-        '_revision_number': 1
+        date: '2016-01-12 20:24:49.448000000',
+        message: 'Uploaded patch set 1.',
+        _revision_number: 1,
       };
 
-      element.addEventListener('reply', function(e) {
+      element.addEventListener('reply', e => {
         assert.deepEqual(e.detail.message, element.message);
         done();
       });
@@ -64,38 +65,7 @@
       MockInteractions.tap(element.$$('.replyContainer gr-button'));
     });
 
-    test('reviewer update', function() {
-      var author = {
-        _account_id: 1115495,
-        name: 'Andrew Bonventre',
-        email: 'andybons@chromium.org',
-      };
-      var reviewer = {
-        _account_id: 123456,
-        name: 'Foo Bar',
-        email: 'barbar@chromium.org',
-      };
-      element.message = {
-        id: 0xDEADBEEF,
-        author: author,
-        reviewer: reviewer,
-        date: '2016-01-12 20:24:49.448000000',
-        type: 'REVIEWER_UPDATE',
-        updates: [
-          {
-            message: 'Added to CC:',
-            reviewers: [reviewer],
-          }
-        ],
-      };
-      flushAsynchronousOperations();
-      var content = element.$$('.contentContainer');
-      assert.isOk(content);
-      assert.strictEqual(element.$$('gr-account-chip').account, reviewer);
-      assert.equal(author.name, element.$$('.name').textContent);
-    });
-
-    test('autogenerated prefix hiding', function() {
+    test('autogenerated prefix hiding', () => {
       element.message = {
         tag: 'autogenerated:gerrit:test',
         updated: '2016-01-12 20:24:49.448000000',
@@ -109,7 +79,7 @@
       assert.isTrue(element.hidden);
     });
 
-    test('reviewer message treated as autogenerated', function() {
+    test('reviewer message treated as autogenerated', () => {
       element.message = {
         tag: 'autogenerated:gerrit:test',
         updated: '2016-01-12 20:24:49.448000000',
@@ -124,7 +94,22 @@
       assert.isTrue(element.hidden);
     });
 
-    test('tag that is not autogenerated prefix does not hide', function() {
+    test('batch reviewer message treated as autogenerated', () => {
+      element.message = {
+        type: 'REVIEWER_UPDATE',
+        updated: '2016-01-12 20:24:49.448000000',
+        reviewer: {},
+      };
+
+      assert.isTrue(element.isAutomated);
+      assert.isFalse(element.hidden);
+
+      element.hideAutomated = true;
+
+      assert.isTrue(element.hidden);
+    });
+
+    test('tag that is not autogenerated prefix does not hide', () => {
       element.message = {
         tag: 'something',
         updated: '2016-01-12 20:24:49.448000000',
@@ -138,12 +123,62 @@
       assert.isFalse(element.hidden);
     });
 
-    test('reply button hidden unless logged in', function() {
-      var message = {
-        'message': 'Uploaded patch set 1.',
+    test('reply button hidden unless logged in', () => {
+      const message = {
+        message: 'Uploaded patch set 1.',
       };
       assert.isFalse(element._computeShowReplyButton(message, false));
       assert.isTrue(element._computeShowReplyButton(message, true));
     });
+
+    test('_computeShowOnBehalfOf', () => {
+      const message = {
+        message: '...',
+      };
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author = {_account_id: 1115495};
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+      message.real_author._account_id = 123456;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      message.updated_by = message.author;
+      delete message.author;
+      assert.isOk(element._computeShowOnBehalfOf(message));
+      delete message.updated_by;
+      assert.isNotOk(element._computeShowOnBehalfOf(message));
+    });
+
+    ['Trybot-Ready', 'Tryjob-Request', 'Commit-Queue'].forEach(label => {
+      test(`${label} ignored for color voting`, () => {
+        element.message = {
+          author: {},
+          expanded: false,
+          message: `Patch Set 1: ${label}+1`,
+        };
+        assert.isNotOk(
+            Polymer.dom(element.root).querySelector('.negativeVote'));
+        assert.isNotOk(
+            Polymer.dom(element.root).querySelector('.positiveVote'));
+      });
+    });
+
+    test('negative vote', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Ready+1',
+      };
+      assert.isOk(Polymer.dom(element.root).querySelector('.negativeVote'));
+    });
+
+    test('positive vote', () => {
+      element.message = {
+        author: {},
+        expanded: false,
+        message: 'Patch Set 1: Verified-1 Code-Review+2 Trybot-Ready-1',
+      };
+      assert.isOk(Polymer.dom(element.root).querySelector('.positiveVote'));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
index 14361f4..ab494b4 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.html
@@ -18,22 +18,26 @@
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../gr-message/gr-message.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-messages-list">
   <template>
-    <style>
+    <style include="shared-styles">
       :host,
       .messageListControls {
         display: block;
       }
       .header {
+        align-items: center;
+        background-color: #fafafa;
+        border-bottom: 1px solid #ddd;
+        border-top: 1px solid #ddd;
         display: flex;
         justify-content: space-between;
-        margin-bottom: .35em;
+        min-height: 3.2em;
+        padding: .5em var(--default-horizontal-margin);
       }
-      .header,
-      #messageControlsContainer,
-      gr-message {
+      #messageControlsContainer {
         padding: 0 var(--default-horizontal-margin);
       }
       .highlighted {
@@ -45,25 +49,40 @@
       }
       #messageControlsContainer {
         align-items: center;
-        background-color: #fef;
+        border-bottom: 1px solid #ddd;
         display: flex;
+        height: 2.25em;
         justify-content: center;
       }
       #messageControlsContainer gr-button {
-        padding: 0.4em;
+        padding: 0.4em 0;
+      }
+      .separator {
+        background-color: rgba(0, 0, 0, .3);
+        height: 1.5em;
+        margin: 0 .6em;
+        width: 1px;
+      }
+      .separator.transparent {
+        background-color: transparent;
+      }
+      .container {
+        align-items: center;
+        display: flex;
       }
     </style>
     <div class="header">
       <h3>Messages</h3>
-      <div class="messageListControls">
+      <div class="messageListControls container">
         <gr-button id="collapse-messages" link
             on-tap="_handleExpandCollapseTap">
           [[_computeExpandCollapseMessage(_expanded)]]
         </gr-button>
         <span
             id="automatedMessageToggleContainer"
+            class="container"
             hidden$="[[!_hasAutomatedMessages(messages)]]">
-          /
+          <span class="transparent separator"></span>
           <gr-button id="automatedMessageToggle" link
               on-tap="_handleAutomatedMessageToggleTap">
             [[_computeAutomatedToggleText(_hideAutomated)]]
@@ -77,11 +96,15 @@
       <gr-button id="oldMessagesBtn" link on-tap="_handleShowAllTap">
           [[_computeNumMessagesText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
       </gr-button>
-      /
-      <gr-button id="incrementMessagesBtn" link
-          on-tap="_handleIncrementShownMessages">
-        [[_computeIncrementText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
-      </gr-button>
+      <span
+          class="container"
+          hidden$="[[_computeIncrementHidden(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]">
+        <span class="transparent separator"></span>
+        <gr-button id="incrementMessagesBtn" link
+            on-tap="_handleIncrementShownMessages">
+          [[_computeIncrementText(_visibleMessages, _processedMessages, _hideAutomated, _visibleMessages.length)]]
+        </gr-button>
+      </span>
     </span>
     <template
         is="dom-repeat"
@@ -92,7 +115,7 @@
           message="[[message]]"
           comments="[[_computeCommentsForMessage(comments, message)]]"
           hide-automated="[[_hideAutomated]]"
-          project-config="[[projectConfig]]"
+          project-name="[[projectName]]"
           show-reply-button="[[showReplyButtons]]"
           on-scroll-to="_handleScrollTo"
           data-message-id$="[[message.id]]"></gr-message>
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
index 0d58d96..58e56a4 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list.js
@@ -14,10 +14,10 @@
 (function() {
   'use strict';
 
-  var MAX_INITIAL_SHOWN_MESSAGES = 20;
-  var MESSAGES_INCREMENT = 5;
+  const MAX_INITIAL_SHOWN_MESSAGES = 20;
+  const MESSAGES_INCREMENT = 5;
 
-  var ReportingEvent = {
+  const ReportingEvent = {
     SHOW_ALL: 'show-all-messages',
     SHOW_MORE: 'show-more-messages',
   };
@@ -29,14 +29,14 @@
       changeNum: Number,
       messages: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       reviewerUpdates: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       comments: Object,
-      projectConfig: Object,
+      projectName: String,
       showReplyButtons: {
         type: Boolean,
         value: false,
@@ -64,34 +64,35 @@
        */
       _visibleMessages: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
     },
 
-    scrollToMessage: function(messageID) {
-      var el = this.$$('[data-message-id="' + messageID + '"]');
+    scrollToMessage(messageID) {
+      let el = this.$$('[data-message-id="' + messageID + '"]');
       // If the message is hidden, expand the hidden messages back to that
       // point.
       if (!el) {
-        for (var index = 0; index < this._processedMessages.length; index++) {
+        let index;
+        for (index = 0; index < this._processedMessages.length; index++) {
           if (this._processedMessages[index].id === messageID) {
             break;
           }
         }
         if (index === this._processedMessages.length) { return; }
 
-        var newMessages = this._processedMessages.slice(index,
+        const newMessages = this._processedMessages.slice(index,
             -this._visibleMessages.length);
         // Add newMessages to the beginning of _visibleMessages.
-        this.splice.apply(this, ['_visibleMessages', 0, 0].concat(newMessages));
+        this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
         // Allow the dom-repeat to stamp.
         Polymer.dom.flush();
         el = this.$$('[data-message-id="' + messageID + '"]');
       }
 
       el.set('message.expanded', true);
-      var top = el.offsetTop;
-      for (var offsetParent = el.offsetParent;
+      let top = el.offsetTop;
+      for (let offsetParent = el.offsetParent;
            offsetParent;
            offsetParent = offsetParent.offsetParent) {
         top += offsetParent.offsetTop;
@@ -100,20 +101,20 @@
       this._highlightEl(el);
     },
 
-    _isAutomated: function(message) {
+    _isAutomated(message) {
       return !!(message.reviewer ||
-          (message.tag && message.tag.indexOf('autogenerated') === 0));
+          (message.tag && message.tag.startsWith('autogenerated')));
     },
 
-    _computeItems: function(messages, reviewerUpdates) {
+    _computeItems(messages, reviewerUpdates) {
       messages = messages || [];
       reviewerUpdates = reviewerUpdates || [];
-      var mi = 0;
-      var ri = 0;
-      var result = [];
-      var mDate;
-      var rDate;
-      for (var i = 0; i < messages.length; i++) {
+      let mi = 0;
+      let ri = 0;
+      let result = [];
+      let mDate;
+      let rDate;
+      for (let i = 0; i < messages.length; i++) {
         messages[i]._index = i;
       }
 
@@ -139,8 +140,8 @@
       return result;
     },
 
-    _expandedChanged: function(exp) {
-      for (var i = 0; i < this._processedMessages.length; i++) {
+    _expandedChanged(exp) {
+      for (let i = 0; i < this._processedMessages.length; i++) {
         this._processedMessages[i].expanded = exp;
         if (i < this._visibleMessages.length) {
           this.set(['_visibleMessages', i, 'expanded'], exp);
@@ -148,11 +149,11 @@
       }
     },
 
-    _highlightEl: function(el) {
-      var highlightedEls =
+    _highlightEl(el) {
+      const highlightedEls =
           Polymer.dom(this.root).querySelectorAll('.highlighted');
-      for (var i = 0; i < highlightedEls.length; i++) {
-        highlightedEls[i].classList.remove('highlighted');
+      for (const highlighedEl of highlightedEls) {
+        highlighedEl.classList.remove('highlighted');
       }
       function handleAnimationEnd() {
         el.removeEventListener('animationend', handleAnimationEnd);
@@ -165,39 +166,40 @@
     /**
      * @param {boolean} expand
      */
-    handleExpandCollapse: function(expand) {
+    handleExpandCollapse(expand) {
       this._expanded = expand;
     },
 
-    _handleExpandCollapseTap: function(e) {
+    _handleExpandCollapseTap(e) {
       e.preventDefault();
       this.handleExpandCollapse(!this._expanded);
     },
 
-    _handleAutomatedMessageToggleTap: function(e) {
+    _handleAutomatedMessageToggleTap(e) {
       e.preventDefault();
 
       this._hideAutomated = !this._hideAutomated;
     },
 
-    _handleScrollTo: function(e) {
+    _handleScrollTo(e) {
       this.scrollToMessage(e.detail.message.id);
     },
 
-    _hasAutomatedMessages: function(messages) {
-      for (var i = 0; messages && i < messages.length; i++) {
-        if (this._isAutomated(messages[i])) {
+    _hasAutomatedMessages(messages) {
+      if (!messages) { return false; }
+      for (const message of messages) {
+        if (this._isAutomated(message)) {
           return true;
         }
       }
       return false;
     },
 
-    _computeExpandCollapseMessage: function(expanded) {
+    _computeExpandCollapseMessage(expanded) {
       return expanded ? 'Collapse all' : 'Expand all';
     },
 
-    _computeAutomatedToggleText: function(hideAutomated) {
+    _computeAutomatedToggleText(hideAutomated) {
       return hideAutomated ? 'Show all messages' : 'Show comments only';
     },
 
@@ -209,18 +211,18 @@
      * @param {!Object} message
      * @return {!Object} Hash of arrays of comments, filename as key.
      */
-    _computeCommentsForMessage: function(comments, message) {
+    _computeCommentsForMessage(comments, message) {
       if (message._index === undefined || !comments || !this.messages) {
         return [];
       }
-      var messages = this.messages || [];
-      var index = message._index;
-      var authorId = message.author && message.author._account_id;
-      var mDate = util.parseDate(message.date).getTime();
+      const messages = this.messages || [];
+      const index = message._index;
+      const authorId = message.author && message.author._account_id;
+      const mDate = util.parseDate(message.date).getTime();
       // NB: Messages array has oldest messages first.
-      var nextMDate;
+      let nextMDate;
       if (index > 0) {
-        for (var i = index - 1; i >= 0; i--) {
+        for (let i = index - 1; i >= 0; i--) {
           if (messages[i] && messages[i].author &&
               messages[i].author._account_id === authorId) {
             nextMDate = util.parseDate(messages[i].date).getTime();
@@ -228,15 +230,16 @@
           }
         }
       }
-      var msgComments = {};
-      for (var file in comments) {
-        var fileComments = comments[file];
-        for (var i = 0; i < fileComments.length; i++) {
+      const msgComments = {};
+      for (const file in comments) {
+        if (!comments.hasOwnProperty(file)) { continue; }
+        const fileComments = comments[file];
+        for (let i = 0; i < fileComments.length; i++) {
           if (fileComments[i].author &&
               fileComments[i].author._account_id !== authorId) {
             continue;
           }
-          var cDate = util.parseDate(fileComments[i].updated).getTime();
+          const cDate = util.parseDate(fileComments[i].updated).getTime();
           if (cDate <= mDate) {
             if (nextMDate && cDate <= nextMDate) {
               continue;
@@ -255,12 +258,12 @@
      * remaining in the list and the number of messages needed to display five
      * more visible messages in the list.
      */
-    _getDelta: function(visibleMessages, messages, hideAutomated) {
-      var delta = MESSAGES_INCREMENT;
-      var msgsRemaining = messages.length - visibleMessages.length;
+    _getDelta(visibleMessages, messages, hideAutomated) {
+      let delta = MESSAGES_INCREMENT;
+      const msgsRemaining = messages.length - visibleMessages.length;
       if (hideAutomated) {
-        var counter = 0;
-        var i;
+        let counter = 0;
+        let i;
         for (i = msgsRemaining; i > 0 && counter < MESSAGES_INCREMENT; i--) {
           if (!this._isAutomated(messages[i - 1])) { counter++; }
         }
@@ -273,7 +276,7 @@
      * Gets the number of messages that would be visible, but do not currently
      * exist in _visibleMessages.
      */
-    _numRemaining: function(visibleMessages, messages, hideAutomated) {
+    _numRemaining(visibleMessages, messages, hideAutomated) {
       if (hideAutomated) {
         return this._getHumanMessages(messages).length -
             this._getHumanMessages(visibleMessages).length;
@@ -281,20 +284,20 @@
       return messages.length - visibleMessages.length;
     },
 
-    _computeIncrementText: function(visibleMessages, messages, hideAutomated) {
-      var delta = this._getDelta(visibleMessages, messages, hideAutomated);
+    _computeIncrementText(visibleMessages, messages, hideAutomated) {
+      let delta = this._getDelta(visibleMessages, messages, hideAutomated);
       delta = Math.min(
           this._numRemaining(visibleMessages, messages, hideAutomated), delta);
       return 'Show ' + Math.min(MESSAGES_INCREMENT, delta) + ' more';
     },
 
-    _getHumanMessages: function(messages) {
-      return messages.filter(function(msg) {
+    _getHumanMessages(messages) {
+      return messages.filter(msg => {
         return !this._isAutomated(msg);
-      }.bind(this));
+      });
     },
 
-    _computeShowHideTextHidden: function(visibleMessages, messages,
+    _computeShowHideTextHidden(visibleMessages, messages,
         hideAutomated) {
       if (hideAutomated) {
         messages = this._getHumanMessages(messages);
@@ -303,29 +306,37 @@
       return visibleMessages.length >= messages.length;
     },
 
-    _handleShowAllTap: function() {
+    _handleShowAllTap() {
       this._visibleMessages = this._processedMessages;
       this.$.reporting.reportInteraction(ReportingEvent.SHOW_ALL);
     },
 
-    _handleIncrementShownMessages: function() {
-      var delta = this._getDelta(this._visibleMessages, this._processedMessages,
-          this._hideAutomated);
-      var len = this._visibleMessages.length;
-      var newMessages = this._processedMessages.slice(-(len + delta), -len);
+    _handleIncrementShownMessages() {
+      const delta = this._getDelta(this._visibleMessages,
+          this._processedMessages, this._hideAutomated);
+      const len = this._visibleMessages.length;
+      const newMessages = this._processedMessages.slice(-(len + delta), -len);
       // Add newMessages to the beginning of _visibleMessages
-      this.splice.apply(this, ['_visibleMessages', 0, 0].concat(newMessages));
+      this.splice(...['_visibleMessages', 0, 0].concat(newMessages));
       this.$.reporting.reportInteraction(ReportingEvent.SHOW_MORE);
     },
 
-    _processedMessagesChanged: function(messages) {
+    _processedMessagesChanged(messages) {
       this._visibleMessages = messages.slice(-MAX_INITIAL_SHOWN_MESSAGES);
     },
 
-    _computeNumMessagesText: function(visibleMessages, messages,
+    _computeNumMessagesText(visibleMessages, messages,
         hideAutomated) {
-      var total = this._numRemaining(visibleMessages, messages, hideAutomated);
+      const total =
+          this._numRemaining(visibleMessages, messages, hideAutomated);
       return total === 1 ? 'Show 1 message' : 'Show all ' + total + ' messages';
     },
+
+    _computeIncrementHidden(visibleMessages, messages,
+        hideAutomated) {
+      const total =
+          this._numRemaining(visibleMessages, messages, hideAutomated);
+      return total <= this._getDelta(visibleMessages, messages, hideAutomated);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
index cdca365..15964a7 100644
--- a/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-messages-list/gr-messages-list_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-messages-list.html">
 
 <script>void(0);</script>
@@ -34,9 +33,9 @@
 
 <script>
 
-  var randomMessage = function(opt_params) {
-    var params = opt_params || {};
-    var author1 = {
+  const randomMessage = function(opt_params) {
+    const params = opt_params || {};
+    const author1 = {
       _account_id: 1115495,
       name: 'Andrew Bonventre',
       email: 'andybons@chromium.org',
@@ -50,24 +49,24 @@
     };
   };
 
-  var randomAutomated = function(opt_params) {
+  const randomAutomated = function(opt_params) {
     return Object.assign({tag: 'autogenerated:gerrit:replace'},
         randomMessage(opt_params));
   };
 
-  suite('gr-messages-list tests', function() {
-    var element;
-    var messages;
-    var sandbox;
+  suite('gr-messages-list tests', () => {
+    let element;
+    let messages;
+    let sandbox;
 
-    var getMessages = function() {
+    const getMessages = function() {
       return Polymer.dom(element.root).querySelectorAll('gr-message');
     };
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
@@ -76,25 +75,25 @@
       flushAsynchronousOperations();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('show some old messages', function() {
+    test('show some old messages', () => {
       assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
       element.messages = _.times(26, randomMessage);
       flushAsynchronousOperations();
 
       assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
       assert.equal(getMessages().length, 20);
-      assert.equal(element.$.incrementMessagesBtn.innerText,
-          'Show 5 more');
+      assert.equal(element.$.incrementMessagesBtn.innerText.toUpperCase()
+          .trim(), 'SHOW 5 MORE');
       MockInteractions.tap(element.$.incrementMessagesBtn);
       flushAsynchronousOperations();
 
       assert.equal(getMessages().length, 25);
-      assert.equal(element.$.incrementMessagesBtn.innerText,
-          'Show 1 more');
+      assert.equal(element.$.incrementMessagesBtn.innerText.toUpperCase()
+          .trim(), 'SHOW 1 MORE');
       MockInteractions.tap(element.$.incrementMessagesBtn);
       flushAsynchronousOperations();
 
@@ -102,14 +101,15 @@
       assert.equal(getMessages().length, 26);
     });
 
-    test('show all old messages', function() {
+    test('show all old messages', () => {
       assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
       element.messages = _.times(26, randomMessage);
       flushAsynchronousOperations();
 
       assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
       assert.equal(getMessages().length, 20);
-      assert.equal(element.$.oldMessagesBtn.innerText, 'Show all 6 messages');
+      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
+          'SHOW ALL 6 MESSAGES');
       MockInteractions.tap(element.$.oldMessagesBtn);
       flushAsynchronousOperations();
 
@@ -117,12 +117,13 @@
       assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
     });
 
-    test('message count respects automated', function() {
+    test('message count respects automated', () => {
       element.messages = _.times(10, randomAutomated)
           .concat(_.times(11, randomMessage));
       flushAsynchronousOperations();
 
-      assert.equal(element.$.oldMessagesBtn.innerText, 'Show 1 message');
+      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
+          'SHOW 1 MESSAGE');
       assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
       MockInteractions.tap(element.$.automatedMessageToggle);
       flushAsynchronousOperations();
@@ -130,21 +131,23 @@
       assert.isTrue(element.$.messageControlsContainer.hasAttribute('hidden'));
     });
 
-    test('message count still respects non-automated on toggle', function() {
+    test('message count still respects non-automated on toggle', () => {
       element.messages = _.times(10, randomMessage)
           .concat(_.times(11, randomAutomated));
       flushAsynchronousOperations();
 
-      assert.equal(element.$.oldMessagesBtn.innerText, 'Show 1 message');
+      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
+          'SHOW 1 MESSAGE');
       assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
       MockInteractions.tap(element.$.automatedMessageToggle);
       flushAsynchronousOperations();
 
-      assert.equal(element.$.oldMessagesBtn.innerText, 'Show 1 message');
+      assert.equal(element.$.oldMessagesBtn.innerText.toUpperCase(),
+          'SHOW 1 MESSAGE');
       assert.isFalse(element.$.messageControlsContainer.hasAttribute('hidden'));
     });
 
-    test('show all messages respects expand', function() {
+    test('show all messages respects expand', () => {
       element.messages = _.times(10, randomAutomated)
           .concat(_.times(11, randomMessage));
       flushAsynchronousOperations();
@@ -152,10 +155,10 @@
       MockInteractions.tap(element.$$('#collapse-messages')); // Expand all.
       flushAsynchronousOperations();
 
-      var messages = getMessages();
+      let messages = getMessages();
       assert.equal(messages.length, 20);
-      for (var i = 0; i < messages.length; i++) {
-        assert.isTrue(messages[i]._expanded);
+      for (const message of messages) {
+        assert.isTrue(message._expanded);
       }
 
       MockInteractions.tap(element.$.oldMessagesBtn);
@@ -163,12 +166,12 @@
 
       messages = getMessages();
       assert.equal(messages.length, 21);
-      for (var i = 0; i < messages.length; i++) {
-        assert.isTrue(messages[i]._expanded);
+      for (const message of messages) {
+        assert.isTrue(message._expanded);
       }
     });
 
-    test('show all messages respects collapse', function() {
+    test('show all messages respects collapse', () => {
       element.messages = _.times(10, randomAutomated)
           .concat(_.times(11, randomMessage));
       flushAsynchronousOperations();
@@ -177,10 +180,10 @@
       MockInteractions.tap(element.$$('#collapse-messages')); // Collapse all.
       flushAsynchronousOperations();
 
-      var messages = getMessages();
+      let messages = getMessages();
       assert.equal(messages.length, 20);
-      for (var i = 0; i < messages.length; i++) {
-        assert.isFalse(messages[i]._expanded);
+      for (const message of messages) {
+        assert.isFalse(message._expanded);
       }
 
       MockInteractions.tap(element.$.oldMessagesBtn);
@@ -188,37 +191,37 @@
 
       messages = getMessages();
       assert.equal(messages.length, 21);
-      for (var i = 0; i < messages.length; i++) {
-        assert.isFalse(messages[i]._expanded);
+      for (const message of messages) {
+        assert.isFalse(message._expanded);
       }
     });
 
-    test('expand/collapse all', function() {
-      var allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        allMessageEls[i]._expanded = false;
+    test('expand/collapse all', () => {
+      let allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message._expanded = false;
       }
       MockInteractions.tap(allMessageEls[1]);
       assert.isTrue(allMessageEls[1]._expanded);
 
       MockInteractions.tap(element.$$('#collapse-messages'));
       allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isTrue(allMessageEls[i]._expanded);
+      for (const message of allMessageEls) {
+        assert.isTrue(message._expanded);
       }
 
       MockInteractions.tap(element.$$('#collapse-messages'));
       allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isFalse(allMessageEls[i]._expanded);
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded);
       }
     });
 
-    test('expand/collapse from external keypress', function() {
+    test('expand/collapse from external keypress', () => {
       MockInteractions.tap(element.$$('#collapse-messages'));
-      var allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isTrue(allMessageEls[i]._expanded);
+      let allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        assert.isTrue(message._expanded);
       }
 
       // Expand/collapse all text also changes.
@@ -227,36 +230,35 @@
 
       MockInteractions.tap(element.$$('#collapse-messages'));
       allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isFalse(allMessageEls[i]._expanded);
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded);
       }
       // Expand/collapse all text also changes.
       assert.equal(element.$$('#collapse-messages').textContent.trim(),
           'Expand all');
     });
 
-    test('hide messages does not appear when no automated messages',
-        function() {
+    test('hide messages does not appear when no automated messages', () => {
       assert.isOk(element.$$('#automatedMessageToggleContainer[hidden]'));
     });
 
-    test('scroll to message', function() {
-      var allMessageEls = getMessages();
-      for (var i = 0; i < allMessageEls.length; i++) {
-        allMessageEls[i].set('message.expanded', false);
+    test('scroll to message', () => {
+      const allMessageEls = getMessages();
+      for (const message of allMessageEls) {
+        message.set('message.expanded', false);
       }
 
-      var scrollToStub = sandbox.stub(window, 'scrollTo');
-      var highlightStub = sandbox.stub(element, '_highlightEl');
+      const scrollToStub = sandbox.stub(window, 'scrollTo');
+      const highlightStub = sandbox.stub(element, '_highlightEl');
 
       element.scrollToMessage('invalid');
 
-      for (var i = 0; i < allMessageEls.length; i++) {
-        assert.isFalse(allMessageEls[i]._expanded,
-            'expected gr-message ' + i + ' to not be expanded');
+      for (const message of allMessageEls) {
+        assert.isFalse(message._expanded,
+            'expected gr-message to not be expanded');
       }
 
-      var messageID = messages[1].id;
+      const messageID = messages[1].id;
       element.scrollToMessage(messageID);
       assert.isTrue(
           element.$$('[data-message-id="' + messageID + '"]')._expanded);
@@ -265,15 +267,15 @@
       assert.isTrue(highlightStub.calledOnce);
     });
 
-    test('scroll to message offscreen', function() {
-      var scrollToStub = sandbox.stub(window, 'scrollTo');
-      var highlightStub = sandbox.stub(element, '_highlightEl');
+    test('scroll to message offscreen', () => {
+      const scrollToStub = sandbox.stub(window, 'scrollTo');
+      const highlightStub = sandbox.stub(element, '_highlightEl');
       element.messages = _.times(25, randomMessage);
       flushAsynchronousOperations();
       assert.isFalse(scrollToStub.called);
       assert.isFalse(highlightStub.called);
 
-      var messageID = element.messages[1].id;
+      const messageID = element.messages[1].id;
       element.scrollToMessage(messageID);
       assert.isTrue(scrollToStub.calledOnce);
       assert.isTrue(highlightStub.calledOnce);
@@ -282,13 +284,13 @@
           element.$$('[data-message-id="' + messageID + '"]')._expanded);
     });
 
-    test('messages', function() {
-      var author = {
+    test('messages', () => {
+      const author = {
         _account_id: 42,
         name: 'Marvin the Paranoid Android',
         email: 'marvin@sirius.org',
       };
-      var comments = {
+      const comments = {
         file1: [
           {
             message: 'message text',
@@ -309,7 +311,7 @@
             line: 42,
             id: '450a935e_0f1c05db',
             patch_set: 2,
-            author: author,
+            author,
           },
           {
             message: 'message text',
@@ -318,7 +320,7 @@
             line: 62,
             id: '6505d749_10ed44b2',
             patch_set: 2,
-            author: author,
+            author,
           },
         ],
         file2: [
@@ -329,18 +331,18 @@
             line: 132,
             id: '450a935e_4f260d25',
             patch_set: 2,
-            author: author,
+            author,
           },
         ],
       };
-      var messages = [].concat(
+      const messages = [].concat(
           randomMessage(),
           {
             _index: 5,
             _revision_number: 4,
             message: 'Uploaded patch set 4.',
             date: '2016-09-28 13:36:33.000000000',
-            author: author,
+            author,
             id: '8c19ccc949c6d482b061be6a28e10782abf0e7af',
           },
           {
@@ -348,18 +350,18 @@
             _revision_number: 4,
             message: 'Patch Set 4:\n\n(6 comments)',
             date: '2016-09-28 13:36:33.000000000',
-            author: author,
+            author,
             id: 'e7bfdbc842f6b6d8064bc68e0f52b673f40c0ca5',
           }
       );
       element.comments = comments;
       element.messages = messages;
-      var isAuthor = function(author, message) {
+      const isAuthor = function(author, message) {
         return message.author._account_id === author._account_id;
       };
-      var isMarvin = isAuthor.bind(null, author);
+      const isMarvin = isAuthor.bind(null, author);
       flushAsynchronousOperations();
-      var messageElements = getMessages();
+      const messageElements = getMessages();
       assert.equal(messageElements.length, messages.length);
       assert.deepEqual(messageElements[1].message, messages[1]);
       assert.deepEqual(messageElements[2].message, messages[2]);
@@ -370,8 +372,8 @@
       assert.deepEqual(messageElements[2].comments, {});
     });
 
-    test('messages without author do not throw', function() {
-      var comments = {
+    test('messages without author do not throw', () => {
+      const comments = {
         file1: [
           {
             message: 'message text',
@@ -386,7 +388,7 @@
             },
           },
         ]};
-      var messages = [{
+      const messages = [{
         _index: 5,
         _revision_number: 4,
         message: 'Uploaded patch set 4.',
@@ -396,31 +398,43 @@
       element.messages = messages;
       element.comments = comments;
       flushAsynchronousOperations();
-      var messageEls = getMessages();
+      const messageEls = getMessages();
       assert.equal(messageEls.length, 1);
       assert.equal(messageEls[0].message.message, messages[0].message);
     });
+
+    test('hide increment text if increment >= total remaining', () => {
+      // Test with stubbed return values, as _numRemaining and _getDelta have
+      // their own tests.
+      sandbox.stub(element, '_getDelta').returns(5);
+      const remainingStub = sandbox.stub(element, '_numRemaining').returns(6);
+      assert.isFalse(element._computeIncrementHidden(null, null, null));
+      remainingStub.restore();
+
+      sandbox.stub(element, '_numRemaining').returns(4);
+      assert.isTrue(element._computeIncrementHidden(null, null, null));
+    });
   });
 
-  suite('gr-messages-list automate tests', function() {
-    var element;
-    var messages;
+  suite('gr-messages-list automate tests', () => {
+    let element;
+    let messages;
 
-    var getMessages = function() {
+    const getMessages = function() {
       return Polymer.dom(element.root).querySelectorAll('gr-message');
     };
-    var getHiddenMessages = function() {
+    const getHiddenMessages = function() {
       return Polymer.dom(element.root).querySelectorAll('gr-message[hidden]');
     };
 
-    var randomMessageReviewer = {
+    const randomMessageReviewer = {
       reviewer: {},
     };
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       element = fixture('basic');
       messages = _.times(2, randomAutomated);
@@ -429,19 +443,19 @@
       flushAsynchronousOperations();
     });
 
-    test('hide autogenerated button is not hidden', function() {
+    test('hide autogenerated button is not hidden', () => {
       assert.isNotOk(element.$$('#automatedMessageToggle[hidden]'));
     });
 
-    test('autogenerated messages are not hidden initially', function() {
-      var allHiddenMessageEls = getHiddenMessages();
+    test('autogenerated messages are not hidden initially', () => {
+      const allHiddenMessageEls = getHiddenMessages();
 
-      //There are no hidden messages.
+      // There are no hidden messages.
       assert.isFalse(!!allHiddenMessageEls.length);
     });
 
-    test('autogenerated messages hidden after hide button tap', function() {
-      var allHiddenMessageEls = getHiddenMessages();
+    test('autogenerated messages hidden after hide button tap', () => {
+      let allHiddenMessageEls = getHiddenMessages();
 
       element._hideAutomated = false;
       MockInteractions.tap(element.$.automatedMessageToggle);
@@ -453,19 +467,19 @@
       assert.equal(allHiddenMessageEls.length, allMessageEls.length);
     });
 
-    test('autogenerated messages not hidden after show button tap', function() {
-      var allHiddenMessageEls = getHiddenMessages();
+    test('autogenerated messages not hidden after show button tap', () => {
+      let allHiddenMessageEls = getHiddenMessages();
 
       element._hideAutomated = true;
       MockInteractions.tap(element.$.automatedMessageToggle);
       allHiddenMessageEls = getHiddenMessages();
 
-      //Autogenerated messages are now hidden.
+      // Autogenerated messages are now hidden.
       assert.isFalse(!!allHiddenMessageEls.length);
     });
 
-    test('_getDelta', function() {
-      var messages = [randomMessage()];
+    test('_getDelta', () => {
+      let messages = [randomMessage()];
       assert.equal(element._getDelta([], messages, false), 1);
       assert.equal(element._getDelta([], messages, true), 1);
 
@@ -478,18 +492,18 @@
           .concat(_.times(2, randomAutomated))
           .concat(_.times(3, randomMessage));
 
-      var dummyArr = _.times(2, randomMessage);
+      const dummyArr = _.times(2, randomMessage);
       assert.equal(element._getDelta(dummyArr, messages, false), 5);
       assert.equal(element._getDelta(dummyArr, messages, true), 7);
     });
 
-    test('_getHumanMessages', function() {
+    test('_getHumanMessages', () => {
       assert.equal(
           element._getHumanMessages(_.times(5, randomAutomated)).length, 0);
       assert.equal(
           element._getHumanMessages(_.times(5, randomMessage)).length, 5);
 
-      var messages = _.shuffle(_.times(5, randomMessage)
+      let messages = _.shuffle(_.times(5, randomMessage)
           .concat(_.times(5, randomAutomated)));
       messages = element._getHumanMessages(messages);
       assert.equal(messages.length, 5);
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
index 8eacd48..401aaf8 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.html
@@ -13,15 +13,17 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-related-changes-list">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
@@ -66,7 +68,7 @@
       }
       .status {
         color: #666;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         margin-left: .25em;
       }
       .notCurrent {
@@ -78,11 +80,18 @@
       .submittable {
         color: #1b5e20;
       }
+      .submittableCheck {
+        color: #388E3C;
+        display: none;
+      }
+      .submittableCheck.submittable {
+        display: inline;
+      }
       .hidden,
       .mobile {
         display: none;
       }
-       @media screen and (max-width: 50em) {
+       @media screen and (max-width: 60em) {
         .mobile {
           display: block;
         }
@@ -94,8 +103,7 @@
         }
       }
     </style>
-    <div hidden$="[[!loading]]">Loading...</div>
-    <div hidden$="[[loading]]">
+    <div>
       <hr class="mobile">
       <section class="relatedChanges" hidden$="[[!_relatedResponse.changes.length]]" hidden>
         <h4>Relation chain</h4>
@@ -104,8 +112,9 @@
             items="[[_relatedResponse.changes]]"
             as="related">
           <div class$="rightIndent [[_computeChangeContainerClass(change, related)]]">
-            <a href$="[[_computeChangeURL(related._change_number, related._revision_number)]]"
-                class$="[[_computeLinkClass(related)]]">
+            <a href$="[[_computeChangeURL(related._change_number, related.project, related._revision_number)]]"
+                class$="[[_computeLinkClass(related)]]"
+                title$="[[related.commit.subject]]">
               [[related.commit.subject]]
             </a>
             <span class$="[[_computeChangeStatusClass(related)]]">
@@ -118,10 +127,15 @@
         <h4>Submitted together</h4>
         <template is="dom-repeat" items="[[_submittedTogether]]" as="change">
           <div>
-            <a href$="[[_computeChangeURL(change._number)]]"
-                class$="[[_computeLinkClass(change)]]">
+            <a href$="[[_computeChangeURL(change._number, change.project)]]"
+                class$="[[_computeLinkClass(change)]]"
+                title$="[[change.project]]: [[change.branch]]: [[change.subject]]">
               [[change.project]]: [[change.branch]]: [[change.subject]]
             </a>
+            <span
+                tabindex="-1"
+                title="Submittable"
+                class$="submittableCheck [[_computeLinkClass(change)]]">✓</span>
           </div>
         </template>
       </section>
@@ -129,8 +143,9 @@
         <h4>Same topic</h4>
         <template is="dom-repeat" items="[[_sameTopic]]" as="change">
           <div>
-            <a href$="[[_computeChangeURL(change._number)]]"
-                class$="[[_computeLinkClass(change)]]">
+            <a href$="[[_computeChangeURL(change._number, change.project)]]"
+                class$="[[_computeLinkClass(change)]]"
+                title$="[[change.project]]: [[change.branch]]: [[change.subject]]">
               [[change.project]]: [[change.branch]]: [[change.subject]]
             </a>
           </div>
@@ -140,8 +155,9 @@
         <h4>Merge conflicts</h4>
         <template is="dom-repeat" items="[[_conflicts]]" as="change">
           <div>
-            <a href$="[[_computeChangeURL(change._number)]]"
-                class$="[[_computeLinkClass(change)]]">
+            <a href$="[[_computeChangeURL(change._number, change.project)]]"
+                class$="[[_computeLinkClass(change)]]"
+                title$="[[change.subject]]">
               [[change.subject]]
             </a>
           </div>
@@ -151,15 +167,17 @@
         <h4>Cherry picks</h4>
         <template is="dom-repeat" items="[[_cherryPicks]]" as="change">
           <div>
-            <a href$="[[_computeChangeURL(change._number)]]"
-                class$="[[_computeLinkClass(change)]]">
+            <a href$="[[_computeChangeURL(change._number, change.project)]]"
+                class$="[[_computeLinkClass(change)]]"
+                title$="[[change.branch]]: [[change.subject]]">
               [[change.branch]]: [[change.subject]]
             </a>
           </div>
         </template>
       </section>
     </div>
+    <div hidden$="[[!loading]]">Loading...</div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-related-changes-list.js"></script>
-</dom-module>
+</dom-module>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
index 55a0bce..780dca2 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list.js
@@ -22,6 +22,7 @@
       hasParent: {
         type: Boolean,
         notify: true,
+        value: false,
       },
       patchNum: String,
       parentChange: Object,
@@ -39,15 +40,31 @@
         computed: '_computeConnectedRevisions(change, patchNum, ' +
             '_relatedResponse.changes)',
       },
-      _relatedResponse: Object,
-      _submittedTogether: Array,
-      _conflicts: Array,
-      _cherryPicks: Array,
-      _sameTopic: Array,
+      /** @type {?} */
+      _relatedResponse: {
+        type: Object,
+        value() { return {changes: []}; },
+      },
+      _submittedTogether: {
+        type: Array,
+        value() { return []; },
+      },
+      _conflicts: {
+        type: Array,
+        value() { return []; },
+      },
+      _cherryPicks: {
+        type: Array,
+        value() { return []; },
+      },
+      _sameTopic: {
+        type: Array,
+        value() { return []; },
+      },
     },
 
     behaviors: [
-      Gerrit.BaseUrlBehavior,
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
@@ -56,118 +73,127 @@
           '_conflicts, _cherryPicks, _sameTopic)',
     ],
 
-    clear: function() {
+    clear() {
       this.loading = true;
+      this.hidden = true;
     },
 
-    reload: function() {
+    reload() {
       if (!this.change || !this.patchNum) {
         return Promise.resolve();
       }
       this.loading = true;
-      var promises = [
-        this._getRelatedChanges().then(function(response) {
+      const promises = [
+        this._getRelatedChanges().then(response => {
           this._relatedResponse = response;
 
           this.hasParent = this._calculateHasParent(this.change.change_id,
-            response.changes);
-
-        }.bind(this)),
-        this._getSubmittedTogether().then(function(response) {
+              response.changes);
+        }),
+        this._getSubmittedTogether().then(response => {
           this._submittedTogether = response;
-        }.bind(this)),
-        this._getCherryPicks().then(function(response) {
+        }),
+        this._getCherryPicks().then(response => {
           this._cherryPicks = response;
-        }.bind(this)),
+        }),
       ];
 
       // Get conflicts if change is open and is mergeable.
       if (this.changeIsOpen(this.change.status) && this.change.mergeable) {
-        promises.push(this._getConflicts().then(function(response) {
-          this._conflicts = response;
-        }.bind(this)));
+        promises.push(this._getConflicts().then(response => {
+          // Because the server doesn't always return a response and the
+          // template expects an array, always return an array.
+          this._conflicts = response ? response : [];
+        }));
       }
 
-      promises.push(this._getServerConfig().then(function(config) {
+      promises.push(this._getServerConfig().then(config => {
         if (this.change.topic && !config.change.submit_whole_topic) {
-          return this._getChangesWithSameTopic().then(function(response) {
+          return this._getChangesWithSameTopic().then(response => {
             this._sameTopic = response;
-          }.bind(this));
+          });
         } else {
           this._sameTopic = [];
         }
         return this._sameTopic;
-      }.bind(this)));
+      }));
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         this.loading = false;
-      }.bind(this));
+      });
     },
 
     /**
      * Determines whether or not the given change has a parent change. If there
      * is a relation chain, and the change id is not the last item of the
      * relation chain, there is a parent.
-     * @param  {Number} currentChangeId
-     * @param  {Array} relatedChanges
-     * @return {Boolean}
+     * @param  {number} currentChangeId
+     * @param  {!Array} relatedChanges
+     * @return {boolean}
      */
-    _calculateHasParent: function(currentChangeId, relatedChanges) {
+    _calculateHasParent(currentChangeId, relatedChanges) {
       return relatedChanges.length > 0 &&
           relatedChanges[relatedChanges.length - 1].change_id !==
           currentChangeId;
     },
 
-    _getRelatedChanges: function() {
+    _getRelatedChanges() {
       return this.$.restAPI.getRelatedChanges(this.change._number,
           this.patchNum);
     },
 
-    _getSubmittedTogether: function() {
+    _getSubmittedTogether() {
       return this.$.restAPI.getChangesSubmittedTogether(this.change._number);
     },
 
-    _getServerConfig: function() {
+    _getServerConfig() {
       return this.$.restAPI.getConfig();
     },
 
-    _getConflicts: function() {
+    _getConflicts() {
       return this.$.restAPI.getChangeConflicts(this.change._number);
     },
 
-    _getCherryPicks: function() {
+    _getCherryPicks() {
       return this.$.restAPI.getChangeCherryPicks(this.change.project,
           this.change.change_id, this.change._number);
     },
 
-    _getChangesWithSameTopic: function() {
+    _getChangesWithSameTopic() {
       return this.$.restAPI.getChangesWithSameTopic(this.change.topic);
     },
 
-    _computeChangeURL: function(changeNum, patchNum) {
-      var urlStr = this.getBaseUrl() + '/c/' + changeNum;
-      if (patchNum != null) {
-        urlStr += '/' + patchNum;
-      }
-      return urlStr;
+    /**
+     * @param {number} changeNum
+     * @param {string} project
+     * @param {number=} opt_patchNum
+     * @return {string}
+     */
+    _computeChangeURL(changeNum, project, opt_patchNum) {
+      return Gerrit.Nav.getUrlForChangeById(changeNum, project, opt_patchNum);
     },
 
-    _computeChangeContainerClass: function(currentChange, relatedChange) {
-      var classes = ['changeContainer'];
+    _computeChangeContainerClass(currentChange, relatedChange) {
+      const classes = ['changeContainer'];
       if (relatedChange.change_id === currentChange.change_id) {
         classes.push('thisChange');
       }
       return classes.join(' ');
     },
 
-    _computeLinkClass: function(change) {
+    _computeLinkClass(change) {
+      const statuses = [];
       if (change.status == this.ChangeStatus.ABANDONED) {
-        return 'strikethrough';
+        statuses.push('strikethrough');
       }
+      if (change.submittable) {
+        statuses.push('submittable');
+      }
+      return statuses.join(' ');
     },
 
-    _computeChangeStatusClass: function(change) {
-      var classes = ['status'];
+    _computeChangeStatusClass(change) {
+      const classes = ['status'];
       if (change._revision_number != change._current_revision_number) {
         classes.push('notCurrent');
       } else if (this._isIndirectAncestor(change)) {
@@ -180,14 +206,12 @@
       return classes.join(' ');
     },
 
-    _computeChangeStatus: function(change) {
+    _computeChangeStatus(change) {
       switch (change.status) {
         case this.ChangeStatus.MERGED:
           return 'Merged';
         case this.ChangeStatus.ABANDONED:
           return 'Abandoned';
-        case this.ChangeStatus.DRAFT:
-          return 'Draft';
       }
       if (change._revision_number != change._current_revision_number) {
         return 'Not current';
@@ -199,41 +223,42 @@
       return '';
     },
 
-    _resultsChanged: function(related, submittedTogether, conflicts,
+    _resultsChanged(related, submittedTogether, conflicts,
         cherryPicks, sameTopic) {
-      var results = [
+      const results = [
         related,
         submittedTogether,
         conflicts,
         cherryPicks,
-        sameTopic
+        sameTopic,
       ];
-      for (var i = 0; i < results.length; i++) {
+      for (let i = 0; i < results.length; i++) {
         if (results[i].length > 0) {
           this.hidden = false;
+          this.fire('update', null, {bubbles: false});
           return;
         }
       }
       this.hidden = true;
     },
 
-    _isIndirectAncestor: function(change) {
-      return this._connectedRevisions.indexOf(change.commit.commit) == -1;
+    _isIndirectAncestor(change) {
+      return !this._connectedRevisions.includes(change.commit.commit);
     },
 
-    _computeConnectedRevisions: function(change, patchNum, relatedChanges) {
-      var connected = [];
-      var changeRevision;
-      for (var rev in change.revisions) {
-        if (change.revisions[rev]._number == patchNum) {
+    _computeConnectedRevisions(change, patchNum, relatedChanges) {
+      const connected = [];
+      let changeRevision;
+      for (const rev in change.revisions) {
+        if (this.patchNumEquals(change.revisions[rev]._number, patchNum)) {
           changeRevision = rev;
         }
       }
-      var commits = relatedChanges.map(function(c) { return c.commit; });
-      var pos = commits.length - 1;
+      const commits = relatedChanges.map(c => { return c.commit; });
+      let pos = commits.length - 1;
 
       while (pos >= 0) {
-        var commit = commits[pos].commit;
+        const commit = commits[pos].commit;
         connected.push(commit);
         if (commit == changeRevision) {
           break;
@@ -241,8 +266,8 @@
         pos--;
       }
       while (pos >= 0) {
-        for (var i = 0; i < commits[pos].parents.length; i++) {
-          if (connected.indexOf(commits[pos].parents[i].commit) != -1) {
+        for (let i = 0; i < commits[pos].parents.length; i++) {
+          if (connected.includes(commits[pos].parents[i].commit)) {
             connected.push(commits[pos].commit);
             break;
           }
diff --git a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
index 78c4cf4..df4391e 100644
--- a/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-related-changes-list/gr-related-changes-list_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-related-changes-list.html">
 
 <script>void(0);</script>
@@ -33,21 +32,21 @@
 </test-fixture>
 
 <script>
-  suite('gr-related-changes-list tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-related-changes-list tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('connected revisions', function() {
-      var change = {
+    test('connected revisions', () => {
+      const change = {
         revisions: {
           'e3c6d60783bfdec9ebae7dcfec4662360433449e': {
             _number: 1,
@@ -69,18 +68,18 @@
           },
           '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6': {
             _number: 4,
-          }
-        }
+          },
+        },
       };
-      var patchNum = 7;
-      var relatedChanges = [
+      let patchNum = 7;
+      let relatedChanges = [
         {
           commit: {
             commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
             parents: [
               {
-                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
-              }
+                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+              },
             ],
           },
         },
@@ -89,8 +88,8 @@
             commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
             parents: [
               {
-                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
-              }
+                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+              },
             ],
           },
         },
@@ -99,8 +98,8 @@
             commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
             parents: [
               {
-                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae'
-              }
+                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+              },
             ],
           },
         },
@@ -109,8 +108,8 @@
             commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
             parents: [
               {
-                commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907'
-              }
+                commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
+              },
             ],
           },
         },
@@ -119,8 +118,8 @@
             commit: 'bf7884d695296ca0c91702ba3e2bc8df0f69a907',
             parents: [
               {
-                commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce'
-              }
+                commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
+              },
             ],
           },
         },
@@ -129,14 +128,14 @@
             commit: '613bc4f81741a559c6667ac08d71dcc3348f73ce',
             parents: [
               {
-                commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75'
-              }
+                commit: '455ed9cd27a16bf6991f04dcc57ef575dc4d5e75',
+              },
             ],
           },
-        }
+        },
       ];
 
-      var connectedChanges =
+      let connectedChanges =
           element._computeConnectedRevisions(change, patchNum, relatedChanges);
       assert.deepEqual(connectedChanges, [
         '613bc4f81741a559c6667ac08d71dcc3348f73ce',
@@ -155,8 +154,8 @@
             commit: '2cebeedfb1e80f4b872d0a13ade529e70652c0c8',
             parents: [
               {
-                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd'
-              }
+                commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
+              },
             ],
           },
         },
@@ -165,8 +164,8 @@
             commit: '87ed20b241576b620bbaa3dfd47715ce6782b7dd',
             parents: [
               {
-                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb'
-              }
+                commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
+              },
             ],
           },
         },
@@ -175,8 +174,8 @@
             commit: '6c71f9e86ba955a7e01e2088bce0050a90eb9fbb',
             parents: [
               {
-                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae'
-              }
+                commit: 'b0ccb183494a8e340b8725a2dc553967d61e6dae',
+              },
             ],
           },
         },
@@ -185,8 +184,8 @@
             commit: 'a3e5d9d4902b915a39e2efba5577211b9b3ebe7b',
             parents: [
               {
-                commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6'
-              }
+                commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
+              },
             ],
           },
         },
@@ -195,8 +194,8 @@
             commit: '9e593f6dcc2c0785a2ad2c895a34ad2aa9a0d8b6',
             parents: [
               {
-                commit: 'af815dac54318826b7f1fa468acc76349ffc588e'
-              }
+                commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
+              },
             ],
           },
         },
@@ -205,11 +204,11 @@
             commit: 'af815dac54318826b7f1fa468acc76349ffc588e',
             parents: [
               {
-                commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c'
-              }
+                commit: '58f76e406e24cb8b0f5d64c7f5ac1e8616d0a22c',
+              },
             ],
           },
-        }
+        },
       ];
 
       connectedChanges =
@@ -222,9 +221,9 @@
       ]);
     });
 
-    test('_computeChangeContainerClass', function() {
-      var change1 = {change_id: 123};
-      var change2 = {change_id: 456};
+    test('_computeChangeContainerClass', () => {
+      const change1 = {change_id: 123};
+      const change2 = {change_id: 456};
 
       assert.notEqual(element._computeChangeContainerClass(
           change1, change1).indexOf('thisChange'), -1);
@@ -232,26 +231,52 @@
           change1, change2).indexOf('thisChange'), -1);
     });
 
-    suite('get conflicts tests', function() {
-      var element;
-      var conflictsStub;
+    suite('_getConflicts resolves undefined', () => {
+      let element;
 
-      setup(function() {
+      setup(() => {
         element = fixture('basic');
 
-        sandbox.stub(element, '_getRelatedChanges',
-            function() {
-              return Promise.resolve({changes: []});
-            });
-        sandbox.stub(element, '_getSubmittedTogether',
-            function() { return Promise.resolve(); });
-        sandbox.stub(element, '_getCherryPicks',
-            function() { return Promise.resolve(); });
-        conflictsStub = sandbox.stub(element, '_getConflicts',
-            function() { return Promise.resolve(); });
+        sandbox.stub(element, '_getRelatedChanges')
+            .returns(Promise.resolve({changes: []}));
+        sandbox.stub(element, '_getSubmittedTogether')
+            .returns(Promise.resolve());
+        sandbox.stub(element, '_getCherryPicks')
+            .returns(Promise.resolve());
+        sandbox.stub(element, '_getConflicts')
+            .returns(Promise.resolve());
       });
 
-      test('request conflicts if open and mergeable', function() {
+      test('_conflicts are an empty array', () => {
+        element.patchNum = 7;
+        element.change = {
+          change_id: 123,
+          status: 'NEW',
+          mergeable: true,
+        };
+        element.reload();
+        assert.equal(element._conflicts.length, 0);
+      });
+    });
+
+    suite('get conflicts tests', () => {
+      let element;
+      let conflictsStub;
+
+      setup(() => {
+        element = fixture('basic');
+
+        sandbox.stub(element, '_getRelatedChanges')
+            .returns(Promise.resolve({changes: []}));
+        sandbox.stub(element, '_getSubmittedTogether')
+            .returns(Promise.resolve());
+        sandbox.stub(element, '_getCherryPicks')
+            .returns(Promise.resolve());
+        conflictsStub = sandbox.stub(element, '_getConflicts')
+          .returns(Promise.resolve());
+      });
+
+      test('request conflicts if open and mergeable', () => {
         element.patchNum = 7;
         element.change = {
           change_id: 123,
@@ -262,7 +287,7 @@
         assert.isTrue(conflictsStub.called);
       });
 
-      test('does not request conflicts if closed and mergeable', function() {
+      test('does not request conflicts if closed and mergeable', () => {
         element.patchNum = 7;
         element.change = {
           change_id: 123,
@@ -273,7 +298,7 @@
         assert.isFalse(conflictsStub.called);
       });
 
-      test('does not request conflicts if open and not mergeable', function() {
+      test('does not request conflicts if open and not mergeable', () => {
         element.patchNum = 7;
         element.change = {
           change_id: 123,
@@ -284,8 +309,7 @@
         assert.isFalse(conflictsStub.called);
       });
 
-      test('does not request conflicts if closed and not mergeable',
-          function() {
+      test('doesnt request conflicts if closed and not mergeable', () => {
         element.patchNum = 7;
         element.change = {
           change_id: 123,
@@ -297,9 +321,9 @@
       });
     });
 
-    test('_calculateHasParent', function() {
-      var changeId = 123;
-      var relatedChanges = [];
+    test('_calculateHasParent', () => {
+      const changeId = 123;
+      const relatedChanges = [];
 
       assert.equal(element._calculateHasParent(changeId, relatedChanges),
           false);
@@ -311,7 +335,33 @@
       relatedChanges.push({change_id: 234});
       assert.equal(element._calculateHasParent(changeId, relatedChanges),
           true);
+    });
 
+    test('clear hides', () => {
+      element.loading = false;
+      element.hidden = false;
+      element.clear();
+      assert.isTrue(element.loading);
+      assert.isTrue(element.hidden);
+    });
+
+    test('update fires', () => {
+      const updateHandler = sandbox.stub();
+      element.addEventListener('update', updateHandler);
+
+      element._resultsChanged([], [], [], [], []);
+      assert.isTrue(element.hidden);
+      assert.isFalse(updateHandler.called);
+
+      element._resultsChanged([], [], [], [], ['test']);
+      assert.isFalse(element.hidden);
+      assert.isTrue(updateHandler.called);
+    });
+
+    test('_computeChangeURL uses Gerrit.Nav', () => {
+      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForChangeById');
+      element._computeChangeURL(123, 'abc/def', 12);
+      assert.isTrue(getUrlStub.called);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
new file mode 100644
index 0000000..babd95c
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog-it_test.html
@@ -0,0 +1,163 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2015 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reply-dialog</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../../plugins/gr-plugin-host/gr-plugin-host.html">
+<link rel="import" href="gr-reply-dialog.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-reply-dialog></gr-reply-dialog>
+  </template>
+</test-fixture>
+
+<test-fixture id="plugin-host">
+  <template>
+    <gr-plugin-host></gr-plugin-host>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-reply-dialog tests', () => {
+    let element;
+    let changeNum;
+    let patchNum;
+
+    let sandbox;
+
+    const setupElement = element => {
+      element.change = {
+        _number: changeNum,
+        labels: {
+          'Verified': {
+            values: {
+              '-1': 'Fails',
+              ' 0': 'No score',
+              '+1': 'Verified',
+            },
+            default_value: 0,
+          },
+          'Code-Review': {
+            values: {
+              '-2': 'Do not submit',
+              '-1': 'I would prefer that you didn\'t submit this',
+              ' 0': 'No score',
+              '+1': 'Looks good to me, but someone else must approve',
+              '+2': 'Looks good to me, approved',
+            },
+            all: [{_account_id: 42, value: 0}],
+            default_value: 0,
+          },
+        },
+      };
+      element.patchNum = patchNum;
+      element.permittedLabels = {
+        'Code-Review': [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+        'Verified': [
+          '-1',
+          ' 0',
+          '+1',
+        ],
+      };
+      element.serverConfig = {note_db_enabled: true};
+      sandbox.stub(element, 'fetchIsLatestKnown', () => Promise.resolve(true));
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+
+      changeNum = 42;
+      patchNum = 1;
+
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+        getAccount() { return Promise.resolve({_account_id: 42}); },
+      });
+
+      element = fixture('basic');
+      setupElement(element);
+
+      // Allow the elements created by dom-repeat to be stamped.
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      Gerrit._pluginsPending = -1;
+      Gerrit._allPluginsPromise = undefined;
+      sandbox.restore();
+    });
+
+    test('send blocked when invalid email is supplied to ccs', () => {
+      const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
+      // Stub the below function to avoid side effects from the send promise
+      // resolving.
+      sandbox.stub(element, '_purgeReviewersPendingRemove');
+
+      element.$$('#ccs').$.entry.setText('test');
+      MockInteractions.tap(element.$$('gr-button.send'));
+      assert.isFalse(sendStub.called);
+      flushAsynchronousOperations();
+
+      element.$$('#ccs').$.entry.setText('test@test.test');
+      MockInteractions.tap(element.$$('gr-button.send'));
+      assert.isTrue(sendStub.called);
+    });
+
+    test('lgtm plugin', done => {
+      const pluginHost = fixture('plugin-host');
+      pluginHost.config = {
+        plugin: {
+          js_resource_paths: [],
+          html_resource_paths: [
+            new URL('test/plugin.html', window.location.href).toString(),
+          ],
+        },
+      };
+      element = fixture('basic');
+      setupElement(element);
+      const importSpy =
+          sandbox.spy(element.$$('gr-endpoint-decorator'), '_import');
+      Gerrit.awaitPluginsLoaded().then(() => {
+        Promise.all(importSpy.returnValues).then(() => {
+          flush(() => {
+            const textarea = element.$.textarea.getNativeTextarea();
+            textarea.value = 'LGTM';
+            textarea.dispatchEvent(new CustomEvent('input', {bubbles: true}));
+            const labelScoreRows = Polymer.dom(element.$.labelScores.root)
+                .querySelector('gr-label-score-row[name="Code-Review"]');
+            const selectedBtn = Polymer.dom(labelScoreRows.root)
+                .querySelector('gr-button[value="+1"].iron-selected');
+            assert.isOk(selectedBtn);
+            done();
+          });
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index b1f95e6..127f6d8 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -14,11 +14,15 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../bower_components/iron-selector/iron-selector.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
+<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
 <link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
@@ -26,10 +30,12 @@
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../gr-account-list/gr-account-list.html">
+<link rel="import" href="../gr-label-scores/gr-label-scores.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-reply-dialog">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
         max-height: 90vh;
@@ -46,13 +52,19 @@
         max-height: 90vh;
       }
       section {
-        border-top: 1px solid #ddd;
-        padding: .5em .75em;
+        border-top: 1px solid #cdcdcd;
+        padding: .5em 1.5em;
         width: 100%;
       }
+      .actions {
+        display: flex;
+        justify-content: space-between;
+      }
+      .actions .right gr-button {
+        margin-left: 1em;
+      }
       .peopleContainer,
-      .labelsContainer,
-      .actionsContainer {
+      .labelsContainer {
         flex-shrink: 0;
       }
       .peopleContainer {
@@ -60,6 +72,7 @@
       }
       .peopleList {
         display: flex;
+        align-items: center;
         padding-top: .1em;
       }
       .peopleListLabel {
@@ -71,6 +84,11 @@
         display: flex;
         flex-wrap: wrap;
         flex: 1;
+        min-height: 1.8em;
+        --account-list-style: {
+          max-height: 12em;
+          overflow-y: auto;
+        }
       }
       #reviewerConfirmationOverlay {
         padding: 1em;
@@ -80,20 +98,20 @@
         margin-top: 1em;
       }
       .groupName {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .groupSize {
         font-style: italic;
       }
       .textareaContainer {
-        display: flex;
-        flex: 1;
         min-height: 6em;
         position: relative;
       }
-      iron-autogrow-textarea {
-        padding: 0;
-        font-family: var(--monospace-font-family);
+      .textareaContainer,
+      #textarea,
+      gr-endpoint-decorator {
+        display: flex;
+        width: 100%;
       }
       .previewContainer gr-formatted-text {
         background: #f6f6f6;
@@ -101,42 +119,6 @@
         overflow-y: scroll;
         padding: 1em;
       }
-      .message {
-        border: none;
-        width: 100%;
-      }
-      .labelContainer:not(:first-of-type) {
-        margin-top: .5em;
-      }
-      .labelName {
-        display: inline-block;
-        margin-right: .5em;
-        min-width: 7em;
-        text-align: right;
-        white-space: nowrap;
-        width: 25%;
-      }
-      .labelMessage {
-        color: #666;
-      }
-      iron-selector {
-        display: inline-flex;
-      }
-      iron-selector > gr-button {
-        margin-right: .25em;
-        min-width: 3.5em;
-      }
-      iron-selector > gr-button:first-of-type {
-        border-top-left-radius: 2px;
-        border-bottom-left-radius: 2px;
-      }
-      iron-selector > gr-button:last-of-type {
-        border-top-right-radius: 2px;
-        border-bottom-right-radius: 2px;
-      }
-      iron-selector > gr-button.iron-selected {
-        background-color: #ddd;
-      }
       .draftsContainer {
         flex: 1;
         overflow-y: auto;
@@ -144,13 +126,23 @@
       .draftsContainer h3 {
         margin-top: .25em;
       }
-      .actionsContainer {
-        display: flex;
-        justify-content: space-between;
+      #checkingStatusLabel,
+      #notLatestLabel {
+        margin-left: 1em;
       }
-      .action:link,
-      .action:visited {
-        color: #00e;
+      #checkingStatusLabel {
+        color: #444;
+        font-style: italic;
+      }
+      #notLatestLabel,
+      #savingLabel {
+        color: red;
+      }
+      #savingLabel {
+        display: none;
+      }
+      #savingLabel.saving {
+        display: inline;
       }
       @media screen and (max-width: 50em) {
         :host {
@@ -161,12 +153,11 @@
         }
       }
     </style>
-    <div class="container">
+    <div class="container" tabindex="-1">
       <section class="peopleContainer">
         <div class="peopleList">
           <div class="peopleListLabel">Owner</div>
-          <gr-account-chip account="[[_owner]]">
-          <gr-account-chip>
+          <gr-account-chip account="[[_owner]]"></gr-account-chip>
         </div>
       </section>
       <section class="peopleContainer">
@@ -189,8 +180,9 @@
                 id="ccs"
                 accounts="{{_ccs}}"
                 change="[[change]]"
-                filter="[[filterReviewerSuggestion]]"
+                filter="[[filterCCSuggestion]]"
                 pending-confirmation="{{_ccPendingConfirmation}}"
+                allow-any-input
                 placeholder="Add CC...">
             </gr-account-list>
           </div>
@@ -219,17 +211,22 @@
         </gr-overlay>
       </section>
       <section class="textareaContainer">
-        <iron-autogrow-textarea
-            id="textarea"
-            class="message"
-            autocomplete="on"
-            placeholder="Say something nice..."
-            disabled="{{disabled}}"
-            rows="4"
-            max-rows="15"
-            bind-value="{{draft}}"
-            on-bind-value-changed="_handleHeightChanged">
-        </iron-autogrow-textarea>
+        <gr-endpoint-decorator name="reply-text">
+          <gr-textarea
+              id="textarea"
+              class="message"
+              autocomplete="on"
+              placeholder=[[_messagePlaceholder]]
+              fixed-position-dropdown
+              hide-border="true"
+              monospace="true"
+              disabled="{{disabled}}"
+              rows="4"
+              max-rows="15"
+              text="{{draft}}"
+              on-bind-value-changed="_handleHeightChanged">
+          </gr-textarea>
+        </gr-endpoint-decorator>
       </section>
       <section class="previewContainer">
         <label>
@@ -242,40 +239,71 @@
             config="[[projectConfig.commentlinks]]"></gr-formatted-text>
       </section>
       <section class="labelsContainer">
-        <template is="dom-repeat" items="[[_labels]]" as="label">
-          <div class="labelContainer">
-            <span class="labelName">[[label.name]]</span>
-            <iron-selector data-label$="[[label.name]]"
-                selected="[[_computeIndexOfLabelValue(change.labels, permittedLabels, label)]]"
-                hidden$="[[!_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
-              <template is="dom-repeat"
-                  items="[[_computePermittedLabelValues(permittedLabels, label.name)]]"
-                  as="value">
-                <gr-button has-tooltip data-value$="[[value]]"
-                    title$="[[_computeLabelValueTitle(change.labels, label.name, value)]]">[[value]]</gr-button>
-              </template>
-            </iron-selector>
-            <span class="labelMessage"
-                hidden$="[[_computeAnyPermittedLabelValues(permittedLabels, label.name)]]">
-              You don't have permission to edit this label.
-            </span>
-          </div>
-        </template>
+        <gr-label-scores
+            id="labelScores"
+            account="[[_account]]"
+            change="[[change]]"
+            on-labels-changed="_handleLabelsChanged"
+            permitted-labels=[[permittedLabels]]></gr-label-scores>
       </section>
       <section class="draftsContainer" hidden$="[[_computeHideDraftList(diffDrafts)]]">
-        <h3>[[_computeDraftsTitle(diffDrafts)]]</h3>
+        <div class="includeComments">
+          <input type="checkbox" id="includeComments"
+              checked="{{_includeComments::change}}">
+          <label for="includeComments">Publish [[_computeDraftsTitle(diffDrafts)]]</label>
+        </div>
         <gr-comment-list
+            id="commentList"
             comments="[[diffDrafts]]"
             change-num="[[change._number]]"
             project-config="[[projectConfig]]"
-            patch-num="[[patchNum]]"></gr-comment-list>
+            patch-num="[[patchNum]]"
+            hidden$="[[!_includeComments]]"></gr-comment-list>
+        <span
+            id="savingLabel"
+            class$="[[_computeSavingLabelClass(_savingComments)]]">
+          Saving comments...
+        </span>
       </section>
-      <section class="actionsContainer">
-        <gr-button primary class="action send" on-tap="_sendTapHandler">Send</gr-button>
-        <gr-button
-            id="cancelButton"
-            class="action cancel"
-            on-tap="_cancelTapHandler">Cancel</gr-button>
+      <section class="actions">
+        <div class="left">
+          <template is="dom-if" if="[[canBeStarted]]">
+            <gr-button
+                link
+                tertiary
+                disabled="[[_isState(knownLatestState, 'not-latest')]]"
+                class="action save"
+                has-tooltip
+                title="[[_saveTooltip]]"
+                on-tap="_saveTapHandler">Save</gr-button>
+          </template>
+          <span
+              id="checkingStatusLabel"
+              hidden$="[[!_isState(knownLatestState, 'checking')]]">
+            Checking whether patch [[patchNum]] is latest...
+          </span>
+          <span
+              id="notLatestLabel"
+              hidden$="[[!_isState(knownLatestState, 'not-latest')]]">
+            Patch [[patchNum]] is not latest.
+            <gr-button link on-tap="_reload">Reload</gr-button>
+          </span>
+        </div>
+        <div class="right">
+          <gr-button
+              link
+              id="cancelButton"
+              class="action cancel"
+              on-tap="_cancelTapHandler">Cancel</gr-button>
+          <gr-button
+              link
+              primary
+              disabled="[[_computeSendButtonDisabled(knownLatestState, _sendButtonLabel, diffDrafts, draft, _reviewersMutated, _labelsChanged, _includeComments)]]"
+              class="action send"
+              has-tooltip
+              title$="[[_computeSendButtonTooltip(canBeStarted)]]"
+              on-tap="_sendTapHandler">[[_sendButtonLabel]]</gr-button>
+        </div>
       </section>
     </div>
     <gr-js-api-interface id="jsAPI"></gr-js-api-interface>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 4c35f38..a1719da 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -14,20 +14,41 @@
 (function() {
   'use strict';
 
-  var STORAGE_DEBOUNCE_INTERVAL_MS = 400;
+  const STORAGE_DEBOUNCE_INTERVAL_MS = 400;
 
-  var FocusTarget = {
+  const FocusTarget = {
     ANY: 'any',
     BODY: 'body',
     CCS: 'cc',
     REVIEWERS: 'reviewers',
   };
 
-  var ReviewerTypes = {
+  const ReviewerTypes = {
     REVIEWER: 'REVIEWER',
     CC: 'CC',
   };
 
+  const LatestPatchState = {
+    LATEST: 'latest',
+    CHECKING: 'checking',
+    NOT_LATEST: 'not-latest',
+  };
+
+  const ButtonLabels = {
+    START_REVIEW: 'Start review',
+    SEND: 'Send',
+  };
+
+  const ButtonTooltips = {
+    SAVE: 'Save reply but do not send',
+    START_REVIEW: 'Mark as ready for review and send reply',
+    SEND: 'Send reply',
+  };
+
+  // TODO(logan): Remove once the fix for issue 6841 is stable on
+  // googlesource.com.
+  const START_REVIEW_MESSAGE = 'This change is ready for review.';
+
   Polymer({
     is: 'gr-reply-dialog',
 
@@ -50,9 +71,29 @@
      * @event autogrow
      */
 
+    /**
+     * Fires to show an alert when a send is attempted on the non-latest patch.
+     *
+     * @event show-alert
+     */
+
+    /**
+     * Fires when the reply dialog believes that the server side diff drafts
+     * have been updated and need to be refreshed.
+     *
+     * @event comment-refresh
+     */
+
     properties: {
+      /**
+       * @type {{ _number: number, removable_reviewers: Array }}
+       */
       change: Object,
       patchNum: String,
+      canBeStarted: {
+        type: Boolean,
+        value: false,
+      },
       disabled: {
         type: Boolean,
         value: false,
@@ -67,30 +108,59 @@
         type: String,
         value: '',
       },
-      diffDrafts: Object,
+      diffDrafts: {
+        type: Object,
+        observer: '_handleHeightChanged',
+      },
+      /** @type {!Function} */
       filterReviewerSuggestion: {
         type: Function,
-        value: function() {
-          return this._filterReviewerSuggestion.bind(this);
+        value() {
+          return this._filterReviewerSuggestionGenerator(false);
+        },
+      },
+      /** @type {!Function} */
+      filterCCSuggestion: {
+        type: Function,
+        value() {
+          return this._filterReviewerSuggestionGenerator(true);
         },
       },
       permittedLabels: Object,
+      /**
+       * @type {{ note_db_enabled: boolean }}
+       */
       serverConfig: Object,
+      /**
+       * @type {{ commentlinks: Array }}
+       */
       projectConfig: Object,
+      knownLatestState: String,
+      underReview: {
+        type: Boolean,
+        value: true,
+      },
 
       _account: Object,
       _ccs: Array,
+      /** @type {?Object} */
       _ccPendingConfirmation: {
         type: Object,
         observer: '_reviewerPendingConfirmationUpdated',
       },
-      _labels: {
-        type: Array,
-        computed: '_computeLabels(change.labels.*, _account)',
+      _messagePlaceholder: {
+        type: String,
+        computed: '_computeMessagePlaceholder(canBeStarted)',
       },
       _owner: Object,
+      /** @type {?} */
       _pendingConfirmationDetails: Object,
+      _includeComments: {
+        type: Boolean,
+        value: true,
+      },
       _reviewers: Array,
+      /** @type {?Object} */
       _reviewerPendingConfirmation: {
         type: Object,
         observer: '_reviewerPendingConfirmationUpdated',
@@ -107,99 +177,180 @@
           REVIEWER: [],
         },
       },
+      _sendButtonLabel: {
+        type: String,
+        computed: '_computeSendButtonLabel(canBeStarted)',
+      },
+      _ccsEnabled: {
+        type: Boolean,
+        computed: '_computeCCsEnabled(serverConfig)',
+      },
+      _savingComments: Boolean,
+      _reviewersMutated: {
+        type: Boolean,
+        value: false,
+      },
+      _labelsChanged: {
+        type: Boolean,
+        value: false,
+      },
+      _saveTooltip: {
+        type: String,
+        value: ButtonTooltips.SAVE,
+        readOnly: true,
+      },
     },
 
-    FocusTarget: FocusTarget,
+    FocusTarget,
+
+    // TODO(logan): Remove once the fix for issue 6841 is stable on
+    // googlesource.com.
+    START_REVIEW_MESSAGE,
 
     behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
       Gerrit.RESTClientBehavior,
     ],
 
+    keyBindings: {
+      'esc': '_handleEscKey',
+      'ctrl+enter meta+enter': '_handleEnterKey',
+    },
+
     observers: [
       '_changeUpdated(change.reviewers.*, change.owner, serverConfig)',
       '_ccsChanged(_ccs.splices)',
       '_reviewersChanged(_reviewers.splices)',
     ],
 
-    attached: function() {
-      this._getAccount().then(function(account) {
+    attached() {
+      this._getAccount().then(account => {
         this._account = account || {};
-      }.bind(this));
+      });
     },
 
-    ready: function() {
+    ready() {
       this.$.jsAPI.addElement(this.$.jsAPI.Element.REPLY_DIALOG, this);
     },
 
-    open: function(opt_focusTarget) {
+    open(opt_focusTarget) {
+      this.knownLatestState = LatestPatchState.CHECKING;
+      this.fetchIsLatestKnown(this.change, this.$.restAPI)
+          .then(isUpToDate => {
+            this.knownLatestState = isUpToDate ?
+                LatestPatchState.LATEST : LatestPatchState.NOT_LATEST;
+          });
+
       this._focusOn(opt_focusTarget);
       if (!this.draft || !this.draft.length) {
         this.draft = this._loadStoredDraft();
       }
+      if (this.$.restAPI.hasPendingDiffDrafts()) {
+        this._savingComments = true;
+        this.$.restAPI.awaitPendingDiffDrafts().then(() => {
+          this.fire('comment-refresh');
+          this._savingComments = false;
+        });
+      }
     },
 
-    focus: function() {
+    focus() {
       this._focusOn(FocusTarget.ANY);
     },
 
-    getFocusStops: function() {
+    getFocusStops() {
       return {
         start: this.$.reviewers.focusStart,
         end: this.$.cancelButton,
       };
     },
 
-    setLabelValue: function(label, value) {
-      var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
-      // The selector may not be present if it’s not at the latest patch set.
+    setLabelValue(label, value) {
+      const selectorEl =
+          this.$.labelScores.$$(`gr-label-score-row[name="${label}"]`);
       if (!selectorEl) { return; }
-      var item = selectorEl.$$('gr-button[data-value="' + value + '"]');
-      if (!item) { return; }
-      selectorEl.selectIndex(selectorEl.indexOf(item));
+      selectorEl.setSelectedValue(value);
     },
 
-    _ccsChanged: function(splices) {
+    getLabelValue(label) {
+      const selectorEl =
+          this.$.labelScores.$$(`gr-label-score-row[name="${label}"]`);
+      if (!selectorEl) { return null; }
+
+      return selectorEl.selectedValue;
+    },
+
+    _handleEscKey(e) {
+      this.cancel();
+    },
+
+    _handleEnterKey(e) {
+      this._submit();
+    },
+
+    _ccsChanged(splices) {
       if (splices && splices.indexSplices) {
+        this._reviewersMutated = true;
         this._processReviewerChange(splices.indexSplices, ReviewerTypes.CC);
       }
     },
 
-    _reviewersChanged: function(splices) {
+    _reviewersChanged(splices) {
       if (splices && splices.indexSplices) {
+        this._reviewersMutated = true;
         this._processReviewerChange(splices.indexSplices,
             ReviewerTypes.REVIEWER);
+        let key;
+        let index;
+        let account;
+        // Remove any accounts that already exist as a CC.
+        for (const splice of splices.indexSplices) {
+          for (const addedKey of splice.addedKeys) {
+            account = this.get(`_reviewers.${addedKey}`);
+            key = this._accountOrGroupKey(account);
+            index = this._ccs.findIndex(
+                account => this._accountOrGroupKey(account) === key);
+            if (index >= 0) {
+              this.splice('_ccs', index, 1);
+              const message = (account.name || account.email || key) +
+                  ' moved from CC to reviewer.';
+              this.fire('show-alert', {message});
+            }
+          }
+        }
       }
     },
 
-    _processReviewerChange: function(indexSplices, type) {
-      indexSplices.forEach(function(splice) {
-        splice.removed.forEach(function(account) {
+    _processReviewerChange(indexSplices, type) {
+      for (const splice of indexSplices) {
+        for (const account of splice.removed) {
           if (!this._reviewersPendingRemove[type]) {
             console.err('Invalid type ' + type + ' for reviewer.');
             return;
           }
           this._reviewersPendingRemove[type].push(account);
-        }.bind(this));
-      }.bind(this));
+        }
+      }
     },
 
     /**
      * Resets the state of the _reviewersPendingRemove object, and removes
      * accounts if necessary.
      *
-     * @param {Boolean} isCancel true if the action is a cancel.
-     * @param {Object} opt_accountIdsTransferred map of account IDs that must
+     * @param {boolean} isCancel true if the action is a cancel.
+     * @param {Object=} opt_accountIdsTransferred map of account IDs that must
      *     not be removed, because they have been readded in another state.
      */
-    _purgeReviewersPendingRemove: function(isCancel,
-        opt_accountIdsTransferred) {
-      var reviewerArr;
-      var keep = opt_accountIdsTransferred || {};
-      for (var type in this._reviewersPendingRemove) {
+    _purgeReviewersPendingRemove(isCancel, opt_accountIdsTransferred) {
+      let reviewerArr;
+      const keep = opt_accountIdsTransferred || {};
+      for (const type in this._reviewersPendingRemove) {
         if (this._reviewersPendingRemove.hasOwnProperty(type)) {
           if (!isCancel) {
             reviewerArr = this._reviewersPendingRemove[type];
-            for (var i = 0; i < reviewerArr.length; i++) {
+            for (let i = 0; i < reviewerArr.length; i++) {
               if (!keep[reviewerArr[i]._account_id]) {
                 this._removeAccount(reviewerArr[i], type);
               }
@@ -214,121 +365,168 @@
      * Removes an account from the change, both on the backend and the client.
      * Does nothing if the account is a pending addition.
      *
-     * @param {Object} account
-     * @param {ReviewerTypes} type
+     * @param {!Object} account
+     * @param {string} type
+     *
+     * * TODO(beckysiegel) submit Polymer PR
+     * @suppress {checkTypes}
      */
-    _removeAccount: function(account, type) {
+    _removeAccount(account, type) {
       if (account._pendingAdd) { return; }
 
       return this.$.restAPI.removeChangeReviewer(this.change._number,
-          account._account_id).then(function(response) {
-        if (!response.ok) { return response; }
+          account._account_id).then(response => {
+            if (!response.ok) { return response; }
 
-        var reviewers = this.change.reviewers[type] || [];
-        for (var i = 0; i < reviewers.length; i++) {
-          if (reviewers[i]._account_id == account._account_id) {
-            this.splice(['change', 'reviewers', type], i, 1);
-            break;
-          }
-        }
-      }.bind(this));
+            const reviewers = this.change.reviewers[type] || [];
+            for (let i = 0; i < reviewers.length; i++) {
+              if (reviewers[i]._account_id == account._account_id) {
+                this.splice(['change', 'reviewers', type], i, 1);
+                break;
+              }
+            }
+          });
     },
 
-    _mapReviewer: function(reviewer) {
-      var reviewerId;
-      var confirmed;
+    _mapReviewer(reviewer) {
+      let reviewerId;
+      let confirmed;
       if (reviewer.account) {
-        reviewerId = reviewer.account._account_id;
+        reviewerId = reviewer.account._account_id || reviewer.account.email;
       } else if (reviewer.group) {
         reviewerId = reviewer.group.id;
         confirmed = reviewer.group.confirmed;
       }
-      return {reviewer: reviewerId, confirmed: confirmed};
+      return {reviewer: reviewerId, confirmed};
     },
 
-    send: function() {
-      var obj = {
-        drafts: 'PUBLISH_ALL_REVISIONS',
-        labels: {},
+    send(includeComments, startReview) {
+      if (this.knownLatestState === 'not-latest') {
+        this.fire('show-alert',
+            {message: 'Cannot reply to non-latest patch.'});
+        return Promise.resolve({});
+      }
+
+      const labels = this.$.labelScores.getLabelValues();
+
+      const obj = {
+        drafts: includeComments ? 'PUBLISH_ALL_REVISIONS' : 'KEEP',
+        labels,
       };
 
-      for (var label in this.permittedLabels) {
-        if (!this.permittedLabels.hasOwnProperty(label)) { continue; }
-
-        var selectorEl = this.$$('iron-selector[data-label="' + label + '"]');
-
-        // The user may have not voted on this label.
-        if (!selectorEl || !selectorEl.selectedItem) { continue; }
-
-        var selectedVal = selectorEl.selectedItem.getAttribute('data-value');
-        selectedVal = parseInt(selectedVal, 10);
-
-        // Only send the selection if the user changed it.
-        var prevVal = this._getVoteForAccount(this.change.labels, label,
-            this._account);
-        if (prevVal !== null) {
-          prevVal = parseInt(prevVal, 10);
-        }
-        if (selectedVal !== prevVal) {
-          obj.labels[label] = selectedVal;
-        }
+      if (startReview) {
+        obj.ready = true;
       }
+
       if (this.draft != null) {
         obj.message = this.draft;
       }
 
-      var accountAdditions = {};
-      obj.reviewers = this.$.reviewers.additions().map(function(reviewer) {
+      const accountAdditions = {};
+      obj.reviewers = this.$.reviewers.additions().map(reviewer => {
         if (reviewer.account) {
           accountAdditions[reviewer.account._account_id] = true;
         }
         return this._mapReviewer(reviewer);
-      }.bind(this));
-      if (this.serverConfig.note_db_enabled) {
-        this.$$('#ccs').additions().forEach(function(reviewer) {
+      });
+      const ccsEl = this.$$('#ccs');
+      if (ccsEl) {
+        for (let reviewer of ccsEl.additions()) {
           if (reviewer.account) {
             accountAdditions[reviewer.account._account_id] = true;
           }
           reviewer = this._mapReviewer(reviewer);
           reviewer.state = 'CC';
           obj.reviewers.push(reviewer);
-        }.bind(this));
+        }
       }
 
       this.disabled = true;
 
-      var errFn = this._handle400Error.bind(this);
-      return this._saveReview(obj, errFn).then(function(response) {
-        if (!response || !response.ok) {
-          return response;
+      if (obj.ready && !obj.message) {
+        // TODO(logan): The server currently doesn't send email in this case.
+        // Insert a dummy message to force an email to be sent. Remove this
+        // once the fix for issue 6841 is stable on googlesource.com.
+        obj.message = START_REVIEW_MESSAGE;
+      }
+
+      const errFn = this._handle400Error.bind(this);
+      return this._saveReview(obj, errFn).then(response => {
+        if (!response) {
+          // Null or undefined response indicates that an error handler
+          // took responsibility, so just return.
+          return {};
         }
+        if (!response.ok) {
+          this.fire('server-error', {response});
+          return {};
+        }
+
+        // TODO(logan): Remove once the required API changes are live and stable
+        // on googlesource.com.
+        return this._maybeSetReady(startReview, response).catch(err => {
+          // We catch error here because we still want to treat this as a
+          // successful review.
+          console.error('error setting ready:', err);
+        }).then(() => {
+          this.draft = '';
+          this._includeComments = true;
+          this.fire('send', null, {bubbles: false});
+          return accountAdditions;
+        });
+      }).then(result => {
         this.disabled = false;
-        this.draft = '';
-        this.fire('send', null, {bubbles: false});
-        return accountAdditions;
-      }.bind(this)).catch(function(err) {
+        return result;
+      }).catch(err => {
         this.disabled = false;
         throw err;
-      }.bind(this));
+      });
     },
 
-    _focusOn: function(section) {
+    /**
+     * Returns a promise resolving to true if review was successfully posted,
+     * false otherwise.
+     *
+     * TODO(logan): Remove this once the required API changes are live and
+     * stable on googlesource.com.
+     */
+    _maybeSetReady(startReview, response) {
+      return this.$.restAPI.getResponseObject(response).then(result => {
+        if (!startReview || result.ready) {
+          return Promise.resolve();
+        }
+        // We don't have confirmation that review was started, so attempt to
+        // start review explicitly.
+        return this.$.restAPI.startReview(
+            this.change._number, null, response => {
+              // If we see a 409 response code, then that means the server
+              // *does* support moving from WIP->ready when posting a
+              // review. Only alert user for non-409 failures.
+              if (response.status !== 409) {
+                this.fire('server-error', {response});
+              }
+            });
+      });
+    },
+
+    _focusOn(section) {
       if (section === FocusTarget.ANY) {
         section = this._chooseFocusTarget();
       }
       if (section === FocusTarget.BODY) {
-        var textarea = this.$.textarea;
-        textarea.async(textarea.textarea.focus.bind(textarea.textarea));
+        const textarea = this.$.textarea;
+        textarea.async(textarea.getNativeTextarea()
+            .focus.bind(textarea.getNativeTextarea()));
       } else if (section === FocusTarget.REVIEWERS) {
-        var reviewerEntry = this.$.reviewers.focusStart;
+        const reviewerEntry = this.$.reviewers.focusStart;
         reviewerEntry.async(reviewerEntry.focus);
       } else if (section === FocusTarget.CCS) {
-        var ccEntry = this.$$('#ccs').focusStart;
+        const ccEntry = this.$$('#ccs').focusStart;
         ccEntry.async(ccEntry.focus);
       }
     },
 
-    _chooseFocusTarget: function() {
+    _chooseFocusTarget() {
       // If we are the owner and the reviewers field is empty, focus on that.
       if (this._account && this.change && this.change.owner &&
           this._account._account_id === this.change.owner._account_id &&
@@ -340,7 +538,7 @@
       return FocusTarget.BODY;
     },
 
-    _handle400Error: function(response) {
+    _handle400Error(response) {
       // A call to _saveReview could fail with a server error if erroneous
       // reviewers were requested. This is signalled with a 400 Bad Request
       // status. The default gr-rest-api-interface error handling would
@@ -356,126 +554,87 @@
 
       if (response.status !== 400) {
         // This is all restAPI does when there is no custom error handling.
-        this.fire('server-error', {response: response});
+        this.fire('server-error', {response});
         return response;
       }
 
       // Process the response body, format a better error message, and fire
       // an event for gr-event-manager to display.
-      var jsonPromise = this.$.restAPI.getResponseObject(response);
-      return jsonPromise.then(function(result) {
-        var errors = [];
-        ['reviewers', 'ccs'].forEach(function(state) {
-          for (var input in result[state]) {
-            var reviewer = result[state][input];
-            if (!!reviewer.error) {
+      const jsonPromise = this.$.restAPI.getResponseObject(response);
+      return jsonPromise.then(result => {
+        const errors = [];
+        for (const state of ['reviewers', 'ccs']) {
+          if (!result.hasOwnProperty(state)) { continue; }
+          for (const reviewer of Object.values(result[state])) {
+            if (reviewer.error) {
               errors.push(reviewer.error);
             }
           }
-        });
+        }
         response = {
           ok: false,
           status: response.status,
-          text: function() { return Promise.resolve(errors.join(', ')); },
+          text() { return Promise.resolve(errors.join(', ')); },
         };
-        this.fire('server-error', {response: response});
-      }.bind(this));
+        this.fire('server-error', {response});
+      });
     },
 
-    _computeHideDraftList: function(drafts) {
+    _computeHideDraftList(drafts) {
       return Object.keys(drafts || {}).length == 0;
     },
 
-    _computeDraftsTitle: function(drafts) {
-      var total = 0;
-      for (var file in drafts) {
-        total += drafts[file].length;
+    _computeDraftsTitle(drafts) {
+      let total = 0;
+      for (const file in drafts) {
+        if (drafts.hasOwnProperty(file)) {
+          total += drafts[file].length;
+        }
       }
       if (total == 0) { return ''; }
       if (total == 1) { return '1 Draft'; }
       if (total > 1) { return total + ' Drafts'; }
     },
 
-    _computeLabelValueTitle: function(labels, label, value) {
-      return labels[label] && labels[label].values[value];
+    _computeMessagePlaceholder(canBeStarted) {
+      return canBeStarted ?
+        'Add a note for your reviewers...' :
+        'Say something nice...';
     },
 
-    _computeLabels: function(labelRecord) {
-      var labelsObj = labelRecord.base;
-      if (!labelsObj) { return []; }
-      return Object.keys(labelsObj).sort().map(function(key) {
-        return {
-          name: key,
-          value: this._getVoteForAccount(labelsObj, key, this._account),
-        };
-      }.bind(this));
-    },
-
-    _getVoteForAccount: function(labels, labelName, account) {
-      var votes = labels[labelName];
-      if (votes.all && votes.all.length > 0) {
-        for (var i = 0; i < votes.all.length; i++) {
-          if (votes.all[i]._account_id == account._account_id) {
-            return votes.all[i].value;
-          }
-        }
-      }
-      return null;
-    },
-
-    _computeIndexOfLabelValue: function(labels, permittedLabels, label) {
-      if (!labels[label.name]) { return null; }
-      var labelValue = label.value;
-      var len = permittedLabels[label.name] != null ?
-          permittedLabels[label.name].length : 0;
-      for (var i = 0; i < len; i++) {
-        var val = parseInt(permittedLabels[label.name][i], 10);
-        if (val == labelValue) {
-          return i;
-        }
-      }
-      return null;
-    },
-
-    _computePermittedLabelValues: function(permittedLabels, label) {
-      return permittedLabels[label];
-    },
-
-    _computeAnyPermittedLabelValues: function(permittedLabels, label) {
-      return permittedLabels.hasOwnProperty(label);
-    },
-
-    _changeUpdated: function(changeRecord, owner, serverConfig) {
+    _changeUpdated(changeRecord, owner, serverConfig) {
       this._rebuildReviewerArrays(changeRecord.base, owner, serverConfig);
     },
 
-    _rebuildReviewerArrays: function(change, owner, serverConfig) {
+    _rebuildReviewerArrays(change, owner, serverConfig) {
       this._owner = owner;
 
-      var reviewers = [];
-      var ccs = [];
+      let reviewers = [];
+      const ccs = [];
 
-      for (var key in change) {
-        if (key !== 'REVIEWER' && key !== 'CC') {
-          console.warn('unexpected reviewer state:', key);
-          continue;
+      for (const key in change) {
+        if (change.hasOwnProperty(key)) {
+          if (key !== 'REVIEWER' && key !== 'CC') {
+            console.warn('unexpected reviewer state:', key);
+            continue;
+          }
+          for (const entry of change[key]) {
+            if (entry._account_id === owner._account_id) {
+              continue;
+            }
+            switch (key) {
+              case 'REVIEWER':
+                reviewers.push(entry);
+                break;
+              case 'CC':
+                ccs.push(entry);
+                break;
+            }
+          }
         }
-        change[key].forEach(function(entry) {
-          if (entry._account_id === owner._account_id) {
-            return;
-          }
-          switch (key) {
-            case 'REVIEWER':
-              reviewers.push(entry);
-              break;
-            case 'CC':
-              ccs.push(entry);
-              break;
-          }
-        });
       }
 
-      if (serverConfig.note_db_enabled) {
+      if (this._ccsEnabled) {
         this._ccs = ccs;
       } else {
         this._ccs = [];
@@ -484,58 +643,94 @@
       this._reviewers = reviewers;
     },
 
-    _accountOrGroupKey: function(entry) {
+    _accountOrGroupKey(entry) {
       return entry.id || entry._account_id;
     },
 
-    _filterReviewerSuggestion: function(suggestion) {
-      var entry;
-      if (suggestion.account) {
-        entry = suggestion.account;
-      } else if (suggestion.group) {
-        entry = suggestion.group;
-      } else {
-        console.warn('received suggestion that was neither account nor group:',
-            suggestion);
-      }
-      if (entry._account_id === this._owner._account_id) {
-        return false;
-      }
+    /**
+     * Generates a function to filter out reviewer/CC entries. When isCCs is
+     * truthy, the function filters out entries that already exist in this._ccs.
+     * When falsy, the function filters entries that exist in this._reviewers.
+     * @param {boolean} isCCs
+     * @return {!Function}
+     */
+    _filterReviewerSuggestionGenerator(isCCs) {
+      return suggestion => {
+        let entry;
+        if (suggestion.account) {
+          entry = suggestion.account;
+        } else if (suggestion.group) {
+          entry = suggestion.group;
+        } else {
+          console.warn(
+              'received suggestion that was neither account nor group:',
+              suggestion);
+        }
+        if (entry._account_id === this._owner._account_id) {
+          return false;
+        }
 
-      var key = this._accountOrGroupKey(entry);
-      var finder = function(entry) {
-        return this._accountOrGroupKey(entry) === key;
-      }.bind(this);
-
-      return this._reviewers.find(finder) === undefined &&
-          this._ccs.find(finder) === undefined;
+        const key = this._accountOrGroupKey(entry);
+        const finder = entry => this._accountOrGroupKey(entry) === key;
+        if (isCCs) {
+          return this._ccs.find(finder) === undefined;
+        }
+        return this._reviewers.find(finder) === undefined;
+      };
     },
 
-    _getAccount: function() {
+    _getAccount() {
       return this.$.restAPI.getAccount();
     },
 
-    _cancelTapHandler: function(e) {
+    _cancelTapHandler(e) {
       e.preventDefault();
+      this.cancel();
+    },
+
+    cancel() {
       this.fire('cancel', null, {bubbles: false});
+      this.$.textarea.closeDropdown();
       this._purgeReviewersPendingRemove(true);
       this._rebuildReviewerArrays(this.change.reviewers, this._owner,
           this.serverConfig);
     },
 
-    _sendTapHandler: function(e) {
+    _saveTapHandler(e) {
       e.preventDefault();
-      this.send().then(function(keep) {
-        this._purgeReviewersPendingRemove(false, keep);
-      }.bind(this));
+      if (this._ccsEnabled && !this.$$('#ccs').submitEntryText()) {
+        // Do not proceed with the save if there is an invalid email entry in
+        // the text field of the CC entry.
+        return;
+      }
+      this.send(this._includeComments, false).then(keepReviewers => {
+        this._purgeReviewersPendingRemove(false, keepReviewers);
+      });
     },
 
-    _saveReview: function(review, opt_errFn) {
+    _sendTapHandler(e) {
+      e.preventDefault();
+      this._submit();
+    },
+
+    _submit() {
+      if (this._ccsEnabled && !this.$$('#ccs').submitEntryText()) {
+        // Do not proceed with the send if there is an invalid email entry in
+        // the text field of the CC entry.
+        return;
+      }
+      return this.send(this._includeComments, this.canBeStarted)
+          .then(keepReviewers => {
+            this._purgeReviewersPendingRemove(false, keepReviewers);
+          });
+    },
+
+    _saveReview(review, opt_errFn) {
       return this.$.restAPI.saveChangeReview(this.change._number, this.patchNum,
           review, opt_errFn);
     },
 
-    _reviewerPendingConfirmationUpdated: function(reviewer) {
+    _reviewerPendingConfirmationUpdated(reviewer) {
       if (reviewer === null) {
         this.$.reviewerConfirmationOverlay.close();
       } else {
@@ -545,7 +740,7 @@
       }
     },
 
-    _confirmPendingReviewer: function() {
+    _confirmPendingReviewer() {
       if (this._ccPendingConfirmation) {
         this.$$('#ccs').confirmGroup(this._ccPendingConfirmation.group);
         this._focusOn(FocusTarget.CCS);
@@ -555,32 +750,32 @@
       }
     },
 
-    _cancelPendingReviewer: function() {
+    _cancelPendingReviewer() {
       this._ccPendingConfirmation = null;
       this._reviewerPendingConfirmation = null;
 
-      var target =
+      const target =
           this._ccPendingConfirmation ? FocusTarget.CCS : FocusTarget.REVIEWERS;
       this._focusOn(target);
     },
 
-    _getStorageLocation: function() {
+    _getStorageLocation() {
       // Tests trigger this method without setting change.
       if (!this.change) { return {}; }
       return {
         changeNum: this.change._number,
-        patchNum: this.patchNum,
+        patchNum: '@change',
         path: '@change',
       };
     },
 
-    _loadStoredDraft: function() {
-      var draft = this.$.storage.getDraftComment(this._getStorageLocation());
+    _loadStoredDraft() {
+      const draft = this.$.storage.getDraftComment(this._getStorageLocation());
       return draft ? draft.message : '';
     },
 
-    _draftChanged: function(newDraft, oldDraft) {
-      this.debounce('store', function() {
+    _draftChanged(newDraft, oldDraft) {
+      this.debounce('store', () => {
         if (!newDraft.length && oldDraft) {
           // If the draft has been modified to be empty, then erase the storage
           // entry.
@@ -592,11 +787,50 @@
       }, STORAGE_DEBOUNCE_INTERVAL_MS);
     },
 
-    _handleHeightChanged: function(e) {
-      // If the textarea resizes, we need to re-fit the overlay.
-      this.debounce('autogrow', function() {
-        this.fire('autogrow');
-      });
+    _handleHeightChanged(e) {
+      this.fire('autogrow');
+    },
+
+    _handleLabelsChanged() {
+      this._labelsChanged = Object.keys(
+          this.$.labelScores.getLabelValues()).length !== 0;
+    },
+
+    _isState(knownLatestState, value) {
+      return knownLatestState === value;
+    },
+
+    _reload() {
+      // Load the current change without any patch range.
+      location.href = this.getBaseUrl() + '/c/' + this.change._number;
+    },
+
+    _computeSendButtonLabel(canBeStarted) {
+      return canBeStarted ? ButtonLabels.START_REVIEW : ButtonLabels.SEND;
+    },
+
+    _computeSendButtonTooltip(canBeStarted) {
+      return canBeStarted ? ButtonTooltips.START_REVIEW : ButtonTooltips.SEND;
+    },
+
+    _computeCCsEnabled(serverConfig) {
+      return serverConfig && serverConfig.note_db_enabled;
+    },
+
+    _computeSavingLabelClass(savingComments) {
+      return savingComments ? 'saving' : '';
+    },
+
+    _computeSendButtonDisabled(knownLatestState, buttonLabel, drafts, text,
+        reviewersMutated, labelsChanged, includeComments) {
+      if (this._isState(knownLatestState, LatestPatchState.NOT_LATEST)) {
+        return true;
+      }
+      if (buttonLabel === ButtonLabels.START_REVIEW) {
+        return false;
+      }
+      const hasDrafts = includeComments && Object.keys(drafts).length;
+      return !hasDrafts && !text.length && !reviewersMutated && !labelsChanged;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 5aa5848..278f2c6 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-reply-dialog.html">
 
 <script>void(0);</script>
@@ -33,36 +32,38 @@
 </test-fixture>
 
 <script>
-  suite('gr-reply-dialog tests', function() {
-    var element;
-    var changeNum;
-    var patchNum;
+  suite('gr-reply-dialog tests', () => {
+    let element;
+    let changeNum;
+    let patchNum;
 
-    var sandbox;
-    var getDraftCommentStub;
-    var setDraftCommentStub;
-    var eraseDraftCommentStub;
+    let sandbox;
+    let getDraftCommentStub;
+    let setDraftCommentStub;
+    let eraseDraftCommentStub;
 
-    var lastId = 0;
-    var makeAccount = function() { return {_account_id: lastId++}; };
-    var makeGroup = function() { return {id: lastId++}; };
+    let lastId = 0;
+    const makeAccount = function() { return {_account_id: lastId++}; };
+    const makeGroup = function() { return {id: lastId++}; };
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
 
       changeNum = 42;
       patchNum = 1;
 
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getAccount: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
+        getAccount() { return Promise.resolve({}); },
+        getChange() { return Promise.resolve({}); },
+        getChangeSuggestedReviewers() { return Promise.resolve([]); },
       });
 
       element = fixture('basic');
       element.change = {
         _number: changeNum,
         labels: {
-          Verified: {
+          'Verified': {
             values: {
               '-1': 'Fails',
               ' 0': 'No score',
@@ -89,7 +90,7 @@
           ' 0',
           '+1',
         ],
-        Verified: [
+        'Verified': [
           '-1',
           ' 0',
           '+1',
@@ -102,85 +103,173 @@
       eraseDraftCommentStub = sandbox.stub(element.$.storage,
           'eraseDraftComment');
 
+      sandbox.stub(element, 'fetchIsLatestKnown',
+          () => { return Promise.resolve(true); });
+
       // Allow the elements created by dom-repeat to be stamped.
       flushAsynchronousOperations();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('changes in label score are reflected in the DOM', function() {
-      element._account = {_account_id: 1};
-      element.set(['change', 'labels', 'Verified', 'all'],
-          [{_account_id: 1, value: -1}]);
-      flushAsynchronousOperations();
-      var selector = element.$$('iron-selector[data-label="Verified"]');
-      assert.equal(selector.selected, 0); // Index 0, value -1
-      element.set(['change', 'labels', 'Verified', 'all'],
-         [{_account_id: 1, value: 1}]);
-      flushAsynchronousOperations();
-      assert.equal(selector.selected, 2); // Index 2, value 1
-    });
+    function stubSaveReview(jsonResponseProducer) {
+      return sandbox.stub(element, '_saveReview', review => {
+        return new Promise((resolve, reject) => {
+          try {
+            const result = jsonResponseProducer(review) || {};
+            const resultStr =
+                element.$.restAPI.JSON_PREFIX + JSON.stringify(result);
+            resolve({
+              ok: true,
+              text() {
+                return Promise.resolve(resultStr);
+              },
+            });
+          } catch (err) {
+            reject(err);
+          }
+        });
+      });
+    }
 
-    test('cancel event', function(done) {
-      element.addEventListener('cancel', function() { done(); });
-      MockInteractions.tap(element.$$('.cancel'));
-    });
-
-    test('label picker', function(done) {
-      element.revisions = {};
-      element.patchNum = '';
-
+    test('default to publishing drafts with reply', done => {
       // Async tick is needed because iron-selector content is distributed and
       // distributed content requires an observer to be set up.
       // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
-      flush(function() {
-        flush(function() {
-          for (var label in element.permittedLabels) {
-            assert.ok(element.$$('iron-selector[data-label="' + label + '"]'),
-                label);
-          }
+      flush(() => {
+        flush(() => {
           element.draft = 'I wholeheartedly disapprove';
-          MockInteractions.tap(element.$$(
-              'iron-selector[data-label="Code-Review"] > ' +
-              'gr-button[data-value="-1"]'));
-          MockInteractions.tap(element.$$(
-              'iron-selector[data-label="Verified"] > ' +
-              'gr-button[data-value="-1"]'));
 
-          var saveReviewStub = sinon.stub(element, '_saveReview',
-              function(review) {
+          stubSaveReview(review => {
             assert.deepEqual(review, {
               drafts: 'PUBLISH_ALL_REVISIONS',
               labels: {
-                'Code-Review': -1,
-                'Verified': -1,
+                'Code-Review': 0,
+                'Verified': 0,
               },
               message: 'I wholeheartedly disapprove',
               reviewers: [],
             });
-            return Promise.resolve({ok: true});
-          });
-
-          element.addEventListener('send', function() {
-            assert.isFalse(element.disabled,
-                'Element should be enabled when done sending reply.');
-            assert.equal(element.draft.length, 0);
-            saveReviewStub.restore();
+            assert.isFalse(element.$.commentList.hidden);
             done();
           });
 
           // This is needed on non-Blink engines most likely due to the ways in
           // which the dom-repeat elements are stamped.
-          flush(function() {
+          flush(() => {
             MockInteractions.tap(element.$$('.send'));
-            assert.isTrue(element.disabled);
           });
         });
       });
     });
 
+    test('keep drafts with reply', done => {
+      MockInteractions.tap(element.$$('#includeComments'));
+      assert.equal(element._includeComments, false);
+
+      // Async tick is needed because iron-selector content is distributed and
+      // distributed content requires an observer to be set up.
+      // Note: Double flush seems to be needed in Safari. {@see Issue 4963}.
+      flush(() => {
+        flush(() => {
+          element.draft = 'I wholeheartedly disapprove';
+
+          stubSaveReview(review => {
+            assert.deepEqual(review, {
+              drafts: 'KEEP',
+              labels: {
+                'Code-Review': 0,
+                'Verified': 0,
+              },
+              message: 'I wholeheartedly disapprove',
+              reviewers: [],
+            });
+            assert.isTrue(element.$.commentList.hidden);
+            done();
+          });
+
+          // This is needed on non-Blink engines most likely due to the ways in
+          // which the dom-repeat elements are stamped.
+          flush(() => {
+            MockInteractions.tap(element.$$('.send'));
+          });
+        });
+      });
+    });
+
+    test('label picker', done => {
+      element.draft = 'I wholeheartedly disapprove';
+      stubSaveReview(review => {
+        assert.deepEqual(review, {
+          drafts: 'PUBLISH_ALL_REVISIONS',
+          labels: {
+            'Code-Review': -1,
+            'Verified': -1,
+          },
+          message: 'I wholeheartedly disapprove',
+          reviewers: [],
+        });
+      });
+
+      sandbox.stub(element.$.labelScores, 'getLabelValues', () => {
+        return {
+          'Code-Review': -1,
+          'Verified': -1,
+        };
+      });
+
+      element.addEventListener('send', () => {
+        // Flush to ensure properties are updated.
+        flush(() => {
+          assert.isFalse(element.disabled,
+              'Element should be enabled when done sending reply.');
+          assert.equal(element.draft.length, 0);
+          done();
+        });
+      });
+
+      // This is needed on non-Blink engines most likely due to the ways in
+      // which the dom-repeat elements are stamped.
+      flush(() => {
+        MockInteractions.tap(element.$$('.send'));
+        assert.isTrue(element.disabled);
+      });
+    });
+
+    test('getlabelValue returns value', done => {
+      flush(() => {
+        element.$$('gr-label-scores').$$(`gr-label-score-row[name="Verified"]`)
+            .setSelectedValue(-1);
+        assert.equal('-1', element.getLabelValue('Verified'));
+        done();
+      });
+    });
+
+    test('getlabelValue when no score is selected', done => {
+      flush(() => {
+        element.$$('gr-label-scores').$$(`gr-label-score-row[name="Code-Review"]`)
+            .setSelectedValue(-1);
+        assert.strictEqual(element.getLabelValue('Verified'), ' 0');
+        done();
+      });
+    });
+
+    test('setlabelValue', () => {
+      element._account = {_account_id: 1};
+      flushAsynchronousOperations();
+      const label = 'Verified';
+      const value = '+1';
+      element.setLabelValue(label, value);
+      flushAsynchronousOperations();
+      const labels = element.$.labelScores.getLabelValues();
+      assert.deepEqual(labels, {
+        'Code-Review': 0,
+        'Verified': 1,
+      });
+    });
+
     function getActiveElement() {
       return Polymer.IronOverlayManager.deepActiveElement;
     }
@@ -191,7 +280,7 @@
     }
 
     function overlayObserver(mode) {
-      return new Promise(function(resolve) {
+      return new Promise(resolve => {
         function listener() {
           element.removeEventListener('iron-overlay-' + mode, listener);
           resolve();
@@ -201,9 +290,9 @@
     }
 
     function testConfirmationDialog(done, cc) {
-      var yesButton =
+      const yesButton =
           element.$$('.reviewerConfirmationButtons gr-button:first-child');
-      var noButton =
+      const noButton =
           element.$$('.reviewerConfirmationButtons gr-button:last-child');
 
       element.serverConfig = {note_db_enabled: true};
@@ -213,19 +302,19 @@
       assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
 
       // Cause the confirmation dialog to display.
-      var observer = overlayObserver('opened');
-      var group = {
+      let observer = overlayObserver('opened');
+      const group = {
         id: 'id',
         name: 'name',
       };
       if (cc) {
         element._ccPendingConfirmation = {
-          group: group,
+          group,
           count: 10,
         };
       } else {
         element._reviewerPendingConfirmation = {
-          group: group,
+          group,
           count: 10,
         };
       }
@@ -241,16 +330,16 @@
             element._pendingConfirmationDetails);
       }
 
-      observer.then(function() {
+      observer.then(() => {
         assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
         observer = overlayObserver('closed');
-        var expected = 'Group name has 10 members';
+        const expected = 'Group name has 10 members';
         assert.notEqual(
             element.$.reviewerConfirmationOverlay.innerText.indexOf(expected),
             -1);
         MockInteractions.tap(noButton); // close the overlay
         return observer;
-      }).then(function() {
+      }).then(() => {
         assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
 
         // We should be focused on account entry input.
@@ -264,24 +353,24 @@
         observer = overlayObserver('opened');
         if (cc) {
           element._ccPendingConfirmation = {
-            group: group,
+            group,
             count: 10,
           };
         } else {
           element._reviewerPendingConfirmation = {
-            group: group,
+            group,
             count: 10,
           };
         }
         return observer;
-      }).then(function() {
+      }).then(() => {
         assert.isTrue(isVisible(element.$.reviewerConfirmationOverlay));
         observer = overlayObserver('closed');
         MockInteractions.tap(yesButton); // Confirm the group.
         return observer;
-      }).then(function() {
+      }).then(() => {
         assert.isFalse(isVisible(element.$.reviewerConfirmationOverlay));
-        var additions = cc ?
+        const additions = cc ?
             element.$$('#ccs').additions() :
             element.$.reviewers.additions();
         assert.deepEqual(
@@ -301,41 +390,41 @@
         // We should be focused on account entry input.
         assert.equal(getActiveElement().id, 'input');
       }).then(done);
-    };
+    }
 
-    test('cc confirmation', function(done) {
+    test('cc confirmation', done => {
       testConfirmationDialog(done, true);
     });
 
-    test('reviewer confirmation', function(done) {
+    test('reviewer confirmation', done => {
       testConfirmationDialog(done, false);
     });
 
-    test('_getStorageLocation', function() {
-      var actual = element._getStorageLocation();
+    test('_getStorageLocation', () => {
+      const actual = element._getStorageLocation();
       assert.equal(actual.changeNum, changeNum);
-      assert.equal(actual.patchNum, patchNum);
+      assert.equal(actual.patchNum, '@change');
       assert.equal(actual.path, '@change');
     });
 
-    test('gets draft from storage on open', function() {
-      var storedDraft = 'hello world';
+    test('gets draft from storage on open', () => {
+      const storedDraft = 'hello world';
       getDraftCommentStub.returns({message: storedDraft});
       element.open();
       assert.isTrue(getDraftCommentStub.called);
       assert.equal(element.draft, storedDraft);
     });
 
-    test('blank if no stored draft', function() {
+    test('blank if no stored draft', () => {
       getDraftCommentStub.returns(null);
       element.open();
       assert.isTrue(getDraftCommentStub.called);
       assert.equal(element.draft, '');
     });
 
-    test('updates stored draft on edits', function() {
-      var firstEdit = 'hello';
-      var location = element._getStorageLocation();
+    test('updates stored draft on edits', () => {
+      const firstEdit = 'hello';
+      const location = element._getStorageLocation();
 
       element.draft = firstEdit;
       element.flushDebouncer('store');
@@ -348,32 +437,33 @@
       assert.isTrue(eraseDraftCommentStub.calledWith(location));
     });
 
-    test('400 converts to human-readable server-error', function(done) {
-      sandbox.stub(window, 'fetch', function() {
-        var text = '....{"reviewers":{"id1":{"error":"first error"}},' +
+    test('400 converts to human-readable server-error', done => {
+      sandbox.stub(window, 'fetch', () => {
+        const text = '....{"reviewers":{"id1":{"error":"first error"}},' +
           '"ccs":{"id2":{"error":"second error"}}}';
         return Promise.resolve({
           ok: false,
           status: 400,
-          text: function() { return Promise.resolve(text); },
+          text() { return Promise.resolve(text); },
         });
       });
 
-      element.addEventListener('server-error', function(event) {
+      element.addEventListener('server-error', event => {
         if (event.target !== element) {
           return;
         }
-        event.detail.response.text().then(function(body) {
+        event.detail.response.text().then(body => {
           assert.equal(body, 'first error, second error');
+          done();
         });
       });
 
       // Async tick is needed because iron-selector content is distributed and
       // distributed content requires an observer to be set up.
-      flush(function() { element.send().then(done); });
+      flush(() => { element.send(); });
     });
 
-    test('ccs are displayed if NoteDb is enabled', function() {
+    test('ccs are displayed if NoteDb is enabled', () => {
       function hasCc() {
         flushAsynchronousOperations();
         return !!element.$$('#ccs');
@@ -386,34 +476,36 @@
       assert.isTrue(hasCc());
     });
 
-    test('filterReviewerSuggestion', function() {
-      var owner = makeAccount();
-      var reviewer1 = makeAccount();
-      var reviewer2 = makeGroup();
-      var cc1 = makeAccount();
-      var cc2 = makeGroup();
+    test('filterReviewerSuggestion', () => {
+      const owner = makeAccount();
+      const reviewer1 = makeAccount();
+      const reviewer2 = makeGroup();
+      const cc1 = makeAccount();
+      const cc2 = makeGroup();
+      let filter = element._filterReviewerSuggestionGenerator(false);
 
       element._owner = owner;
       element._reviewers = [reviewer1, reviewer2];
       element._ccs = [cc1, cc2];
 
-      assert.isTrue(
-          element._filterReviewerSuggestion({account: makeAccount()}));
-      assert.isTrue(element._filterReviewerSuggestion({group: makeGroup()}));
+      assert.isTrue(filter({account: makeAccount()}));
+      assert.isTrue(filter({group: makeGroup()}));
 
       // Owner should be excluded.
-      assert.isFalse(element._filterReviewerSuggestion({account: owner}));
+      assert.isFalse(filter({account: owner}));
 
-      // Existing and pending reviewers should be excluded.
-      assert.isFalse(element._filterReviewerSuggestion({account: reviewer1}));
-      assert.isFalse(element._filterReviewerSuggestion({group: reviewer2}));
+      // Existing and pending reviewers should be excluded when isCC = false.
+      assert.isFalse(filter({account: reviewer1}));
+      assert.isFalse(filter({group: reviewer2}));
 
-      // Existing and pending CCs should be excluded.
-      assert.isFalse(element._filterReviewerSuggestion({account: cc1}));
-      assert.isFalse(element._filterReviewerSuggestion({group: cc2}));
+      filter = element._filterReviewerSuggestionGenerator(true);
+
+      // Existing and pending CCs should be excluded when isCC = true;.
+      assert.isFalse(filter({account: cc1}));
+      assert.isFalse(filter({group: cc2}));
     });
 
-    test('_chooseFocusTarget', function() {
+    test('_chooseFocusTarget', () => {
       element._account = null;
       assert.strictEqual(
           element._chooseFocusTarget(), element.FocusTarget.BODY);
@@ -440,77 +532,31 @@
           element._chooseFocusTarget(), element.FocusTarget.BODY);
     });
 
-    test('only send labels that have changed', function(done) {
-      flush(function() {
-        var saveReviewStub = sinon.stub(element, '_saveReview',
-            function(review) {
-          assert.deepEqual(review.labels, {Verified: -1});
-          return Promise.resolve({ok: true});
+    test('only send labels that have changed', done => {
+      flush(() => {
+        stubSaveReview(review => {
+          assert.deepEqual(review.labels, {
+            'Code-Review': 0,
+            'Verified': -1,
+          });
         });
 
-        element.addEventListener('send', function() {
-          saveReviewStub.restore();
+        element.addEventListener('send', () => {
           done();
         });
         // Without wrapping this test in flush(), the below two calls to
         // MockInteractions.tap() cause a race in some situations in shadow DOM.
         // The send button can be tapped before the others, causing the test to
         // fail.
-        MockInteractions.tap(element.$$(
-            'iron-selector[data-label="Verified"] > ' +
-            'gr-button[data-value="-1"]'));
+
+        element.$$('gr-label-scores').$$(
+            'gr-label-score-row[name="Verified"]').setSelectedValue(-1);
         MockInteractions.tap(element.$$('.send'));
       });
     });
 
-    test('do not display tooltips on touch devices', function() {
-      element._account = {_account_id: 1};
-      element.set(['change', 'labels', 'Verified', 'all'],
-          [{_account_id: 1, value: -1}]);
-      element.labels = {
-        Verified: {
-          values: {
-            '-1': 'Fails',
-            ' 0': 'No score',
-            '+1': 'Verified'
-          },
-          default_value: 0,
-        },
-        'Code-Review': {
-          values: {
-            '-2': 'Do not submit',
-            '-1': 'I would prefer that you didn\'t submit this',
-            ' 0': 'No score',
-            '+1': 'Looks good to me, but someone else must approve',
-            '+2': 'Looks good to me, approved'
-          },
-          default_value: 0,
-        },
-      };
-
-      flushAsynchronousOperations();
-
-      var verifiedBtn = element.$$(
-          'iron-selector[data-label="Verified"] > ' +
-          'gr-button[data-value="-1"]');
-
-      // On touch devices, tooltips should not be shown.
-      verifiedBtn._isTouchDevice = true;
-      verifiedBtn._handleShowTooltip();
-      assert.isNotOk(verifiedBtn._tooltip);
-      verifiedBtn._handleHideTooltip();
-      assert.isNotOk(verifiedBtn._tooltip);
-
-      // On other devices, tooltips should be shown.
-      verifiedBtn._isTouchDevice = false;
-      verifiedBtn._handleShowTooltip();
-      assert.isOk(verifiedBtn._tooltip);
-      verifiedBtn._handleHideTooltip();
-      assert.isNotOk(verifiedBtn._tooltip);
-    });
-
-    test('_processReviewerChange', function() {
-      var mockIndexSplices = function(toRemove) {
+    test('_processReviewerChange', () => {
+      const mockIndexSplices = function(toRemove) {
         return [{
           removed: [toRemove],
         }];
@@ -521,16 +567,16 @@
       assert.equal(element._reviewersPendingRemove.REVIEWER.length, 1);
     });
 
-    test('_purgeReviewersPendingRemove', function() {
-      var removeStub = sandbox.stub(element, '_removeAccount');
-      var mock = function() {
+    test('_purgeReviewersPendingRemove', () => {
+      const removeStub = sandbox.stub(element, '_removeAccount');
+      const mock = function() {
         element._reviewersPendingRemove = {
           test: [makeAccount()],
           test2: [makeAccount(), makeAccount()],
         };
       };
-      var checkObjEmpty = function(obj) {
-        for (var prop in obj) {
+      const checkObjEmpty = function(obj) {
+        for (const prop in obj) {
           if (obj.hasOwnProperty(prop) && obj[prop].length) { return false; }
         }
         return true;
@@ -546,48 +592,78 @@
       assert.isTrue(checkObjEmpty(element._reviewersPendingRemove));
     });
 
-    test('_removeAccount', function(done) {
+    test('_removeAccount', done => {
       sandbox.stub(element.$.restAPI, 'removeChangeReviewer')
           .returns(Promise.resolve({ok: true}));
-      var arr = [makeAccount(), makeAccount()];
+      const arr = [makeAccount(), makeAccount()];
       element.change.reviewers = {
         REVIEWER: arr.slice(),
       };
 
-      element._removeAccount(arr[1], 'REVIEWER').then(function() {
+      element._removeAccount(arr[1], 'REVIEWER').then(() => {
         assert.equal(element.change.reviewers.REVIEWER.length, 1);
         assert.deepEqual(element.change.reviewers.REVIEWER, arr.slice(0, 1));
         done();
       });
     });
 
-    test('migrate reviewers between states', function(done) {
+    test('moving from cc to reviewer', () => {
       element.serverConfig = {note_db_enabled: true};
       element._reviewersPendingRemove = {
         CC: [],
         REVIEWER: [],
       };
       flushAsynchronousOperations();
-      var reviewers = element.$.reviewers;
-      var ccs = element.$$('#ccs');
-      var reviewer1 = makeAccount();
-      var reviewer2 = makeAccount();
-      var cc1 = makeAccount();
-      var cc2 = makeAccount();
+
+      const reviewer1 = makeAccount();
+      const reviewer2 = makeAccount();
+      const reviewer3 = makeAccount();
+      const cc1 = makeAccount();
+      const cc2 = makeAccount();
+      const cc3 = makeAccount();
+      const cc4 = makeAccount();
+      element._reviewers = [reviewer1, reviewer2, reviewer3];
+      element._ccs = [cc1, cc2, cc3, cc4];
+      element.push('_reviewers', cc1);
+      flushAsynchronousOperations();
+
+      assert.deepEqual(element._reviewers,
+          [reviewer1, reviewer2, reviewer3, cc1]);
+      assert.deepEqual(element._ccs, [cc2, cc3, cc4]);
+      assert.deepEqual(element._reviewersPendingRemove.CC, [cc1]);
+
+      element.push('_reviewers', cc4, cc3);
+      flushAsynchronousOperations();
+
+      assert.deepEqual(element._reviewers,
+          [reviewer1, reviewer2, reviewer3, cc1, cc4, cc3]);
+      assert.deepEqual(element._ccs, [cc2]);
+      assert.deepEqual(element._reviewersPendingRemove.CC, [cc1, cc4, cc3]);
+    });
+
+    test('migrate reviewers between states', done => {
+      element.serverConfig = {note_db_enabled: true};
+      element._reviewersPendingRemove = {
+        CC: [],
+        REVIEWER: [],
+      };
+      flushAsynchronousOperations();
+      const reviewers = element.$.reviewers;
+      const ccs = element.$$('#ccs');
+      const reviewer1 = makeAccount();
+      const reviewer2 = makeAccount();
+      const cc1 = makeAccount();
+      const cc2 = makeAccount();
+      const cc3 = makeAccount();
       element._reviewers = [reviewer1, reviewer2];
-      element._ccs = [cc1, cc2];
+      element._ccs = [cc1, cc2, cc3];
 
-      var mutations = [];
+      const mutations = [];
 
-      var saveReviewStub = sandbox.stub(element, '_saveReview',
-          function(review) {
-        mutations.push.apply(mutations, review.reviewers);
-        return Promise.resolve({ok: true});
-      });
+      stubSaveReview(review => mutations.push(...review.reviewers));
 
-      var removeAccountStub = sandbox.stub(element, '_removeAccount',
-          function(account, type) {
-        mutations.push({state: 'REMOVED', account: account});
+      sandbox.stub(element, '_removeAccount', (account, type) => {
+        mutations.push({state: 'REMOVED', account});
         return Promise.resolve();
       });
 
@@ -595,33 +671,362 @@
       reviewers.fire('remove', {account: reviewer1});
       ccs.$.entry.fire('add', {value: {account: reviewer1}});
       ccs.fire('remove', {account: cc1});
+      ccs.fire('remove', {account: cc3});
       reviewers.$.entry.fire('add', {value: {account: cc1}});
 
       // Add to other field without removing from former field.
       // (Currently not possible in UI, but this is a good consistency check).
       reviewers.$.entry.fire('add', {value: {account: cc2}});
       ccs.$.entry.fire('add', {value: {account: reviewer2}});
-      var mapReviewer = function(reviewer, opt_state) {
-        var result = {reviewer: reviewer._account_id, confirmed: undefined};
+      const mapReviewer = function(reviewer, opt_state) {
+        const result = {reviewer: reviewer._account_id, confirmed: undefined};
         if (opt_state) {
           result.state = opt_state;
         }
         return result;
       };
 
-      // Send and purge and verify moves without deletions.
+      // Send and purge and verify moves, delete cc3.
       element.send()
-          .then(element._purgeReviewersPendingRemove.bind(element))
-          .then(function() {
-        assert.deepEqual(
-            mutations, [
-                mapReviewer(cc1),
-                mapReviewer(cc2),
-                mapReviewer(reviewer1, 'CC'),
-                mapReviewer(reviewer2, 'CC'),
-            ]);
-        done();
+          .then(keepReviewers =>
+              element._purgeReviewersPendingRemove(false, keepReviewers))
+          .then(() => {
+            assert.deepEqual(
+                mutations, [
+                  mapReviewer(cc1),
+                  mapReviewer(cc2),
+                  mapReviewer(reviewer1, 'CC'),
+                  mapReviewer(reviewer2, 'CC'),
+                  {account: cc3, state: 'REMOVED'},
+                ]);
+            done();
+          });
+    });
+
+    test('emits cancel on esc key', () => {
+      const cancelHandler = sandbox.spy();
+      element.addEventListener('cancel', cancelHandler);
+      MockInteractions.pressAndReleaseKeyOn(element, 27, null, 'esc');
+      flushAsynchronousOperations();
+
+      assert.isTrue(cancelHandler.called);
+    });
+
+    test('should not send on enter key', () => {
+      stubSaveReview(() => undefined);
+      element.addEventListener('send', () => assert.fail('wrongly called'));
+      MockInteractions.pressAndReleaseKeyOn(element, 13, null, 'enter');
+      flushAsynchronousOperations();
+    });
+
+    test('emit send on ctrl+enter key', done => {
+      stubSaveReview(() => undefined);
+      element.addEventListener('send', () => done());
+      MockInteractions.pressAndReleaseKeyOn(element, 13, 'ctrl', 'enter');
+      flushAsynchronousOperations();
+    });
+
+    test('_computeMessagePlaceholder', () => {
+      assert.equal(
+          element._computeMessagePlaceholder(false),
+          'Say something nice...');
+      assert.equal(
+          element._computeMessagePlaceholder(true),
+          'Add a note for your reviewers...');
+    });
+
+    test('_computeSendButtonLabel', () => {
+      assert.equal(
+          element._computeSendButtonLabel(false),
+          'Send');
+      assert.equal(
+          element._computeSendButtonLabel(true),
+          'Start review');
+    });
+
+    test('_handle400Error reviewrs and CCs', done => {
+      const error1 = 'error 1';
+      const error2 = 'error 2';
+      const error3 = 'error 3';
+      const response = {
+        status: 400,
+        text() {
+          return Promise.resolve(')]}\'' + JSON.stringify({
+            reviewers: {
+              username1: {
+                input: 'user 1',
+                error: error1,
+              },
+              username2: {
+                input: 'user 2',
+                error: error2,
+              },
+            },
+            ccs: {
+              username3: {
+                input: 'user 3',
+                error: error3,
+              },
+            },
+          }));
+        },
+      };
+      element.addEventListener('server-error', e => {
+        e.detail.response.text().then(text => {
+          assert.equal(text, [error1, error2, error3].join(', '));
+          done();
+        });
       });
+      element._handle400Error(response);
+    });
+
+    test('_handle400Error CCs only', done => {
+      const error1 = 'error 1';
+      const response = {
+        status: 400,
+        text() {
+          return Promise.resolve(')]}\'' + JSON.stringify({
+            ccs: {
+              username1: {
+                input: 'user 1',
+                error: error1,
+              },
+            },
+          }));
+        },
+      };
+      element.addEventListener('server-error', e => {
+        e.detail.response.text().then(text => {
+          assert.equal(text, error1);
+          done();
+        });
+      });
+      element._handle400Error(response);
+    });
+
+    test('fires height change when the drafts load', done => {
+      // Flush DOM operations before binding to the autogrow event so we don't
+      // catch the events fired from the initial layout.
+      flush(() => {
+        const autoGrowHandler = sinon.stub();
+        element.addEventListener('autogrow', autoGrowHandler);
+        element.diffDrafts = {};
+        flush(() => {
+          assert.isTrue(autoGrowHandler.called);
+          done();
+        });
+      });
+    });
+
+    suite('post review API', () => {
+      let startReviewStub;
+
+      setup(() => {
+        startReviewStub = sandbox.stub(element.$.restAPI, 'startReview', () => {
+          return Promise.resolve();
+        });
+      });
+
+      test('ready property in review input on start review', () => {
+        stubSaveReview(review => {
+          assert.isTrue(review.ready);
+          return {ready: true};
+        });
+        return element.send(true, true).then(() => {
+          assert.isFalse(startReviewStub.called);
+        });
+      });
+
+      test('no ready property in review input on save review', () => {
+        stubSaveReview(review => {
+          assert.isUndefined(review.ready);
+        });
+        return element.send(true, false).then(() => {
+          assert.isFalse(startReviewStub.called);
+        });
+      });
+
+      test('fall back to start review against old backend', () => {
+        stubSaveReview(review => {
+          return {}; // old backend won't set ready: true
+        });
+
+        return element.send(true, true).then(() => {
+          assert.isTrue(startReviewStub.called);
+        }).then(() => {
+          startReviewStub.reset();
+          return element.send(true, false);
+        }).then(() => {
+          assert.isFalse(startReviewStub.called);
+        });
+      });
+    });
+
+    suite('start review and save buttons', () => {
+      let sendStub;
+
+      setup(() => {
+        sendStub = sandbox.stub(element, 'send', () => Promise.resolve());
+        element.canBeStarted = true;
+        // Flush to make both Start/Save buttons appear in DOM.
+        flushAsynchronousOperations();
+      });
+
+      test('start review sets ready', () => {
+        MockInteractions.tap(element.$$('.send'));
+        flushAsynchronousOperations();
+        assert.isTrue(sendStub.calledWith(true, true));
+      });
+
+      test('save review doesn\'t set ready', () => {
+        MockInteractions.tap(element.$$('.save'));
+        flushAsynchronousOperations();
+        assert.isTrue(sendStub.calledWith(true, false));
+      });
+    });
+
+    test('dummy message to force email on start review', () => {
+      stubSaveReview(review => {
+        assert.equal(review.message, element.START_REVIEW_MESSAGE);
+        return {ready: true};
+      });
+      return element.send(true, true);
+    });
+
+    test('buttons disabled until all API calls are resolved', () => {
+      stubSaveReview(review => {
+        return {}; // old backend won't set ready: true
+      });
+      // Check that element is disabled asynchronously after the setReady
+      // promise is returned. The element should not be reenabled until
+      // that promise is resolved.
+      sandbox.stub(element, '_maybeSetReady', (startReview, response) => {
+        return new Promise(resolve => {
+          Polymer.Base.async(() => {
+            assert.isTrue(element.disabled);
+            resolve();
+          });
+        });
+      });
+      return element.send(true, true).then(() => {
+        assert.isFalse(element.disabled);
+      });
+    });
+
+    suite('error handling', () => {
+      const expectedDraft = 'draft';
+      const expectedError = new Error('test');
+
+      setup(() => {
+        element.draft = expectedDraft;
+      });
+
+      function assertDialogOpenAndEnabled() {
+        assert.strictEqual(expectedDraft, element.draft);
+        assert.isFalse(element.disabled);
+      }
+
+      function assertDialogClosed() {
+        assert.strictEqual('', element.draft);
+        assert.isFalse(element.disabled);
+      }
+
+      test('error occurs in _saveReview', () => {
+        stubSaveReview(review => {
+          throw expectedError;
+        });
+        return element.send(true, true).catch(err => {
+          assert.strictEqual(expectedError, err);
+          assertDialogOpenAndEnabled();
+        });
+      });
+
+      test('error occurs during startReview', () => {
+        stubSaveReview(review => {
+          return {}; // old backend won't set ready: true
+        });
+        const errorStub = sandbox.stub(
+            console, 'error', (msg, err) => undefined);
+        sandbox.stub(element.$.restAPI, 'startReview', () => {
+          throw expectedError;
+        });
+        return element.send(true, true).then(() => {
+          assertDialogClosed();
+          assert.isTrue(
+              errorStub.calledWith('error setting ready:', expectedError));
+        });
+      });
+
+      test('non-ok response received by startReview', () => {
+        stubSaveReview(review => {
+          return {}; // old backend won't set ready: true
+        });
+        sandbox.stub(element.$.restAPI, 'startReview', (c, b, f) => {
+          f({status: 500});
+        });
+        return element.send(true, true).then(() => {
+          assertDialogClosed();
+        });
+      });
+
+      test('409 response received by startReview', () => {
+        stubSaveReview(review => {
+          return {}; // old backend won't set ready: true
+        });
+        sandbox.stub(element.$.restAPI, 'startReview', (c, b, f) => {
+          f({status: 409});
+        });
+        return element.send(true, true).then(() => {
+          assertDialogClosed();
+        });
+      });
+
+      suite('pending diff drafts?', () => {
+        test('yes', () => {
+          const promise = mockPromise();
+          const refreshHandler = sandbox.stub();
+
+          element.addEventListener('comment-refresh', refreshHandler);
+          sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(true);
+          element.$.restAPI._pendingRequests.sendDiffDraft = [promise];
+          element.open();
+
+          assert.isFalse(refreshHandler.called);
+          assert.isTrue(element._savingComments);
+
+          promise.resolve();
+
+          return element.$.restAPI.awaitPendingDiffDrafts().then(() => {
+            assert.isTrue(refreshHandler.called);
+            assert.isFalse(element._savingComments);
+          });
+        });
+
+        test('no', () => {
+          sandbox.stub(element.$.restAPI, 'hasPendingDiffDrafts').returns(false);
+          element.open();
+          assert.notOk(element._savingComments);
+        });
+      });
+    });
+
+    test('_computeSendButtonDisabled', () => {
+      const fn = element._computeSendButtonDisabled.bind(element);
+      assert.isTrue(fn('not-latest'));
+      assert.isFalse(fn('latest', 'Start review'));
+      assert.isTrue(fn('latest', 'Send', {}, '', false, false, false));
+      // Mock nonempty comment draft array, with seding comments.
+      assert.isFalse(fn('latest', 'Send', {file: ['draft']}, '', false, false,
+          true));
+      // Mock nonempty comment draft array, without seding comments.
+      assert.isTrue(fn('latest', 'Send', {file: ['draft']}, '', false, false,
+          false));
+      // Mock nonempty change message.
+      assert.isFalse(fn('latest', 'Send', {}, 'test', false, false, false));
+      // Mock reviewers mutated.
+      assert.isFalse(fn('latest', 'Send', {}, '', true, false, false));
+      // Mock labels changed.
+      assert.isFalse(fn('latest', 'Send', {}, '', false, true, false));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
new file mode 100644
index 0000000..70e1b83
--- /dev/null
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/test/plugin.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(plugin => {
+      const replyApi = plugin.changeReply();
+      replyApi.addReplyTextChangedCallback(text => {
+        const label = 'Code-Review';
+        const labelValue = replyApi.getLabelValue(label);
+        if (labelValue &&
+            labelValue === ' 0' &&
+            text.indexOf('LGTM') === 0) {
+          replyApi.setLabelValue(label, '+1');
+        }
+      });
+    }, '0.1', 'http://test.com/plugins/testplugin/static/test.js');
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
index 435b7de..9a5b5ed 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.html
@@ -19,10 +19,11 @@
 <link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-reviewer-list">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
@@ -33,6 +34,9 @@
       .autocompleteContainer {
         position: relative;
       }
+      .hiddenReviewers {
+        margin-top: .3em;
+      }
       .inputContainer {
         display: flex;
         margin-top: .25em;
@@ -56,13 +60,17 @@
         }
       }
     </style>
-    <template is="dom-repeat" items="[[_reviewers]]" as="reviewer">
+    <template is="dom-repeat" items="[[_displayedReviewers]]" as="reviewer">
       <gr-account-chip class="reviewer" account="[[reviewer]]"
           on-remove="_handleRemove"
-          data-account-id$="[[reviewer._account_id]]"
           removable="[[_computeCanRemoveReviewer(reviewer, mutable)]]">
       </gr-account-chip>
     </template>
+    <gr-button
+        class="hiddenReviewers"
+        link
+        hidden$="[[!_hiddenReviewerCount]]"
+        on-tap="_handleViewAll">and [[_hiddenReviewerCount]] more</gr-button>
     <div class="controlsContainer" hidden$="[[!mutable]]">
       <gr-button
           link
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
index 72a7c9b..59332f9 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list.js
@@ -42,10 +42,15 @@
         type: Boolean,
         value: false,
       },
+      maxReviewersDisplayed: Number,
 
+      _displayedReviewers: {
+        type: Array,
+        value() { return []; },
+      },
       _reviewers: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _showInput: {
         type: Boolean,
@@ -55,6 +60,11 @@
         type: String,
         computed: '_computeAddLabel(ccsOnly)',
       },
+      _hiddenReviewerCount: {
+        type: Number,
+        computed: '_computeHiddenCount(_reviewers, _displayedReviewers)',
+      },
+
 
       // Used for testing.
       _lastAutocompleteRequest: Object,
@@ -65,10 +75,10 @@
       '_reviewersChanged(change.reviewers.*, change.owner)',
     ],
 
-    _reviewersChanged: function(changeRecord, owner) {
-      var result = [];
-      var reviewers = changeRecord.base;
-      for (var key in reviewers) {
+    _reviewersChanged(changeRecord, owner) {
+      let result = [];
+      const reviewers = changeRecord.base;
+      for (const key in reviewers) {
         if (this.reviewersOnly && key !== 'REVIEWER') {
           continue;
         }
@@ -79,66 +89,88 @@
           result = result.concat(reviewers[key]);
         }
       }
-      this._reviewers = result.filter(function(reviewer) {
+      this._reviewers = result.filter(reviewer => {
         return reviewer._account_id != owner._account_id;
       });
+
+      // If there is one more than the max reviewers, don't show the 'show
+      // more' button, because it takes up just as much space.
+      if (this.maxReviewersDisplayed &&
+          this._reviewers.length > this.maxReviewersDisplayed + 1) {
+        this._displayedReviewers =
+          this._reviewers.slice(0, this.maxReviewersDisplayed);
+      } else {
+        this._displayedReviewers = this._reviewers;
+      }
     },
 
-    _computeCanRemoveReviewer: function(reviewer, mutable) {
+    _computeHiddenCount(reviewers, displayedReviewers) {
+      return reviewers.length - displayedReviewers.length;
+    },
+
+    _computeCanRemoveReviewer(reviewer, mutable) {
       if (!mutable) { return false; }
 
-      for (var i = 0; i < this.change.removable_reviewers.length; i++) {
-        if (this.change.removable_reviewers[i]._account_id ==
-            reviewer._account_id) {
+      let current;
+      for (let i = 0; i < this.change.removable_reviewers.length; i++) {
+        current = this.change.removable_reviewers[i];
+        if (current._account_id === reviewer._account_id ||
+            (!reviewer._account_id && current.email === reviewer.email)) {
           return true;
         }
       }
       return false;
     },
 
-    _handleRemove: function(e) {
+    _handleRemove(e) {
       e.preventDefault();
-      var target = Polymer.dom(e).rootTarget;
-      var accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      const target = Polymer.dom(e).rootTarget;
+      if (!target.account) { return; }
+      const accountID = target.account._account_id || target.account.email;
       this.disabled = true;
-      this._xhrPromise =
-          this._removeReviewer(accountID).then(function(response) {
+      this._xhrPromise = this._removeReviewer(accountID).then(response => {
         this.disabled = false;
         if (!response.ok) { return response; }
 
-        var reviewers = this.change.reviewers;
-        ['REVIEWER', 'CC'].forEach(function(type) {
+        const reviewers = this.change.reviewers;
+
+        for (const type of ['REVIEWER', 'CC']) {
           reviewers[type] = reviewers[type] || [];
-          for (var i = 0; i < reviewers[type].length; i++) {
-            if (reviewers[type][i]._account_id == accountID) {
+          for (let i = 0; i < reviewers[type].length; i++) {
+            if (reviewers[type][i]._account_id == accountID ||
+            reviewers[type][i].email == accountID) {
               this.splice('change.reviewers.' + type, i, 1);
               break;
             }
           }
-        }, this);
-      }.bind(this)).catch(function(err) {
+        }
+      }).catch(err => {
         this.disabled = false;
         throw err;
-      }.bind(this));
+      });
     },
 
-    _handleAddTap: function(e) {
+    _handleAddTap(e) {
       e.preventDefault();
-      var value = {};
+      const value = {};
       if (this.reviewersOnly) {
         value.reviewersOnly = true;
       }
       if (this.ccsOnly) {
         value.ccsOnly = true;
       }
-      this.fire('show-reply-dialog', {value: value});
+      this.fire('show-reply-dialog', {value});
     },
 
-    _removeReviewer: function(id) {
+    _handleViewAll(e) {
+      this._displayedReviewers = this._reviewers;
+    },
+
+    _removeReviewer(id) {
       return this.$.restAPI.removeChangeReviewer(this.change._number, id);
     },
 
-    _computeAddLabel: function(ccsOnly) {
+    _computeAddLabel(ccsOnly) {
       return ccsOnly ? 'Add CC' : 'Add reviewer';
     },
   });
diff --git a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
index 4542df8..40f1cbd 100644
--- a/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reviewer-list/gr-reviewer-list_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-reviewer-list.html">
 
 <script>void(0);</script>
@@ -33,47 +32,47 @@
 </test-fixture>
 
 <script>
-  suite('gr-reviewer-list tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-reviewer-list tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        removeChangeReviewer: function() {
+        getConfig() { return Promise.resolve({}); },
+        removeChangeReviewer() {
           return Promise.resolve({ok: true});
         },
       });
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('controls hidden on immutable element', function() {
+    test('controls hidden on immutable element', () => {
       element.mutable = false;
       assert.isTrue(element.$$('.controlsContainer').hasAttribute('hidden'));
       element.mutable = true;
       assert.isFalse(element.$$('.controlsContainer').hasAttribute('hidden'));
     });
 
-    test('add reviewer button opens reply dialog', function(done) {
-      element.addEventListener('show-reply-dialog', function() {
+    test('add reviewer button opens reply dialog', done => {
+      element.addEventListener('show-reply-dialog', () => {
         done();
       });
       MockInteractions.tap(element.$$('.addReviewer'));
     });
 
-    test('only show remove for removable reviewers', function() {
+    test('only show remove for removable reviewers', () => {
       element.mutable = true;
       element.change = {
         owner: {
           _account_id: 1,
         },
         reviewers: {
-          'REVIEWER': [
+          REVIEWER: [
             {
               _account_id: 2,
               name: 'Bojack Horseman',
@@ -84,13 +83,16 @@
               name: 'Pinky Penguin',
             },
           ],
-          'CC': [
+          CC: [
             {
               _account_id: 4,
               name: 'Diane Nguyen',
               email: 'macarthurfellow2B@juno.com',
             },
-          ]
+            {
+              email: 'test@e.mail',
+            },
+          ],
         },
         removable_reviewers: [
           {
@@ -102,36 +104,40 @@
             name: 'Diane Nguyen',
             email: 'macarthurfellow2B@juno.com',
           },
-        ]
+          {
+            email: 'test@e.mail',
+          },
+        ],
       };
       flushAsynchronousOperations();
-      var chips =
+      const chips =
           Polymer.dom(element.root).querySelectorAll('gr-account-chip');
-      assert.equal(chips.length, 3);
-      Array.from(chips).forEach(function(el) {
-        var accountID = parseInt(el.getAttribute('data-account-id'), 10);
+      assert.equal(chips.length, 4);
+
+      for (const el of Array.from(chips)) {
+        const accountID = el.account._account_id || el.account.email;
         assert.ok(accountID);
 
-        var buttonEl = el.$$('gr-button');
+        const buttonEl = el.$$('gr-button');
         assert.isNotNull(buttonEl);
         if (accountID == 2) {
           assert.isTrue(buttonEl.hasAttribute('hidden'));
         } else {
           assert.isFalse(buttonEl.hasAttribute('hidden'));
         }
-      });
+      }
     });
 
-    test('tracking reviewers and ccs', function() {
-      var counter = 0;
+    test('tracking reviewers and ccs', () => {
+      let counter = 0;
       function makeAccount() {
         return {_account_id: counter++};
       }
 
-      var owner = makeAccount();
-      var reviewer = makeAccount();
-      var cc = makeAccount();
-      var reviewers = {
+      const owner = makeAccount();
+      const reviewer = makeAccount();
+      const cc = makeAccount();
+      const reviewers = {
         REMOVED: [makeAccount()],
         REVIEWER: [owner, reviewer],
         CC: [owner, cc],
@@ -140,30 +146,30 @@
       element.ccsOnly = false;
       element.reviewersOnly = false;
       element.change = {
-        owner: owner,
-        reviewers: reviewers,
+        owner,
+        reviewers,
       };
       assert.deepEqual(element._reviewers, [reviewer, cc]);
 
       element.reviewersOnly = true;
       element.change = {
-        owner: owner,
-        reviewers: reviewers,
+        owner,
+        reviewers,
       };
       assert.deepEqual(element._reviewers, [reviewer]);
 
       element.ccsOnly = true;
       element.reviewersOnly = false;
       element.change = {
-        owner: owner,
-        reviewers: reviewers,
+        owner,
+        reviewers,
       };
       assert.deepEqual(element._reviewers, [cc]);
     });
 
-    test('_handleAddTap passes mode with event', function() {
-      var fireStub = sandbox.stub(element, 'fire');
-      var e = {preventDefault: function() {}};
+    test('_handleAddTap passes mode with event', () => {
+      const fireStub = sandbox.stub(element, 'fire');
+      const e = {preventDefault() {}};
 
       element.ccsOnly = false;
       element.reviewersOnly = false;
@@ -181,5 +187,108 @@
       assert.isTrue(fireStub.lastCall.calledWith('show-reply-dialog',
           {value: {ccsOnly: true}}));
     });
+
+    test('no show all reviewers button with 6 reviewers', () => {
+      const reviewers = [];
+      element.maxReviewersDisplayed = 5;
+      for (let i = 0; i < 6; i++) {
+        reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+      }
+      element.ccsOnly = true;
+
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          CC: reviewers,
+        },
+      };
+      flushAsynchronousOperations();
+      assert.equal(element._hiddenReviewerCount, 0);
+      assert.equal(element._displayedReviewers.length, 6);
+      assert.equal(element._reviewers.length, 6);
+      assert.isTrue(element.$$('.hiddenReviewers').hidden);
+    });
+
+    test('show all reviewers button with 7 reviewers', () => {
+      const reviewers = [];
+      element.maxReviewersDisplayed = 5;
+      for (let i = 0; i < 7; i++) {
+        reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+      }
+      element.ccsOnly = true;
+
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          CC: reviewers,
+        },
+      };
+      flushAsynchronousOperations();
+      assert.equal(element._hiddenReviewerCount, 2);
+      assert.equal(element._displayedReviewers.length, 5);
+      assert.equal(element._reviewers.length, 7);
+      assert.isFalse(element.$$('.hiddenReviewers').hidden);
+    });
+
+
+    test('no maxReviewersDisplayed', () => {
+      const reviewers = [];
+      for (let i = 0; i < 7; i++) {
+        reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+      }
+      element.ccsOnly = true;
+
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          CC: reviewers,
+        },
+      };
+      flushAsynchronousOperations();
+      assert.equal(element._hiddenReviewerCount, 0);
+      assert.equal(element._displayedReviewers.length, 7);
+      assert.equal(element._reviewers.length, 7);
+      assert.isTrue(element.$$('.hiddenReviewers').hidden);
+    });
+
+    test('show all reviewers button', () => {
+      const reviewers = [];
+      element.maxReviewersDisplayed = 5;
+      for (let i = 0; i < 100; i++) {
+        reviewers.push(
+          {email: i+'reviewer@google.com', name: 'reviewer-' + i});
+      }
+      element.ccsOnly = true;
+
+      element.change = {
+        owner: {
+          _account_id: 1,
+        },
+        reviewers: {
+          CC: reviewers,
+        },
+      };
+      flushAsynchronousOperations();
+      assert.equal(element._hiddenReviewerCount, 95);
+      assert.equal(element._displayedReviewers.length, 5);
+      assert.equal(element._reviewers.length, 100);
+      assert.isFalse(element.$$('.hiddenReviewers').hidden);
+
+      MockInteractions.tap(element.$$('.hiddenReviewers'));
+      flushAsynchronousOperations();
+      assert.equal(element._hiddenReviewerCount, 0);
+      assert.equal(element._displayedReviewers.length, 100);
+      assert.equal(element._reviewers.length, 100);
+      assert.isTrue(element.$$('.hiddenReviewers').hidden);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
index 7e358fd..85db3a1 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.html
@@ -14,14 +14,16 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-account-dropdown">
   <template>
-    <style>
+    <style include="shared-styles">
       button {
         background: none;
         border: none;
@@ -39,7 +41,7 @@
         items=[[links]]
         top-content=[[topContent]]
         horizontal-align="right">
-        <span hidden$="[[_hasAvatars]]" hidden>[[_accountName(account, _anonymousName)]]</span>
+        <span hidden$="[[_hasAvatars]]" hidden>[[_accountName(account)]]</span>
         <gr-avatar account="[[account]]" hidden$="[[!_hasAvatars]]" hidden
             image-size="56" aria-label="Account avatar"></gr-avatar>
     </gr-dropdown>
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
index d1da829..1bea61a 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown.js
@@ -12,59 +12,88 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 (function() {
-  'use strict'
+  'use strict';
 
-   var ANONYMOUS_NAME = 'Anonymous';
+  const INTERPOLATE_URL_PATTERN = /\$\{([\w]+)\}/g;
 
   Polymer({
     is: 'gr-account-dropdown',
 
     properties: {
       account: Object,
-      _hasAvatars: Boolean,
-      _anonymousName: {
-        type: String,
-        value: ANONYMOUS_NAME,
-      },
+      config: Object,
       links: {
         type: Array,
-        value: [
-          {name: 'Settings', url: '/settings'},
-          {name: 'Switch account', url: '/switch-account'},
-          {name: 'Sign out', url: '/logout'},
-        ],
+        computed: '_getLinks(_switchAccountUrl, _path)',
       },
       topContent: {
         type: Array,
-        computed: '_getTopContent(account, _anonymousName)',
+        computed: '_getTopContent(account)',
       },
+      _path: {
+        type: String,
+        value: '/',
+      },
+      _hasAvatars: Boolean,
+      _switchAccountUrl: String,
     },
 
-    attached: function() {
-      this.$.restAPI.getConfig().then(function(cfg) {
-        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-        if (cfg && cfg.user &&
-            cfg.user.anonymous_coward_name &&
-            cfg.user.anonymous_coward_name !== 'Anonymous Coward') {
-          this._anonymousName = cfg.user.anonymous_coward_name;
+    attached() {
+      this._handleLocationChange();
+      this.listen(window, 'location-change', '_handleLocationChange');
+      this.$.restAPI.getConfig().then(cfg => {
+        this.config = cfg;
+
+        if (cfg && cfg.auth && cfg.auth.switch_account_url) {
+          this._switchAccountUrl = cfg.auth.switch_account_url;
+        } else {
+          this._switchAccountUrl = '';
         }
-      }.bind(this));
+        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+      });
     },
 
-    _getTopContent: function(account, _anonymousName) {
+    behaviors: [
+      Gerrit.AnonymousNameBehavior,
+    ],
+
+    detached() {
+      this.unlisten(window, 'location-change', '_handleLocationChange');
+    },
+
+    _getLinks(switchAccountUrl, path) {
+      const links = [{name: 'Settings', url: '/settings/'}];
+      if (switchAccountUrl) {
+        const replacements = {path};
+        const url = this._interpolateUrl(switchAccountUrl, replacements);
+        links.push({name: 'Switch account', url, external: true});
+      }
+      links.push({name: 'Sign out', url: '/logout'});
+      return links;
+    },
+
+    _getTopContent(account) {
       return [
-        {text: this._accountName(account, _anonymousName), bold: true},
+        {text: this._accountName(account), bold: true},
         {text: account.email ? account.email : ''},
       ];
     },
 
-    _accountName: function(account, _anonymousName) {
-      if (account && account.name) {
-        return account.name;
-      } else if (account && account.email) {
-        return account.email;
-      }
-      return _anonymousName;
+    _handleLocationChange() {
+      this._path =
+          window.location.pathname +
+          window.location.search +
+          window.location.hash;
+    },
+
+    _interpolateUrl(url, replacements) {
+      return url.replace(INTERPOLATE_URL_PATTERN, (match, p1) => {
+        return replacements[p1] || '';
+      });
+    },
+
+    _accountName(account) {
+      return this.getUserName(this.config, account, true);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
index d7f09b8..1183d9c 100644
--- a/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
+++ b/polygerrit-ui/app/elements/core/gr-account-dropdown/gr-account-dropdown_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-account-dropdown.html">
 
 <script>void(0);</script>
@@ -33,40 +32,87 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-dropdown tests', function() {
-    var element;
+  suite('gr-account-dropdown tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
     });
 
-    test('account information', function() {
+    test('account information', () => {
       element.account = {name: 'John Doe', email: 'john@doe.com'};
       assert.deepEqual(element.topContent,
           [{text: 'John Doe', bold: true}, {text: 'john@doe.com'}]);
     });
 
-    test('test for account without a name', function() {
+    test('test for account without a name', () => {
       element.account = {id: '0001'};
       assert.deepEqual(element.topContent,
           [{text: 'Anonymous', bold: true}, {text: ''}]);
     });
 
-   test('test for account without a name but using config', function() {
-      element._anonymousName = 'WikiGerrit';
+    test('test for account without a name but using config', () => {
+      element.config = {
+        user: {
+          anonymous_coward_name: 'WikiGerrit',
+        },
+      };
       element.account = {id: '0001'};
       assert.deepEqual(element.topContent,
           [{text: 'WikiGerrit', bold: true}, {text: ''}]);
     });
 
-   test('test for account name as an email', function() {
-      element._anonymousName = 'WikiGerrit';
+    test('test for account name as an email', () => {
+      element.config = {
+        user: {
+          anonymous_coward_name: 'WikiGerrit',
+        },
+      };
       element.account = {email: 'john@doe.com'};
       assert.deepEqual(element.topContent,
           [{text: 'john@doe.com', bold: true}, {text: 'john@doe.com'}]);
     });
+
+    test('switch account', () => {
+      // No switch account link.
+      assert.equal(element._getLinks(null).length, 2);
+
+      // Unparameterized switch account link.
+      let links = element._getLinks('/switch-account');
+      assert.equal(links.length, 3);
+      assert.deepEqual(links[1], {
+        name: 'Switch account',
+        url: '/switch-account',
+        external: true,
+      });
+
+      // Parameterized switch account link.
+      links = element._getLinks('/switch-account${path}', '/c/123');
+      assert.equal(links.length, 3);
+      assert.deepEqual(links[1], {
+        name: 'Switch account',
+        url: '/switch-account/c/123',
+        external: true,
+      });
+    });
+
+    test('_interpolateUrl', () => {
+      const replacements = {
+        foo: 'bar',
+        test: 'TEST',
+      };
+      const interpolate = function(url) {
+        return element._interpolateUrl(url, replacements);
+      };
+
+      assert.equal(interpolate('test'), 'test');
+      assert.equal(interpolate('${test}'), 'TEST');
+      assert.equal(
+          interpolate('${}, ${test}, ${TEST}, ${foo}'),
+          '${}, TEST, , bar');
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
index e3f2bbc..5765411 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.html
@@ -25,4 +25,3 @@
   </template>
   <script src="gr-error-manager.js"></script>
 </dom-module>
-
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index d48d870..ac74ee5 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -14,12 +14,13 @@
 (function() {
   'use strict';
 
-  var HIDE_ALERT_TIMEOUT_MS = 5000;
-  var CHECK_SIGN_IN_INTERVAL_MS = 60*1000;
-  var STALE_CREDENTIAL_THRESHOLD_MS = 10*60*1000;
-  var SIGN_IN_WIDTH_PX = 690;
-  var SIGN_IN_HEIGHT_PX = 500;
-  var TOO_MANY_FILES = 'too many files to find conflicts';
+  const HIDE_ALERT_TIMEOUT_MS = 5000;
+  const CHECK_SIGN_IN_INTERVAL_MS = 60 * 1000;
+  const STALE_CREDENTIAL_THRESHOLD_MS = 10 * 60 * 1000;
+  const SIGN_IN_WIDTH_PX = 690;
+  const SIGN_IN_HEIGHT_PX = 500;
+  const TOO_MANY_FILES = 'too many files to find conflicts';
+  const AUTHENTICATION_REQUIRED = 'Authentication required\n';
 
   Polymer({
     is: 'gr-error-manager',
@@ -35,7 +36,9 @@
        */
       knownAccountId: Number,
 
-      _alertElement: Element,
+      /** @type {?Object} */
+      _alertElement: Object,
+      /** @type {?number} */
       _hideAlertHandle: Number,
       _refreshingCredentials: {
         type: Boolean,
@@ -47,91 +50,123 @@
        */
       _lastCredentialCheck: {
         type: Number,
-        value: function() { return Date.now(); },
-      }
+        value() { return Date.now(); },
+      },
     },
 
-    attached: function() {
+    attached() {
       this.listen(document, 'server-error', '_handleServerError');
       this.listen(document, 'network-error', '_handleNetworkError');
+      this.listen(document, 'auth-error', '_handleAuthError');
       this.listen(document, 'show-alert', '_handleShowAlert');
       this.listen(document, 'visibilitychange', '_handleVisibilityChange');
+      this.listen(document, 'show-auth-required', '_handleAuthRequired');
     },
 
-    detached: function() {
+    detached() {
       this._clearHideAlertHandle();
       this.unlisten(document, 'server-error', '_handleServerError');
       this.unlisten(document, 'network-error', '_handleNetworkError');
+      this.unlisten(document, 'auth-error', '_handleAuthError');
+      this.unlisten(document, 'show-auth-required', '_handleAuthRequired');
       this.unlisten(document, 'visibilitychange', '_handleVisibilityChange');
     },
 
-    _shouldSuppressError: function(msg) {
-      return msg.indexOf(TOO_MANY_FILES) > -1;
+    _shouldSuppressError(msg) {
+      return msg.includes(TOO_MANY_FILES);
     },
 
-    _handleServerError: function(e) {
-      if (e.detail.response.status === 403) {
-        this._getLoggedIn().then(function(loggedIn) {
-          if (loggedIn) {
-            // The app was logged at one point and is now getting auth errors.
-            // This indicates the auth token is no longer valid.
-            this._showAuthErrorAlert();
-          }
-        }.bind(this));
-      } else {
-        e.detail.response.text().then(function(text) {
-          if (!this._shouldSuppressError(text)) {
-            this._showAlert('Server error: ' + text);
-          }
-        }.bind(this));
-      }
+    _handleAuthRequired() {
+      this._showAuthErrorAlert(
+          'Log in is required to perform that action.', 'Log in.');
     },
 
-    _handleShowAlert: function(e) {
-      this._showAlert(e.detail.message);
+    _handleAuthError() {
+      this._showAuthErrorAlert('Auth error', 'Refresh credentials.');
     },
 
-    _handleNetworkError: function(e) {
+    _handleServerError(e) {
+      Promise.all([
+        e.detail.response.text(), this._getLoggedIn(),
+      ]).then(values => {
+        const text = values[0];
+        const loggedIn = values[1];
+        if (e.detail.response.status === 403 &&
+            loggedIn &&
+            text === AUTHENTICATION_REQUIRED) {
+          // The app was logged at one point and is now getting auth errors.
+          // This indicates the auth token is no longer valid.
+          this._handleAuthError();
+        } else if (!this._shouldSuppressError(text)) {
+          this._showAlert('Server error: ' + text);
+        }
+        console.error(text);
+      });
+    },
+
+    _handleShowAlert(e) {
+      this._showAlert(e.detail.message, e.detail.action, e.detail.callback,
+          e.detail.dismissOnNavigation);
+    },
+
+    _handleNetworkError(e) {
       this._showAlert('Server unavailable');
       console.error(e.detail.error.message);
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _showAlert: function(text) {
-      if (this._alertElement) { return; }
+    /**
+     * @param {string} text
+     * @param {?string=} opt_actionText
+     * @param {?Function=} opt_actionCallback
+     * @param {?boolean=} opt_dismissOnNavigation
+     */
+    _showAlert(text, opt_actionText, opt_actionCallback,
+        opt_dismissOnNavigation) {
+      if (this._alertElement) {
+        this._hideAlert();
+      }
 
       this._clearHideAlertHandle();
-      this._hideAlertHandle =
-        this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
-      var el = this._createToastAlert();
-      el.show(text);
+      if (opt_dismissOnNavigation) {
+        // Persist alert until navigation.
+        this.listen(document, 'location-change', '_hideAlert');
+      } else {
+        this._hideAlertHandle =
+          this.async(this._hideAlert, HIDE_ALERT_TIMEOUT_MS);
+      }
+      const el = this._createToastAlert();
+      el.show(text, opt_actionText, opt_actionCallback);
       this._alertElement = el;
     },
 
-    _hideAlert: function() {
+    _hideAlert() {
       if (!this._alertElement) { return; }
 
       this._alertElement.hide();
       this._alertElement = null;
+
+      // Remove listener for page navigation, if it exists.
+      this.unlisten(document, 'location-change', '_hideAlert');
     },
 
-    _clearHideAlertHandle: function() {
+    _clearHideAlertHandle() {
       if (this._hideAlertHandle != null) {
         this.cancelAsync(this._hideAlertHandle);
         this._hideAlertHandle = null;
       }
     },
 
-    _showAuthErrorAlert: function() {
+    _showAuthErrorAlert(errorText, actionText) {
       // TODO(viktard): close alert if it's not for auth error.
       if (this._alertElement) { return; }
 
       this._alertElement = this._createToastAlert();
-      this._alertElement.show('Auth error', 'Refresh credentials.');
-      this.listen(this._alertElement, 'action', '_createLoginPopup');
+      this._alertElement.show(errorText, actionText,
+          this._createLoginPopup.bind(this));
 
       this._refreshingCredentials = true;
       this._requestCheckLoggedIn();
@@ -140,46 +175,40 @@
       }
     },
 
-    _createToastAlert: function() {
-      var el = document.createElement('gr-alert');
+    _createToastAlert() {
+      const el = document.createElement('gr-alert');
       el.toast = true;
       return el;
     },
 
-    _handleVisibilityChange: function() {
+    _handleVisibilityChange() {
       // Ignore when the page is transitioning to hidden (or hidden is
       // undefined).
       if (document.hidden !== false) { return; }
 
-      // If we're currently in a credential refresh, flush the debouncer so that
-      // it can be checked immediately.
-      if (this._refreshingCredentials) {
-        this.flushDebouncer('checkLoggedIn');
-        return;
-      }
-
-      // If the credentials are old, request them to confirm their validity or
-      // (display an auth toast if it fails).
-      var timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
-      if (this.knownAccountId !== undefined &&
+      // If not currently refreshing credentials and the credentials are old,
+      // request them to confirm their validity or (display an auth toast if it
+      // fails).
+      const timeSinceLastCheck = Date.now() - this._lastCredentialCheck;
+      if (!this._refreshingCredentials &&
+          this.knownAccountId !== undefined &&
           timeSinceLastCheck > STALE_CREDENTIAL_THRESHOLD_MS) {
         this._lastCredentialCheck = Date.now();
         this.$.restAPI.checkCredentials();
       }
     },
 
-    _requestCheckLoggedIn: function() {
+    _requestCheckLoggedIn() {
       this.debounce(
-        'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
+          'checkLoggedIn', this._checkSignedIn, CHECK_SIGN_IN_INTERVAL_MS);
     },
 
-    _checkSignedIn: function() {
-      this.$.restAPI.checkCredentials().then(function(account) {
-        var isLoggedIn = !!account;
+    _checkSignedIn() {
+      this.$.restAPI.checkCredentials().then(account => {
+        const isLoggedIn = !!account;
         this._lastCredentialCheck = Date.now();
         if (this._refreshingCredentials) {
           if (isLoggedIn) {
-
             // If the credentials were refreshed but the account is different
             // then reload the page completely.
             if (account._account_id !== this.knownAccountId) {
@@ -192,17 +221,19 @@
             this._requestCheckLoggedIn();
           }
         }
-      }.bind(this));
+      });
     },
 
-    _reloadPage: function() {
+    _reloadPage() {
       window.location.reload();
     },
 
-    _createLoginPopup: function() {
-      var left = window.screenLeft + (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
-      var top = window.screenTop + (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
-      var options = [
+    _createLoginPopup() {
+      const left = window.screenLeft +
+          (window.outerWidth - SIGN_IN_WIDTH_PX) / 2;
+      const top = window.screenTop +
+          (window.outerHeight - SIGN_IN_HEIGHT_PX) / 2;
+      const options = [
         'width=' + SIGN_IN_WIDTH_PX,
         'height=' + SIGN_IN_HEIGHT_PX,
         'left=' + left,
@@ -210,13 +241,18 @@
       ];
       window.open(this.getBaseUrl() +
           '/login/%3FcloseAfterLogin', '_blank', options.join(','));
+      this.listen(window, 'focus', '_handleWindowFocus');
     },
 
-    _handleCredentialRefreshed: function() {
+    _handleCredentialRefreshed() {
+      this.unlisten(window, 'focus', '_handleWindowFocus');
       this._refreshingCredentials = false;
-      this.unlisten(this._alertElement, 'action', '_createLoginPopup');
       this._hideAlert();
       this._showAlert('Credentials refreshed.');
     },
+
+    _handleWindowFocus() {
+      this.flushDebouncer('checkLoggedIn');
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index 2013686..17fa746 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-error-manager.html">
 
 <script>void(0);</script>
@@ -33,38 +32,69 @@
 </test-fixture>
 
 <script>
-  suite('gr-error-manager tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-error-manager tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(true); },
+        getLoggedIn() { return Promise.resolve(true); },
       });
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('show auth error', function(done) {
-      var showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
-      element.fire('server-error', {response: {status: 403}});
-      element.$.restAPI.getLoggedIn.lastCall.returnValue.then(function() {
+    test('does not show auth error on 403 by default', done => {
+      const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('server says no.');
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        responseText,
+      ]).then(() => {
+        assert.isFalse(showAuthErrorStub.calledOnce);
+        done();
+      });
+    });
+
+    test('shows auth error on 403 and Authentication required', done => {
+      const showAuthErrorStub = sandbox.stub(element, '_showAuthErrorAlert');
+      const responseText = Promise.resolve('Authentication required\n');
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        responseText,
+      ]).then(() => {
         assert.isTrue(showAuthErrorStub.calledOnce);
         done();
       });
     });
 
-    test('show normal server error', function(done) {
-      var showAlertStub = sandbox.stub(element, '_showAlert');
-      var textSpy = sandbox.spy(function() { return Promise.resolve('ZOMG'); });
+    test('show logged in error', () => {
+      sandbox.stub(element, '_showAuthErrorAlert');
+      element.fire('show-auth-required');
+      assert.isTrue(element._showAuthErrorAlert.calledWithExactly(
+          'Log in is required to perform that action.', 'Log in.'));
+    });
+
+    test('show normal server error', done => {
+      const showAlertStub = sandbox.stub(element, '_showAlert');
+      const textSpy = sandbox.spy(() => { return Promise.resolve('ZOMG'); });
       element.fire('server-error', {response: {status: 500, text: textSpy}});
 
       assert.isTrue(textSpy.called);
-      textSpy.lastCall.returnValue.then(function() {
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        textSpy.lastCall.returnValue,
+      ]).then(() => {
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
             'Server error: ZOMG'));
@@ -72,25 +102,28 @@
       });
     });
 
-    test('suppress TOO_MANY_FILES error', function(done) {
-      var showAlertStub = sandbox.stub(element, '_showAlert');
-      var textSpy = sandbox.spy(function() {
+    test('suppress TOO_MANY_FILES error', done => {
+      const showAlertStub = sandbox.stub(element, '_showAlert');
+      const textSpy = sandbox.spy(() => {
         return Promise.resolve('too many files to find conflicts');
       });
       element.fire('server-error', {response: {status: 500, text: textSpy}});
 
       assert.isTrue(textSpy.called);
-      textSpy.lastCall.returnValue.then(function() {
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        textSpy.lastCall.returnValue,
+      ]).then(() => {
         assert.isFalse(showAlertStub.called);
         done();
       });
     });
 
-    test('show network error', function(done) {
-      var consoleErrorStub = sandbox.stub(console, 'error');
-      var showAlertStub = sandbox.stub(element, '_showAlert');
+    test('show network error', done => {
+      const consoleErrorStub = sandbox.stub(console, 'error');
+      const showAlertStub = sandbox.stub(element, '_showAlert');
       element.fire('network-error', {error: new Error('ZOMG')});
-      flush(function() {
+      flush(() => {
         assert.isTrue(showAlertStub.calledOnce);
         assert.isTrue(showAlertStub.lastCall.calledWithExactly(
             'Server unavailable'));
@@ -100,15 +133,21 @@
       });
     });
 
-    test('show auth refresh toast', function(done) {
-      var refreshStub = sandbox.stub(element.$.restAPI, 'checkCredentials',
-          function() { return Promise.resolve(true); });
-      var toastSpy = sandbox.spy(element, '_createToastAlert');
-      var windowOpen = sandbox.stub(window, 'open');
-      element.fire('server-error', {response: {status: 403}});
-      element.$.restAPI.getLoggedIn.lastCall.returnValue.then(function() {
+    test('show auth refresh toast', done => {
+      const refreshStub = sandbox.stub(element.$.restAPI, 'checkCredentials',
+          () => { return Promise.resolve(true); });
+      const toastSpy = sandbox.spy(element, '_createToastAlert');
+      const windowOpen = sandbox.stub(window, 'open');
+      const responseText = Promise.resolve('Authentication required\n');
+      element.fire('server-error',
+          {response: {status: 403, text() { return responseText; }}}
+      );
+      Promise.all([
+        element.$.restAPI.getLoggedIn.lastCall.returnValue,
+        responseText,
+      ]).then(() => {
         assert.isTrue(toastSpy.called);
-        var toast = toastSpy.lastCall.returnValue;
+        let toast = toastSpy.lastCall.returnValue;
         assert.isOk(toast);
         assert.include(
             Polymer.dom(toast.root).textContent, 'Auth error');
@@ -116,18 +155,19 @@
             Polymer.dom(toast.root).textContent, 'Refresh credentials.');
 
         assert.isFalse(windowOpen.called);
-        toast.fire('action');
+        MockInteractions.tap(toast.$$('gr-button.action'));
         assert.isTrue(windowOpen.called);
 
         // @see Issue 5822: noopener breaks closeAfterLogin
         assert.equal(windowOpen.lastCall.args[2].indexOf('noopener=yes'),
             -1);
 
-        var hideToastSpy = sandbox.spy(toast, 'hide');
+        const hideToastSpy = sandbox.spy(toast, 'hide');
 
+        element._handleWindowFocus();
         assert.isTrue(refreshStub.called);
         element.flushDebouncer('checkLoggedIn');
-        flush(function() {
+        flush(() => {
           assert.isTrue(refreshStub.called);
           assert.isTrue(hideToastSpy.called);
 
@@ -141,15 +181,18 @@
       });
     });
 
-    test('show alert', function() {
+    test('show alert', () => {
+      const alertObj = {message: 'foo'};
       sandbox.stub(element, '_showAlert');
-      element.fire('show-alert', {message: 'foo'});
+      element.fire('show-alert', alertObj);
       assert.isTrue(element._showAlert.calledOnce);
-      assert.isTrue(element._showAlert.lastCall.calledWithExactly('foo'));
+      assert.equal(element._showAlert.lastCall.args[0], 'foo');
+      assert.isNotOk(element._showAlert.lastCall.args[1]);
+      assert.isNotOk(element._showAlert.lastCall.args[2]);
     });
 
-    test('checks stale credentials on visibility change', function() {
-      var refreshStub = sandbox.stub(element.$.restAPI,
+    test('checks stale credentials on visibility change', () => {
+      const refreshStub = sandbox.stub(element.$.restAPI,
           'checkCredentials');
       sandbox.stub(Date, 'now').returns(999999);
       element._lastCredentialCheck = 0;
@@ -167,19 +210,19 @@
       assert.equal(element._lastCredentialCheck, 999999);
     });
 
-    test('refresh loop continues on credential fail', function(done) {
-      var accountPromise = Promise.resolve(null);
+    test('refresh loop continues on credential fail', done => {
+      const accountPromise = Promise.resolve(null);
       sandbox.stub(element.$.restAPI, 'checkCredentials')
           .returns(accountPromise);
-      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      var handleRefreshStub = sandbox.stub(element,
+      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sandbox.stub(element,
           '_handleCredentialRefreshed');
-      var reloadStub = sandbox.stub(element, '_reloadPage');
+      const reloadStub = sandbox.stub(element, '_reloadPage');
 
       element._refreshingCredentials = true;
       element._checkSignedIn();
 
-      accountPromise.then(function() {
+      accountPromise.then(() => {
         assert.isTrue(requestCheckStub.called);
         assert.isFalse(handleRefreshStub.called);
         assert.isFalse(reloadStub.called);
@@ -187,20 +230,20 @@
       });
     });
 
-    test('refreshes with same credentials', function(done) {
-      var accountPromise = Promise.resolve({_account_id: 1234});
+    test('refreshes with same credentials', done => {
+      const accountPromise = Promise.resolve({_account_id: 1234});
       sandbox.stub(element.$.restAPI, 'checkCredentials')
           .returns(accountPromise);
-      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      var handleRefreshStub = sandbox.stub(element,
+      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sandbox.stub(element,
           '_handleCredentialRefreshed');
-      var reloadStub = sandbox.stub(element, '_reloadPage');
+      const reloadStub = sandbox.stub(element, '_reloadPage');
 
       element.knownAccountId = 1234;
       element._refreshingCredentials = true;
       element._checkSignedIn();
 
-      accountPromise.then(function() {
+      accountPromise.then(() => {
         assert.isFalse(requestCheckStub.called);
         assert.isTrue(handleRefreshStub.called);
         assert.isFalse(reloadStub.called);
@@ -208,25 +251,32 @@
       });
     });
 
-    test('reloads when refreshed credentials differ', function(done) {
-      var accountPromise = Promise.resolve({_account_id: 1234});
+    test('reloads when refreshed credentials differ', done => {
+      const accountPromise = Promise.resolve({_account_id: 1234});
       sandbox.stub(element.$.restAPI, 'checkCredentials')
           .returns(accountPromise);
-      var requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
-      var handleRefreshStub = sandbox.stub(element,
+      const requestCheckStub = sandbox.stub(element, '_requestCheckLoggedIn');
+      const handleRefreshStub = sandbox.stub(element,
           '_handleCredentialRefreshed');
-      var reloadStub = sandbox.stub(element, '_reloadPage');
+      const reloadStub = sandbox.stub(element, '_reloadPage');
 
       element.knownAccountId = 4321; // Different from 1234
       element._refreshingCredentials = true;
       element._checkSignedIn();
 
-      accountPromise.then(function() {
+      accountPromise.then(() => {
         assert.isFalse(requestCheckStub.called);
         assert.isFalse(handleRefreshStub.called);
         assert.isTrue(reloadStub.called);
         done();
       });
     });
+
+    test('_showAlert hides existing alerts', () => {
+      element._alertElement = element._createToastAlert();
+      const hideStub = sandbox.stub(element, '_hideAlert');
+      element._showAlert();
+      assert.isTrue(hideStub.calledOnce);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index 9a3a267..03f0e53 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -16,10 +16,11 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-keyboard-shortcuts-dialog">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
@@ -32,7 +33,7 @@
       }
       header {
         align-items: center;
-        border-bottom: 1px solid #ddd;
+        border-bottom: 1px solid #cdcdcd;
         display: flex;
         justify-content: space-between;
       }
@@ -47,12 +48,12 @@
         text-align: right;
       }
       .header {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         padding-top: 1em;
       }
       .key {
         display: inline-block;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         border-radius: 3px;
         background-color: #f1f2f3;
         padding: .1em .5em;
@@ -82,7 +83,7 @@
           </tr>
         </tbody>
         <!-- Change View -->
-        <tbody hidden$="[[!_computeInView(view, 'gr-change-view')]]" hidden>
+        <tbody hidden$="[[!_computeInView(view, 'change')]]" hidden>
           <tr>
             <td></td><td class="header">Navigation</td>
           </tr>
@@ -100,7 +101,7 @@
           </tr>
         </tbody>
         <!-- Diff View -->
-        <tbody hidden$="[[!_computeInView(view, 'gr-diff-view')]]" hidden>
+        <tbody hidden$="[[!_computeInView(view, 'diff')]]" hidden>
           <tr>
             <td></td><td class="header">Navigation</td>
           </tr>
@@ -135,7 +136,7 @@
 
       <table>
         <!-- Change List -->
-        <tbody hidden$="[[!_computeInView(view, 'gr-change-list-view')]]" hidden>
+        <tbody hidden$="[[!_computeInView(view, 'search')]]" hidden>
           <tr>
             <td></td><td class="header">Change list</td>
           </tr>
@@ -162,9 +163,20 @@
             </td>
             <td>Show selected change</td>
           </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">r</span>
+            </td>
+            <td>Refresh list of changes</td>
+          </tr>
+          <tr>
+            <td><span class="key">s</span></td>
+            <td>Star (or unstar) change</td>
+          </tr>
         </tbody>
         <!-- Dashboard -->
-        <tbody hidden$="[[!_computeInView(view, 'gr-dashboard-view')]]" hidden>
+        <tbody hidden$="[[!_computeInView(view, 'dashboard')]]" hidden>
           <tr>
             <td></td><td class="header">Dashboard</td>
           </tr>
@@ -183,9 +195,20 @@
             </td>
             <td>Show selected change</td>
           </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">r</span>
+            </td>
+            <td>Refresh list of changes</td>
+          </tr>
+          <tr>
+            <td><span class="key">s</span></td>
+            <td>Star (or unstar) change</td>
+          </tr>
         </tbody>
         <!-- Change View -->
-        <tbody hidden$="[[!_computeInView(view, 'gr-change-view')]]" hidden>
+        <tbody hidden$="[[!_computeInView(view, 'change')]]" hidden>
           <tr>
             <td></td><td class="header">Actions</td>
           </tr>
@@ -205,6 +228,18 @@
             <td>Reload the change at the latest patch</td>
           </tr>
           <tr>
+            <td><span class="key">s</span></td>
+            <td>Star (or unstar) change</td>
+          </tr>
+          <tr>
+            <td><span class="key">x</span></td>
+            <td>Expand all messages</td>
+          </tr>
+          <tr>
+            <td><span class="key">z</span></td>
+            <td>Collapse all messages</td>
+          </tr>
+          <tr>
             <td></td><td class="header">File list</td>
           </tr>
           <tr>
@@ -216,10 +251,17 @@
             <td>Select previous file</td>
           </tr>
           <tr>
-            <td><span class="key">Enter</span> or <span class="key">o</span></td>
+            <td>
+              <span class="key">Enter</span> or
+              <span class="key">o</span>
+            </td>
             <td>Show selected file</td>
           </tr>
           <tr>
+            <td><span class="key">r</span></td>
+            <td>Toggle review flag on selected file</td>
+          </tr>
+          <tr>
             <td>
               <span class="key modifier">Shift</span>
               <span class="key">i</span>
@@ -303,7 +345,7 @@
           </tr>
         </tbody>
         <!-- Diff View -->
-        <tbody hidden$="[[!_computeInView(view, 'gr-diff-view')]]" hidden>
+        <tbody hidden$="[[!_computeInView(view, 'diff')]]" hidden>
           <tr>
             <td></td><td class="header">Actions</td>
           </tr>
diff --git a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
index 1a286c7..b7900c0 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.js
@@ -31,11 +31,11 @@
       role: 'dialog',
     },
 
-    _computeInView: function(currentView, view) {
+    _computeInView(currentView, view) {
       return view === currentView;
     },
 
-    _handleCloseTap: function(e) {
+    _handleCloseTap(e) {
       e.preventDefault();
       this.fire('close', null, {bubbles: false});
     },
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
index 1e6596b..e56dc91 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.html
@@ -13,18 +13,19 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-account-dropdown/gr-account-dropdown.html">
-<link rel="import" href="../../shared/gr-dropdown/gr-dropdown.html">
 <link rel="import" href="../gr-search-bar/gr-search-bar.html">
 
 <dom-module id="gr-main-header">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
@@ -40,20 +41,39 @@
       .bigTitle:hover {
         text-decoration: underline;
       }
+      /* TODO (viktard): Clean-up after chromium-style migrates to component. */
+      .titleText::before {
+        background-image: var(--header-icon);
+        background-size: var(--header-icon-size) var(--header-icon-size);
+        background-repeat: no-repeat;
+        content: "";
+        display: inline-block;
+        height: var(--header-icon-size);
+        margin: 0 .25em 0 0;
+        vertical-align: text-bottom;
+        width: var(--header-icon-size);
+      }
+      .titleText::after {
+        content: var(--header-title-content);
+      }
       ul {
         list-style: none;
       }
       .links > li {
         cursor: default;
         display: inline-block;
-        margin-left: 1em;
         padding: 0;
         position: relative;
       }
       .linksTitle {
-        color: black;
+        color: var(--primary-text-color);
         display: inline-block;
+        font-family: var(--font-family-bold);
         position: relative;
+        text-transform: uppercase;
+      }
+      .linksTitle:hover {
+        opacity: .75;
       }
       .rightItems {
         align-items: center;
@@ -61,11 +81,21 @@
         flex: 1;
         justify-content: flex-end;
       }
+      .rightItems gr-endpoint-decorator:not(:empty) {
+        margin-left: 1em;
+      }
       gr-search-bar {
         flex-grow: 1;
         margin-left: .5em;
         max-width: 500px;
       }
+      gr-dropdown {
+        padding: 0.5em;
+      }
+      .browse {
+        padding: 1em;
+        text-decoration: none;
+      }
       .accountContainer:not(.loggedIn):not(.loggedOut) .loginButton,
       .accountContainer:not(.loggedIn):not(.loggedOut) gr-account-dropdown,
       .accountContainer.loggedIn .loginButton,
@@ -75,11 +105,14 @@
       .accountContainer {
         align-items: center;
         display: flex;
-        margin-left: var(--default-horizontal-margin);
+        margin: 0 -0.5em 0 0.5em;
         white-space: nowrap;
         overflow: hidden;
         text-overflow: ellipsis;
       }
+      .loginButton {
+        padding: 1em;
+      }
       .dropdown-trigger {
         text-decoration: none;
       }
@@ -90,21 +123,31 @@
       @media screen and (max-width: 50em) {
         .bigTitle {
           font-size: 14px;
-          font-weight: bold;
+          font-family: var(--font-family-bold);
         }
-        gr-search-bar {
+        gr-search-bar,
+        .browse,
+        .rightItems .hideOnMobile,
+        .links > li.hideOnMobile {
           display: none;
         }
         .accountContainer {
           margin-left: .5em !important;
         }
+        gr-dropdown {
+          padding: .5em 0 .5em .5em;
+        }
       }
     </style>
     <nav>
-      <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">PolyGerrit</a>
+      <a href$="[[_computeRelativeURL('/')]]" class="bigTitle">
+        <gr-endpoint-decorator name="header-title">
+          <span class="titleText"></span>
+        </gr-endpoint-decorator>
+      </a>
       <ul class="links">
         <template is="dom-repeat" items="[[_links]]" as="linkGroup">
-          <li>
+          <li class$="[[linkGroup.class]]">
           <gr-dropdown
               link
               down-arrow
@@ -116,11 +159,20 @@
           </gr-dropdown>
           </li>
         </template>
+        <li>
+          <a
+              class="browse linksTitle"
+              href$="[[_computeRelativeURL('/admin/projects')]]">
+            Browse</a>
+        </li>
       </ul>
       <div class="rightItems">
         <gr-search-bar value="{{searchQuery}}" role="search"></gr-search-bar>
+        <gr-endpoint-decorator
+            class="hideOnMobile"
+            name="header-browse-source"></gr-endpoint-decorator>
         <div class="accountContainer" id="accountContainer">
-          <a class="loginButton" href$="[[_loginURL]]" on-tap="_loginTapHandler">Sign in</a>
+          <a class="loginButton" href$="[[_loginURL]]">Sign in</a>
           <gr-account-dropdown account="[[_account]]"></gr-account-dropdown>
         </div>
       </div>
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
index 3e7c82a..fe4f7cf 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header.js
@@ -14,33 +14,7 @@
 (function() {
   'use strict';
 
-  var ADMIN_LINKS = [
-    {
-      url: '/admin/groups',
-      name: 'Groups',
-    },
-    {
-      url: '/admin/create-group',
-      name: 'Create Group',
-      capability: 'createGroup'
-    },
-    {
-      url: '/admin/projects',
-      name: 'Projects',
-    },
-    {
-      url: '/admin/create-project',
-      name: 'Create Project',
-      capability: 'createProject',
-    },
-    {
-      url: '/admin/plugins',
-      name: 'Plugins',
-      capability: 'viewPlugins',
-    },
-  ];
-
-  var DEFAULT_LINKS = [{
+  const DEFAULT_LINKS = [{
     title: 'Changes',
     links: [
       {
@@ -58,31 +32,31 @@
     ],
   }];
 
-  var DOCUMENTATION_LINKS = [
+  const DOCUMENTATION_LINKS = [
     {
-      url : '/index.html',
-      name : 'Table of Contents',
+      url: '/index.html',
+      name: 'Table of Contents',
     },
     {
-      url : '/user-search.html',
-      name : 'Searching',
+      url: '/user-search.html',
+      name: 'Searching',
     },
     {
-      url : '/user-upload.html',
-      name : 'Uploading',
+      url: '/user-upload.html',
+      name: 'Uploading',
     },
     {
-      url : '/access-control.html',
-      name : 'Access Control',
+      url: '/access-control.html',
+      name: 'Access Control',
     },
     {
-      url : '/rest-api.html',
-      name : 'REST API',
+      url: '/rest-api.html',
+      name: 'REST API',
     },
     {
-      url : '/intro-project-owner.html',
-      name : 'Project Owner Guide',
-    }
+      url: '/intro-project-owner.html',
+      name: 'Project Owner Guide',
+    },
   ];
 
   Polymer({
@@ -98,24 +72,25 @@
         notify: true,
       },
 
+      /** @type {?Object} */
       _account: Object,
       _adminLinks: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _defaultLinks: {
         type: Array,
-        value: function() {
+        value() {
           return DEFAULT_LINKS;
         },
       },
       _docBaseUrl: {
         type: String,
+        value: null,
       },
       _links: {
         type: Array,
-        computed: '_computeLinks(_defaultLinks, _userLinks, _adminLinks, ' +
-            '_docBaseUrl)',
+        computed: '_computeLinks(_defaultLinks, _userLinks, _docBaseUrl)',
       },
       _loginURL: {
         type: String,
@@ -123,38 +98,40 @@
       },
       _userLinks: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
     },
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
+      Gerrit.DocsUrlBehavior,
     ],
 
     observers: [
       '_accountLoaded(_account)',
     ],
 
-    attached: function() {
+    attached() {
       this._loadAccount();
       this._loadConfig();
       this.listen(window, 'location-change', '_handleLocationChange');
     },
 
-    detached: function() {
+    detached() {
       this.unlisten(window, 'location-change', '_handleLocationChange');
     },
 
-    reload: function() {
+    reload() {
       this._loadAccount();
     },
 
-    _handleLocationChange: function(e) {
-      if (this.getBaseUrl()) {
+    _handleLocationChange(e) {
+      const baseUrl = this.getBaseUrl();
+      if (baseUrl) {
         // Strip the canonical path from the path since needing canonical in
         // the path is uneeded and breaks the url.
-        this._loginURL = this.getBaseUrl() + '/login/' + encodeURIComponent(
-            '/' + window.location.pathname.substring(this.getBaseUrl().length) +
+        this._loginURL = baseUrl + '/login/' + encodeURIComponent(
+            '/' + window.location.pathname.substring(baseUrl.length) +
             window.location.search +
             window.location.hash);
       } else {
@@ -165,40 +142,35 @@
       }
     },
 
-    _computeRelativeURL: function(path) {
+    _computeRelativeURL(path) {
       return '//' + window.location.host + this.getBaseUrl() + path;
     },
 
-    _computeLinks: function(defaultLinks, userLinks, adminLinks, docBaseUrl) {
-      var links = defaultLinks.slice();
+    _computeLinks(defaultLinks, userLinks, docBaseUrl) {
+      const links = defaultLinks.slice();
       if (userLinks && userLinks.length > 0) {
         links.push({
           title: 'Your',
           links: userLinks,
         });
       }
-      if (adminLinks && adminLinks.length > 0) {
-        links.push({
-          title: 'Admin',
-          links: adminLinks,
-        });
-      }
-      var docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
+      const docLinks = this._getDocLinks(docBaseUrl, DOCUMENTATION_LINKS);
       if (docLinks.length) {
         links.push({
           title: 'Documentation',
           links: docLinks,
+          class: 'hideOnMobile',
         });
       }
       return links;
     },
 
-    _getDocLinks: function(docBaseUrl, docLinks) {
+    _getDocLinks(docBaseUrl, docLinks) {
       if (!docBaseUrl || !docLinks) {
         return [];
       }
-      return docLinks.map(function(link) {
-        var url = docBaseUrl;
+      return docLinks.map(link => {
+        let url = docBaseUrl;
         if (url && url[url.length - 1] === '/') {
           url = url.substring(0, url.length - 1);
         }
@@ -210,59 +182,32 @@
       });
     },
 
-    _loadAccount: function() {
-      this.$.restAPI.getAccount().then(function(account) {
+    _loadAccount() {
+      return this.$.restAPI.getAccount().then(account => {
         this._account = account;
         this.$.accountContainer.classList.toggle('loggedIn', account != null);
         this.$.accountContainer.classList.toggle('loggedOut', account == null);
-      }.bind(this));
+      });
     },
 
-    _loadConfig: function() {
-      this.$.restAPI.getConfig().then(function(config) {
-        if (config && config.gerrit && config.gerrit.doc_url) {
-          this._docBaseUrl = config.gerrit.doc_url;
-        }
-        if (!this._docBaseUrl) {
-          return this._probeDocLink('/Documentation/index.html');
-        }
-      }.bind(this));
+    _loadConfig() {
+      this.$.restAPI.getConfig()
+          .then(config => this.getDocsBaseUrl(config, this.$.restAPI))
+          .then(docBaseUrl => { this._docBaseUrl = docBaseUrl; });
     },
 
-    _probeDocLink: function(path) {
-      return this.$.restAPI.probePath(this.getBaseUrl() + path).then(function(ok) {
-        if (ok) {
-          this._docBaseUrl = this.getBaseUrl() + '/Documentation';
-        } else {
-          this._docBaseUrl = null;
-        }
-      }.bind(this));
-    },
-
-    _accountLoaded: function(account) {
+    _accountLoaded(account) {
       if (!account) { return; }
 
-      this.$.restAPI.getPreferences().then(function(prefs) {
+      this.$.restAPI.getPreferences().then(prefs => {
         this._userLinks =
             prefs.my.map(this._fixMyMenuItem).filter(this._isSupportedLink);
-      }.bind(this));
-      this._loadAccountCapabilities();
+      });
     },
 
-    _loadAccountCapabilities: function() {
-      var params = ['createProject', 'createGroup', 'viewPlugins'];
-      return this.$.restAPI.getAccountCapabilities(params)
-          .then(function(capabilities) {
-        this._adminLinks = ADMIN_LINKS.filter(function(link) {
-          return !link.capability ||
-              capabilities.hasOwnProperty(link.capability);
-        });
-      }.bind(this));
-    },
-
-    _fixMyMenuItem: function(linkObj) {
+    _fixMyMenuItem(linkObj) {
       // Normalize all urls to PolyGerrit style.
-      if (linkObj.url.indexOf('#') === 0) {
+      if (linkObj.url.startsWith('#')) {
         linkObj.url = linkObj.url.slice(1);
       }
 
@@ -283,9 +228,9 @@
       return linkObj;
     },
 
-    _isSupportedLink: function(linkObj) {
+    _isSupportedLink(linkObj) {
       // Groups are not yet supported.
-      return linkObj.url.indexOf('/groups') !== 0;
+      return !linkObj.url.startsWith('/groups');
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
index bea5736..5da8382 100644
--- a/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
+++ b/polygerrit-ui/app/elements/core/gr-main-header/gr-main-header_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-main-header.html">
 
 <script>void(0);</script>
@@ -33,109 +32,73 @@
 </test-fixture>
 
 <script>
-  suite('gr-main-header tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-main-header tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        probePath: function(path) { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        probePath(path) { return Promise.resolve(false); },
       });
       stub('gr-main-header', {
-        _loadAccount: function() {},
+        _loadAccount() {},
       });
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('fix my menu item', function() {
+    test('fix my menu item', () => {
       assert.deepEqual([
-        {url: '#/q/owner:self+is:draft'},
         {url: 'https://awesometown.com/#hashyhash'},
         {url: 'url', target: '_blank'},
-      ].map(element._fixMyMenuItem),
-      [
-        {url: '/q/owner:self+is:draft', external: true},
+      ].map(element._fixMyMenuItem), [
         {url: 'https://awesometown.com/#hashyhash', external: true},
         {url: 'url', external: true},
       ]);
     });
 
-    test('filter unsupported urls', function() {
+    test('filter unsupported urls', () => {
       assert.deepEqual([
-        {url: '/q/owner:self+is:draft'},
         {url: '/c/331788/'},
         {url: '/groups/self'},
         {url: 'https://awesometown.com/#hashyhash'},
-      ].filter(element._isSupportedLink),
-      [
-        {url: '/q/owner:self+is:draft'},
+      ].filter(element._isSupportedLink), [
         {url: '/c/331788/'},
         {url: 'https://awesometown.com/#hashyhash'},
       ]);
     });
 
-    test('_loadAccountCapabilities admin', function(done) {
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', function() {
-        return Promise.resolve({
-          createGroup: true,
-          createProject: true,
-          viewPlugins: true,
-        });
-      });
-      element._loadAccountCapabilities().then(function() {
-        assert.equal(element._adminLinks.length, 5);
-        done();
-      });
-    });
 
-    test('_loadAccountCapabilities non admin', function(done) {
-      sandbox.stub(element.$.restAPI, 'getAccountCapabilities', function() {
-        return Promise.resolve({});
-      });
-      element._loadAccountCapabilities().then(function() {
-        assert.equal(element._adminLinks.length, 2);
-        done();
-      });
-    });
-
-    test('user links', function() {
-      var defaultLinks = [{
+    test('user links', () => {
+      const defaultLinks = [{
         title: 'Faves',
         links: [{
           name: 'Pinterest',
           url: 'https://pinterest.com',
         }],
       }];
-      var userLinks = [{
+      const userLinks = [{
         name: 'Facebook',
         url: 'https://facebook.com',
       }];
-      var adminLinks = [{
-        url: '/admin/groups',
-        name: 'Groups',
-      }];
 
+      // When no admin links are passed, it should use the default.
+      assert.deepEqual(element._computeLinks(defaultLinks, []), defaultLinks);
       assert.deepEqual(
-          element._computeLinks(defaultLinks, [], []), defaultLinks);
-      assert.deepEqual(
-          element._computeLinks(defaultLinks, userLinks, adminLinks),
+          element._computeLinks(defaultLinks, userLinks),
           defaultLinks.concat({
             title: 'Your',
             links: userLinks,
-          }, {
-            title: 'Admin',
-            links: adminLinks,
           }));
     });
 
-    test('documentation links', function() {
-      var docLinks = [
+    test('documentation links', () => {
+      const docLinks = [
         {
           name: 'Table of Contents',
           url: '/index.html',
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
new file mode 100644
index 0000000..2ed7952
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation.html
@@ -0,0 +1,333 @@
+<!--
+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.
+-->
+<script>
+  (function(window) {
+    'use strict';
+
+    // Navigation parameters object format:
+    //
+    // Each object has a `view` property with a value from Gerrit.Nav.View. The
+    // remaining properties depend on the value used for view.
+    //
+    //  - Gerrit.Nav.View.CHANGE:
+    //    - `changeNum`, required, String: the numeric ID of the change.
+    //
+    // - Gerrit.Nav.View.SEARCH:
+    //    - `owner`, optional, String: the owner name.
+    //    - `project`, optional, String: the project name.
+    //    - `branch`, optional, String: the branch name.
+    //    - `topic`, optional, String: the topic name.
+    //    - `hashtag`, optional, String: the hashtag name.
+    //    - `statuses`, optional, Array<String>: the list of change statuses to
+    //        search for. If more than one is provided, the search will OR them
+    //        together.
+    //
+    //  - Gerrit.Nav.View.DIFF:
+    //    - `changeNum`, required, String: the numeric ID of the change.
+    //    - `path`, required, String: the filepath of the diff.
+    //    - `patchNum`, required, Number, the patch for the right-hand-side of
+    //        the diff.
+    //    - `basePatchNum`, optional, Number, the patch for the left-hand-side
+    //        of the diff. If `basePatchNum` is provided, then `patchNum` must
+    //        also be provided.
+    //    - `lineNum`, optional, Number, the line number to be selected on load.
+    //    - `leftSide`, optional, Boolean, if a `lineNum` is provided, a value
+    //        of true selects the line from base of the patch range. False by
+    //        default.
+
+    window.Gerrit = window.Gerrit || {};
+
+    // Prevent redefinition.
+    if (window.Gerrit.hasOwnProperty('Nav')) { return; }
+
+    const uninitialized = () => {
+      console.warn('Use of uninitialized routing');
+    };
+
+    const PARENT_PATCHNUM = 'PARENT';
+
+    window.Gerrit.Nav = {
+
+      View: {
+        ADMIN: 'admin',
+        CHANGE: 'change',
+        AGREEMENTS: 'agreements',
+        DASHBOARD: 'dashboard',
+        DIFF: 'diff',
+        EDIT: 'edit',
+        SEARCH: 'search',
+        SETTINGS: 'settings',
+      },
+
+      /** @type {Function} */
+      _navigate: uninitialized,
+
+      /** @type {Function} */
+      _generateUrl: uninitialized,
+
+      /**
+       * @param {number=} patchNum
+       * @param {number|string=} basePatchNum
+       */
+      _checkPatchRange(patchNum, basePatchNum) {
+        if (basePatchNum && !patchNum) {
+          throw new Error('Cannot use base patch number without patch number.');
+        }
+      },
+
+      /**
+       * Setup router implementation.
+       * @param {Function} navigate
+       * @param {Function} generateUrl
+       */
+      setup(navigate, generateUrl) {
+        this._navigate = navigate;
+        this._generateUrl = generateUrl;
+      },
+
+      destroy() {
+        this._navigate = uninitialized;
+        this._generateUrl = uninitialized;
+      },
+
+      /**
+       * Generate a URL for the given route parameters.
+       * @param {Object} params
+       * @return {string}
+       */
+      _getUrlFor(params) {
+        return this._generateUrl(params);
+      },
+
+      /**
+       * @param {!string} project The name of the project.
+       * @param {boolean=} opt_openOnly When true, only search open changes in
+       *     the project.
+       * @return {string}
+       */
+      getUrlForProject(project, opt_openOnly) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.SEARCH,
+          project,
+          statuses: opt_openOnly ? ['open'] : [],
+        });
+      },
+
+      /**
+       * @param {string} branch The name of the branch.
+       * @param {string} project The name of the project.
+       * @param {string=} opt_status The status to search.
+       * @return {string}
+       */
+      getUrlForBranch(branch, project, opt_status) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.SEARCH,
+          branch,
+          project,
+          statuses: opt_status ? [opt_status] : undefined,
+        });
+      },
+
+      /**
+       * @param {string} topic The name of the topic.
+       * @return {string}
+       */
+      getUrlForTopic(topic) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.SEARCH,
+          topic,
+          statuses: ['open', 'merged'],
+        });
+      },
+
+      /**
+       * @param {string} hashtag The name of the hashtag.
+       * @return {string}
+       */
+      getUrlForHashtag(hashtag) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.SEARCH,
+          hashtag,
+          statuses: ['open', 'merged'],
+        });
+      },
+
+      /**
+       * @param {!Object} change The change object.
+       * @param {number=} opt_patchNum
+       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+       *     used for none.
+       * @return {string}
+       */
+      getUrlForChange(change, opt_patchNum, opt_basePatchNum) {
+        if (opt_basePatchNum === PARENT_PATCHNUM) {
+          opt_basePatchNum = undefined;
+        }
+
+        this._checkPatchRange(opt_patchNum, opt_basePatchNum);
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.CHANGE,
+          changeNum: change._number,
+          project: change.project,
+          patchNum: opt_patchNum,
+          basePatchNum: opt_basePatchNum,
+        });
+      },
+
+      /**
+       * @param {number} changeNum
+       * @param {string} project The name of the project.
+       * @param {number=} opt_patchNum
+       * @return {string}
+       */
+      getUrlForChangeById(changeNum, project, opt_patchNum) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.CHANGE,
+          changeNum,
+          project,
+          patchNum: opt_patchNum,
+        });
+      },
+
+      /**
+       * @param {!Object} change The change object.
+       * @param {number=} opt_patchNum
+       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+       *     used for none.
+       */
+      navigateToChange(change, opt_patchNum, opt_basePatchNum) {
+        this._navigate(this.getUrlForChange(change, opt_patchNum,
+            opt_basePatchNum));
+      },
+
+      /**
+       * @param {{ _number: number, project: string }} change The change object.
+       * @param {string} path The file path.
+       * @param {number=} opt_patchNum
+       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+       *     used for none.
+       * @return {string}
+       */
+      getUrlForDiff(change, path, opt_patchNum, opt_basePatchNum) {
+        return this.getUrlForDiffById(change._number, change.project, path,
+            opt_patchNum, opt_basePatchNum);
+      },
+
+      /**
+       * @param {number} changeNum
+       * @param {string} project The name of the project.
+       * @param {string} path The file path.
+       * @param {number=} opt_patchNum
+       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+       *     used for none.
+       * @param {number=} opt_lineNum
+       * @param {boolean=} opt_leftSide
+       * @return {string}
+       */
+      getUrlForDiffById(changeNum, project, path, opt_patchNum,
+          opt_basePatchNum, opt_lineNum, opt_leftSide) {
+        if (opt_basePatchNum === PARENT_PATCHNUM) {
+          opt_basePatchNum = undefined;
+        }
+
+        this._checkPatchRange(opt_patchNum, opt_basePatchNum);
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.DIFF,
+          changeNum,
+          project,
+          path,
+          patchNum: opt_patchNum,
+          basePatchNum: opt_basePatchNum,
+          lineNum: opt_lineNum,
+          leftSide: opt_leftSide,
+        });
+      },
+
+      /**
+       * @param {{ _number: number, project: string }} change The change object.
+       * @param {string} path The file path.
+       * @return {string}
+       */
+      getEditUrlForDiff(change, path) {
+        return this.getEditUrlForDiffById(change._number, change.project, path);
+      },
+
+      /**
+       * @param {number} changeNum
+       * @param {string} project The name of the project.
+       * @param {string} path The file path.
+       * @return {string}
+       */
+      getEditUrlForDiffById(changeNum, project, path) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.EDIT,
+          changeNum,
+          project,
+          path,
+        });
+      },
+
+      /**
+       * @param {!Object} change The change object.
+       * @param {string} path The file path.
+       * @param {number=} opt_patchNum
+       * @param {number|string=} opt_basePatchNum The string 'PARENT' can be
+       *     used for none.
+       */
+      navigateToDiff(change, path, opt_patchNum, opt_basePatchNum) {
+        this._navigate(this.getUrlForDiff(change, path, opt_patchNum,
+            opt_basePatchNum));
+      },
+
+      /**
+       * @param {string} owner The name of the owner.
+       * @return {string}
+       */
+      getUrlForOwner(owner) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.SEARCH,
+          owner,
+        });
+      },
+
+      /**
+       * @param {string} user The name of the user.
+       * @return {string}
+       */
+      getUrlForUserDashboard(user) {
+        return this._getUrlFor({
+          view: Gerrit.Nav.View.DASHBOARD,
+          user,
+        });
+      },
+
+      /**
+       * Navigate to an arbitrary relative URL.
+       * @param {string} relativeUrl
+       */
+      navigateToRelativeUrl(relativeUrl) {
+        if (!relativeUrl.startsWith('/')) {
+          throw new Error('navigateToRelativeUrl with non-relative URL');
+        }
+        this._navigate(relativeUrl);
+      },
+
+      getUrlForSettings() {
+        return this._getUrlFor({view: Gerrit.Nav.View.SETTINGS});
+      },
+    };
+  })(window);
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
new file mode 100644
index 0000000..b829e83
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-navigation/gr-navigation_test.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-navigation</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<script>
+  suite('gr-navigation tests', () => {
+    test('invalid patch ranges throw exceptions', () => {
+      assert.throw(() => Gerrit.Nav.getUrlForChange('123', undefined, 12));
+      assert.throw(() => Gerrit.Nav.getUrlForDiff('123', 'x.c', undefined, 12));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index 1f96014..5a8ddf6 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -15,7 +15,7 @@
   'use strict';
 
   // Latency reporting constants.
-  var TIMING = {
+  const TIMING = {
     TYPE: 'timing-report',
     CATEGORY: 'UI Latency',
     // Reported events - alphabetize below.
@@ -24,25 +24,25 @@
   };
 
   // Navigation reporting constants.
-  var NAVIGATION = {
+  const NAVIGATION = {
     TYPE: 'nav-report',
     CATEGORY: 'Location Changed',
     PAGE: 'Page',
   };
 
-  var ERROR = {
+  const ERROR = {
     TYPE: 'error',
     CATEGORY: 'exception',
   };
 
-  var INTERACTION_TYPE = 'interaction';
+  const INTERACTION_TYPE = 'interaction';
 
-  var CHANGE_VIEW_REGEX = /^\/c\/\d+\/?\d*$/;
-  var DIFF_VIEW_REGEX = /^\/c\/\d+\/\d+\/.+$/;
+  const CHANGE_VIEW_REGEX = /^\/c\/\d+\/?\d*$/;
+  const DIFF_VIEW_REGEX = /^\/c\/\d+\/\d+\/.+$/;
 
-  var pending = [];
+  const pending = [];
 
-  var onError = function(oldOnError, msg, url, line, column, error) {
+  const onError = function(oldOnError, msg, url, line, column, error) {
     if (oldOnError) {
       oldOnError(msg, url, line, column, error);
     }
@@ -51,23 +51,30 @@
       column = column || error.columnNumber;
       msg = msg || error.toString();
     }
-    var payload = {
-      url: url,
-      line: line,
-      column: column,
-      error: error,
+    const payload = {
+      url,
+      line,
+      column,
+      error,
     };
     GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
     return true;
   };
 
-  var catchErrors = function(opt_context) {
-    var context = opt_context || window;
+  const catchErrors = function(opt_context) {
+    const context = opt_context || window;
     context.onerror = onError.bind(null, context.onerror);
+    context.addEventListener('unhandledrejection', e => {
+      const msg = e.reason.message;
+      const payload = {
+        error: e.reason,
+      };
+      GrReporting.prototype.reporter(ERROR.TYPE, ERROR.CATEGORY, msg, payload);
+    });
   };
   catchErrors();
 
-  var GrReporting = Polymer({
+  const GrReporting = Polymer({
     is: 'gr-reporting',
 
     properties: {
@@ -75,7 +82,7 @@
 
       _baselines: {
         type: Array,
-        value: function() { return {}; },
+        value() { return {}; },
       },
     },
 
@@ -87,24 +94,24 @@
       return window.performance.timing;
     },
 
-    now: function() {
+    now() {
       return Math.round(10 * window.performance.now()) / 10;
     },
 
-    reporter: function() {
-      var report = (Gerrit._arePluginsLoaded() && !pending.length) ?
+    reporter(...args) {
+      const report = (Gerrit._arePluginsLoaded() && !pending.length) ?
         this.defaultReporter : this.cachingReporter;
-      report.apply(this, arguments);
+      report.apply(this, args);
     },
 
-    defaultReporter: function(type, category, eventName, eventValue) {
-      var detail = {
-        type: type,
-        category: category,
+    defaultReporter(type, category, eventName, eventValue) {
+      const detail = {
+        type,
+        category,
         name: eventName,
         value: eventValue,
       };
-      document.dispatchEvent(new CustomEvent(type, {detail: detail}));
+      document.dispatchEvent(new CustomEvent(type, {detail}));
       if (type === ERROR.TYPE) {
         console.error(eventValue.error || eventName);
       } else {
@@ -113,15 +120,15 @@
       }
     },
 
-    cachingReporter: function(type, category, eventName, eventValue) {
+    cachingReporter(type, category, eventName, eventValue) {
       if (type === ERROR.TYPE) {
         console.error(eventValue.error || eventName);
       }
       if (Gerrit._arePluginsLoaded()) {
         if (pending.length) {
-          pending.splice(0).forEach(function(args) {
-            this.reporter.apply(this, args);
-          }, this);
+          for (const args of pending.splice(0)) {
+            this.reporter(...args);
+          }
         }
         this.reporter(type, category, eventName, eventValue);
       } else {
@@ -132,8 +139,8 @@
     /**
      * User-perceived app start time, should be reported when the app is ready.
      */
-    appStarted: function() {
-      var startTime =
+    appStarted() {
+      const startTime =
           new Date().getTime() - this.performanceTiming.navigationStart;
       this.reporter(
           TIMING.TYPE, TIMING.CATEGORY, TIMING.APP_STARTED, startTime);
@@ -142,22 +149,22 @@
     /**
      * Page load time, should be reported at any time after navigation.
      */
-    pageLoaded: function() {
+    pageLoaded() {
       if (this.performanceTiming.loadEventEnd === 0) {
         console.error('pageLoaded should be called after window.onload');
         this.async(this.pageLoaded, 100);
       } else {
-        var loadTime = this.performanceTiming.loadEventEnd -
+        const loadTime = this.performanceTiming.loadEventEnd -
             this.performanceTiming.navigationStart;
         this.reporter(
-          TIMING.TYPE, TIMING.CATEGORY, TIMING.PAGE_LOADED, loadTime);
+            TIMING.TYPE, TIMING.CATEGORY, TIMING.PAGE_LOADED, loadTime);
       }
     },
 
-    locationChanged: function() {
-      var page = '';
-      var pathname = this._getPathname();
-      if (pathname.indexOf('/q/') === 0) {
+    locationChanged() {
+      let page = '';
+      const pathname = this._getPathname();
+      if (pathname.startsWith('/q/')) {
         page = this.getBaseUrl() + '/q/';
       } else if (pathname.match(CHANGE_VIEW_REGEX)) { // change view
         page = this.getBaseUrl() + '/c/';
@@ -171,32 +178,32 @@
           NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
     },
 
-    pluginsLoaded: function() {
+    pluginsLoaded() {
       this.timeEnd('PluginsLoaded');
     },
 
-    _getPathname: function() {
+    _getPathname() {
       return '/' + window.location.pathname.substring(this.getBaseUrl().length);
     },
 
     /**
      * Reset named timer.
      */
-    time: function(name) {
+    time(name) {
       this._baselines[name] = this.now();
     },
 
     /**
      * Finish named timer and report it to server.
      */
-    timeEnd: function(name) {
-      var baseTime = this._baselines[name] || 0;
-      var time = this.now() - baseTime;
+    timeEnd(name) {
+      const baseTime = this._baselines[name] || 0;
+      const time = Math.round(this.now() - baseTime) + 'ms';
       this.reporter(TIMING.TYPE, TIMING.CATEGORY, name, time);
       delete this._baselines[name];
     },
 
-    reportInteraction: function(eventName, opt_msg) {
+    reportInteraction(eventName, opt_msg) {
       this.reporter(INTERACTION_TYPE, this.category, eventName, opt_msg);
     },
   });
@@ -204,5 +211,4 @@
   window.GrReporting = GrReporting;
   // Expose onerror installation so it would be accessible from tests.
   window.GrReporting._catchErrors = catchErrors;
-
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index 2720ebd..e88096b 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -20,7 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-reporting.html">
 
 <script>void(0);</script>
@@ -32,15 +32,15 @@
 </test-fixture>
 
 <script>
-  suite('gr-reporting tests', function() {
-    var element;
-    var sandbox;
-    var clock;
-    var fakePerformance;
+  suite('gr-reporting tests', () => {
+    let element;
+    let sandbox;
+    let clock;
+    let fakePerformance;
 
-    var NOW_TIME = 100;
+    const NOW_TIME = 100;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       clock = sinon.useFakeTimers(NOW_TIME);
       element = fixture('basic');
@@ -49,15 +49,15 @@
         loadEventEnd: 2,
       };
       sinon.stub(element, 'performanceTiming',
-          {get: function() {return fakePerformance;}});
+          {get() { return fakePerformance; }});
       sandbox.stub(element, 'reporter');
     });
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
       clock.restore();
     });
 
-    test('appStarted', function() {
+    test('appStarted', () => {
       element.appStarted();
       assert.isTrue(
           element.reporter.calledWithExactly(
@@ -66,7 +66,7 @@
       ));
     });
 
-    test('pageLoaded', function() {
+    test('pageLoaded', () => {
       element.pageLoaded();
       assert.isTrue(
           element.reporter.calledWithExactly(
@@ -75,8 +75,8 @@
       );
     });
 
-    test('time and timeEnd', function() {
-      var nowStub = sandbox.stub(element, 'now').returns(0);
+    test('time and timeEnd', () => {
+      const nowStub = sandbox.stub(element, 'now').returns(0);
       element.time('foo');
       nowStub.returns(1);
       element.time('bar');
@@ -85,42 +85,42 @@
       nowStub.returns(3.123);
       element.timeEnd('foo');
       assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'foo', 3.123
+          'timing-report', 'UI Latency', 'foo', '3ms'
       ));
       assert.isTrue(element.reporter.calledWithExactly(
-          'timing-report', 'UI Latency', 'bar', 1
+          'timing-report', 'UI Latency', 'bar', '1ms'
       ));
     });
 
-    suite('plugins', function() {
-      setup(function() {
+    suite('plugins', () => {
+      setup(() => {
         element.reporter.restore();
         sandbox.stub(element, 'defaultReporter');
         sandbox.stub(Gerrit, '_arePluginsLoaded');
       });
 
-      test('pluginsLoaded reports time', function() {
+      test('pluginsLoaded reports time', () => {
         Gerrit._arePluginsLoaded.returns(true);
         sandbox.stub(element, 'now').returns(42);
         element.pluginsLoaded();
         assert.isTrue(element.defaultReporter.calledWithExactly(
-            'timing-report', 'UI Latency', 'PluginsLoaded', 42
+            'timing-report', 'UI Latency', 'PluginsLoaded', '42ms'
         ));
       });
 
-      test('caches reports if plugins are not loaded', function() {
+      test('caches reports if plugins are not loaded', () => {
         Gerrit._arePluginsLoaded.returns(false);
         element.timeEnd('foo');
         assert.isFalse(element.defaultReporter.called);
       });
 
-      test('reports if plugins are loaded', function() {
+      test('reports if plugins are loaded', () => {
         Gerrit._arePluginsLoaded.returns(true);
         element.timeEnd('foo');
         assert.isTrue(element.defaultReporter.called);
       });
 
-      test('reports cached events preserving order', function() {
+      test('reports cached events preserving order', () => {
         Gerrit._arePluginsLoaded.returns(false);
         element.timeEnd('foo');
         Gerrit._arePluginsLoaded.returns(true);
@@ -134,38 +134,38 @@
       });
     });
 
-    suite('location changed', function() {
-      var pathnameStub;
-      setup(function() {
+    suite('location changed', () => {
+      let pathnameStub;
+      setup(() => {
         pathnameStub = sinon.stub(element, '_getPathname');
       });
 
-      teardown(function() {
+      teardown(() => {
         pathnameStub.restore();
       });
 
-      test('search', function() {
+      test('search', () => {
         pathnameStub.returns('/q/foo');
         element.locationChanged();
         assert.isTrue(element.reporter.calledWithExactly(
             'nav-report', 'Location Changed', 'Page', '/q/'));
       });
 
-      test('change view', function() {
+      test('change view', () => {
         pathnameStub.returns('/c/42/');
         element.locationChanged();
         assert.isTrue(element.reporter.calledWithExactly(
             'nav-report', 'Location Changed', 'Page', '/c/'));
       });
 
-      test('change view', function() {
+      test('change view', () => {
         pathnameStub.returns('/c/41/2');
         element.locationChanged();
         assert.isTrue(element.reporter.calledWithExactly(
             'nav-report', 'Location Changed', 'Page', '/c/'));
       });
 
-      test('diff view', function() {
+      test('diff view', () => {
         pathnameStub.returns('/c/41/2/file.txt');
         element.locationChanged();
         assert.isTrue(element.reporter.calledWithExactly(
@@ -173,38 +173,51 @@
       });
     });
 
-    suite('exception logging', function() {
-      var fakeWindow;
-      var reporter;
+    suite('exception logging', () => {
+      let fakeWindow;
+      let reporter;
 
-      var emulateThrow = function(msg, url, line, column, error) {
+      const emulateThrow = function(msg, url, line, column, error) {
         return fakeWindow.onerror(msg, url, line, column, error);
       };
 
-      setup(function() {
+      setup(() => {
         reporter = sandbox.stub(GrReporting.prototype, 'reporter');
-        fakeWindow = {};
+        fakeWindow = {
+          handlers: {},
+          addEventListener(type, handler) {
+            this.handlers[type] = handler;
+          },
+        };
         sandbox.stub(console, 'error');
         window.GrReporting._catchErrors(fakeWindow);
       });
 
-      test('is reported', function() {
-        var error = new Error('bar');
+      test('is reported', () => {
+        const error = new Error('bar');
         emulateThrow('bar', 'http://url', 4, 2, error);
-        assert.isTrue(
-            reporter.calledWith('error', 'exception', 'bar'));
-        var payload = reporter.lastCall.args[3];
+        assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+        const payload = reporter.lastCall.args[3];
         assert.deepEqual(payload, {
           url: 'http://url',
           line: 4,
           column: 2,
-          error: error,
+          error,
         });
       });
 
-      test('prevent default event handler', function() {
+      test('prevent default event handler', () => {
         assert.isTrue(emulateThrow());
       });
+
+      test('unhandled rejection', () => {
+        fakeWindow.handlers['unhandledrejection']({
+          reason: {
+            message: 'bar',
+          },
+        });
+        assert.isTrue(reporter.calledWith('error', 'exception', 'bar'));
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.html b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
index 5a494b1..2ef65f4 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.html
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.html
@@ -13,12 +13,19 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-reporting/gr-reporting.html">
 
 <dom-module id="gr-router">
+  <template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
   <script src="../../../bower_components/page/page.js"></script>
   <script src="gr-router.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index cca5154..49e7012 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -14,14 +14,145 @@
 (function() {
   'use strict';
 
+  const RoutePattern = {
+    ROOT: '/',
+    DASHBOARD: '/dashboard/(.*)',
+    ADMIN_PLACEHOLDER: '/admin/(.*)',
+    AGREEMENTS: /^\/settings\/(agreements|new-agreement)/,
+    REGISTER: /^\/register(\/.*)?$/,
+
+    // Pattern for login and logout URLs intended to be passed-through. May
+    // include a return URL.
+    LOG_IN_OR_OUT: /\/log(in|out)(\/(.+))?$/,
+
+    // Pattern for a catchall route when no other pattern is matched.
+    DEFAULT: /.*/,
+
+    // Matches /admin/groups/[uuid-]<group>
+    GROUP: /^\/admin\/groups\/(?:uuid-)?([^,]+)$/,
+
+    // Matches /admin/groups/[uuid-]<group>,info (backwords compat with gwtui)
+    // Redirects to /admin/groups/[uuid-]<group>
+    GROUP_INFO: /^\/admin\/groups\/(?:uuid-)?(.+),info$/,
+
+    // Matches /admin/groups/<group>,audit-log
+    GROUP_AUDIT_LOG: /^\/admin\/groups\/(?:uuid-)?(.+),audit-log$/,
+
+    // Matches /admin/groups/[uuid-]<group>,members
+    GROUP_MEMBERS: /^\/admin\/groups\/(?:uuid-)?(.+),members$/,
+
+    // Matches /admin/groups[,<offset>][/].
+    GROUP_LIST_OFFSET: /^\/admin\/groups(,(\d+))?(\/)?$/,
+    GROUP_LIST_FILTER: '/admin/groups/q/filter::filter',
+    GROUP_LIST_FILTER_OFFSET: '/admin/groups/q/filter::filter,:offset',
+
+    // Matches /admin/create-project
+    LEGACY_CREATE_PROJECT: /^\/admin\/create-project\/?$/,
+
+    // Matches /admin/create-project
+    LEGACY_CREATE_GROUP: /^\/admin\/create-group\/?$/,
+
+    // Matches /admin/projects/<project>
+    PROJECT: /^\/admin\/projects\/([^,]+)$/,
+
+    // Matches /admin/projects/<project>,commands.
+    PROJECT_COMMANDS: /^\/admin\/projects\/(.+),commands$/,
+
+    // Matches /admin/projects/<project>,access.
+    PROJECT_ACCESS: /^\/admin\/projects\/(.+),access$/,
+
+    // Matches /admin/projects[,<offset>][/].
+    PROJECT_LIST_OFFSET: /^\/admin\/projects(,(\d+))?(\/)?$/,
+    PROJECT_LIST_FILTER: '/admin/projects/q/filter::filter',
+    PROJECT_LIST_FILTER_OFFSET: '/admin/projects/q/filter::filter,:offset',
+
+    // Matches /admin/projects/<project>,branches[,<offset>].
+    BRANCH_LIST_OFFSET: /^\/admin\/projects\/(.+),branches(,(.+))?$/,
+    BRANCH_LIST_FILTER: '/admin/projects/:project,branches/q/filter::filter',
+    BRANCH_LIST_FILTER_OFFSET:
+        '/admin/projects/:project,branches/q/filter::filter,:offset',
+
+    // Matches /admin/projects/<project>,tags[,<offset>].
+    TAG_LIST_OFFSET: /^\/admin\/projects\/(.+),tags(,(.+))?$/,
+    TAG_LIST_FILTER: '/admin/projects/:project,tags/q/filter::filter',
+    TAG_LIST_FILTER_OFFSET:
+        '/admin/projects/:project,tags/q/filter::filter,:offset',
+
+    PLUGINS: /^\/plugins\/(.+)$/,
+
+    PLUGIN_LIST: /^\/admin\/plugins(\/)?$/,
+
+    // Matches /admin/plugins[,<offset>][/].
+    PLUGIN_LIST_OFFSET: /^\/admin\/plugins(,(\d+))?(\/)?$/,
+    PLUGIN_LIST_FILTER: '/admin/plugins/q/filter::filter',
+    PLUGIN_LIST_FILTER_OFFSET: '/admin/plugins/q/filter::filter,:offset',
+
+    QUERY: /^\/q\/([^,]+)(,(\d+))?$/,
+
+    /**
+     * Support vestigial params from GWT UI.
+     * @see Issue 7673.
+     * @type {!RegExp}
+     */
+    QUERY_LEGACY_SUFFIX: /^\/q\/.+,n,z$/,
+
+    // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
+    CHANGE_LEGACY: /^\/c\/(\d+)\/?(((-?\d+|edit)(\.\.(\d+|edit))?))?\/?$/,
+    CHANGE_NUMBER_LEGACY: /^\/(\d+)\/?/,
+
+    // Matches
+    // /c/<project>/+/<changeNum>/
+    //     [<basePatchNum|edit>..][<patchNum|edit>]/[path].
+    // TODO(kaspern): Migrate completely to project based URLs, with backwards
+    // compatibility for change-only.
+    // eslint-disable-next-line max-len
+    CHANGE_OR_DIFF: /^\/c\/(.+)\/\+\/(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?(\/(.+))?))?\/?$/,
+
+    // Matches /c/<project>/+/<changeNum>/edit/<path>,edit
+    // eslint-disable-next-line max-len
+    DIFF_EDIT: /^\/c\/(.+)\/\+\/(\d+)\/edit\/(.+),edit$/,
+
+    // Matches non-project-relative
+    // /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
+    DIFF_LEGACY: /^\/c\/(\d+)\/((-?\d+|edit)(\.\.(\d+|edit))?)\/(.+)/,
+
+    // Matches diff routes using @\d+ to specify a file name (whether or not
+    // the project name is included).
+    // eslint-disable-next-line max-len
+    DIFF_LEGACY_LINENUM: /^\/c\/((.+)\/\+\/)?(\d+)(\/?((-?\d+|edit)(\.\.(\d+|edit))?\/(.+))?)@[ab]?\d+$/,
+
+    SETTINGS: /^\/settings\/?/,
+    SETTINGS_LEGACY: /^\/settings\/VE\/(\S+)/,
+
+    // Matches /c/<changeNum>/ /<URL tail>
+    // Catches improperly encoded URLs (context: Issue 7100)
+    IMPROPERLY_ENCODED_PLUS: /^\/c\/(.+)\/\ \/(.+)$/,
+  };
+
+  /**
+   * Pattern to recognize and parse the diff line locations as they appear in
+   * the hash of diff URLs. In this format, a number on its own indicates that
+   * line number in the revision of the diff. A number prefixed by either an 'a'
+   * or a 'b' indicates that line number of the base of the diff.
+   * @type {RegExp}
+   */
+  const LINE_ADDRESS_PATTERN = /^([ab]?)(\d+)$/;
+
+  /**
+   * GWT UI would use @\d+ at the end of a path to indicate linenum.
+   */
+  const LEGACY_LINENUM_PATTERN = /@([ab]?\d+)$/;
+
+  const LEGACY_QUERY_SUFFIX_PATTERN = /,n,z$/;
+
   // Polymer makes `app` intrinsically defined on the window by virtue of the
   // custom element having the id "app", but it is made explicit here.
-  var app = document.querySelector('#app');
+  const app = document.querySelector('#app');
   if (!app) {
     console.log('No gr-app found (running tests)');
   }
 
-  var _reporting;
+  let _reporting;
   function getReporting() {
     if (!_reporting) {
       _reporting = document.createElement('gr-reporting');
@@ -33,226 +164,871 @@
     getReporting().pageLoaded();
   };
 
-  window.addEventListener('WebComponentsReady', function() {
+  window.addEventListener('WebComponentsReady', () => {
     getReporting().timeEnd('WebComponentsReady');
   });
 
-  function startRouter() {
-    var base = window.Gerrit.BaseUrlBehavior.getBaseUrl();
-    if (base) {
-      page.base(base);
-    }
+  Polymer({
+    is: 'gr-router',
 
-    var restAPI = document.createElement('gr-rest-api-interface');
-    var reporting = getReporting();
+    properties: {
+      _app: {
+        type: Object,
+        value: app,
+      },
+    },
 
-    // Middleware
-    page(function(ctx, next) {
-      document.body.scrollTop = 0;
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.PatchSetBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
 
-      // Fire asynchronously so that the URL is changed by the time the event
-      // is processed.
-      app.async(function() {
-        app.fire('location-change', {
-          hash: window.location.hash,
-          pathname: window.location.pathname,
-        });
-        reporting.locationChanged();
-      }, 1);
-      next();
-    });
+    start() {
+      if (!this._app) { return; }
+      this._startRouter();
+    },
 
-    function loadUser(ctx, next) {
-      restAPI.getLoggedIn().then(function() {
-        next();
-      });
-    }
+    _setParams(params) {
+      this._app.params = params;
+    },
 
-    // Routes.
-    page('/', loadUser, function(data) {
-      if (data.querystring.match(/^closeAfterLogin/)) {
-        // Close child window on redirect after login.
-        window.close();
+    _redirect(url) {
+      page.redirect(url);
+    },
+
+    _generateUrl(params) {
+      const base = this.getBaseUrl();
+      let url = '';
+
+      if (params.view === Gerrit.Nav.View.SEARCH) {
+        const operators = [];
+        if (params.owner) {
+          operators.push('owner:' + this.encodeURL(params.owner, false));
+        }
+        if (params.project) {
+          operators.push('project:' + this.encodeURL(params.project, false));
+        }
+        if (params.branch) {
+          operators.push('branch:' + this.encodeURL(params.branch, false));
+        }
+        if (params.topic) {
+          operators.push('topic:"' + this.encodeURL(params.topic, false) + '"');
+        }
+        if (params.hashtag) {
+          operators.push('hashtag:"' +
+              this.encodeURL(params.hashtag.toLowerCase(), false) + '"');
+        }
+        if (params.statuses) {
+          if (params.statuses.length === 1) {
+            operators.push(
+                'status:' + this.encodeURL(params.statuses[0], false));
+          } else if (params.statuses.length > 1) {
+            operators.push(
+                '(' +
+                params.statuses.map(s => `status:${this.encodeURL(s, false)}`)
+                    .join(' OR ') +
+                ')');
+          }
+        }
+        url = '/q/' + operators.join('+');
+      } else if (params.view === Gerrit.Nav.View.CHANGE) {
+        let range = this._getPatchRangeExpression(params);
+        if (range.length) { range = '/' + range; }
+        if (params.project) {
+          url = `/c/${params.project}/+/${params.changeNum}${range}`;
+        } else {
+          url = `/c/${params.changeNum}${range}`;
+        }
+      } else if (params.view === Gerrit.Nav.View.DASHBOARD) {
+        url = `/dashboard/${params.user || 'self'}`;
+      } else if (params.view === Gerrit.Nav.View.DIFF) {
+        let range = this._getPatchRangeExpression(params);
+        if (range.length) { range = '/' + range; }
+
+        let suffix = `${range}/${this.encodeURL(params.path, true)}`;
+        if (params.lineNum) {
+          suffix += '#';
+          if (params.leftSide) { suffix += 'b'; }
+          suffix += params.lineNum;
+        }
+
+        if (params.project) {
+          url = `/c/${params.project}/+/${params.changeNum}${suffix}`;
+        } else {
+          url = `/c/${params.changeNum}${suffix}`;
+        }
+        if (params.edit) {
+          url += ',edit';
+        }
+      } else if (params.view === Gerrit.Nav.View.SETTINGS) {
+        url = this._generateSettingsUrl(params);
+      } else {
+        throw new Error('Can\'t generate');
       }
-      // For backward compatibility with GWT links.
-      if (data.hash) {
-        // In certain login flows the server may redirect to a hash without
-        // a leading slash, which page.js doesn't handle correctly.
-        if (data.hash[0] !== '/') {
-          data.hash = '/' + data.hash;
-        }
-        var hash = data.hash;
-        var newUrl = base + hash;
-        if (hash.indexOf('/VE/') === 0) {
-          newUrl = base + '/settings' + data.hash;
-        }
-        page.redirect(newUrl);
-        return;
-      }
-      restAPI.getLoggedIn().then(function(loggedIn) {
-        if (loggedIn) {
-          page.redirect('/dashboard/self');
-        } else {
-          page.redirect('/q/status:open');
-        }
-      });
-    });
 
-    page('/dashboard/(.*)', loadUser, function(data) {
-      restAPI.getLoggedIn().then(function(loggedIn) {
-        if (loggedIn) {
-          data.params.view = 'gr-dashboard-view';
-          app.params = data.params;
-        } else {
-          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
-        }
-      });
-    });
+      return base + url;
+    },
 
-    page('/admin/(.*)', loadUser, function(data) {
-      restAPI.getLoggedIn().then(function(loggedIn) {
-        if (loggedIn) {
-          data.params.view = 'gr-admin-view';
-          app.params = data.params;
-        } else {
-          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
-        }
-      });
-    });
+    /**
+     * @param {!Object} params
+     * @return {string}
+     */
+    _generateSettingsUrl(params) {
+      return '/settings';
+    },
 
-    function queryHandler(data) {
-      data.params.view = 'gr-change-list-view';
-      app.params = data.params;
-    }
+    /**
+     * Given an object of parameters, potentially including a `patchNum` or a
+     * `basePatchNum` or both, return a string representation of that range. If
+     * no range is indicated in the params, the empty string is returned.
+     * @param {!Object} params
+     * @return {string}
+     */
+    _getPatchRangeExpression(params) {
+      let range = '';
+      if (params.patchNum) { range = '' + params.patchNum; }
+      if (params.basePatchNum) { range = params.basePatchNum + '..' + range; }
+      return range;
+    },
 
-    page('/q/:query,:offset', queryHandler);
-    page('/q/:query', queryHandler);
+    /**
+     * Given a set of params without a project, gets the project from the rest
+     * API project lookup and then sets the app params.
+     *
+     * @param {?Object} params
+     */
+    _normalizeLegacyRouteParams(params) {
+      if (!params.changeNum) { return Promise.resolve(); }
 
-    page(/^\/(\d+)\/?/, function(ctx) {
-      page.redirect('/c/' + encodeURIComponent(ctx.params[0]));
-    });
+      return this.$.restAPI.getFromProjectLookup(params.changeNum)
+          .then(project => {
+            // Do nothing if the lookup request failed. This avoids an infinite
+            // loop of project lookups.
+            if (!project) { return; }
 
-    function normalizePatchRangeParams(params) {
-      if (params.basePatchNum && !params.patchNum) {
+            params.project = project;
+            this._normalizePatchRangeParams(params);
+            this._redirect(this._generateUrl(params));
+          });
+    },
+
+    /**
+     * Normalizes the params object, and determines if the URL needs to be
+     * modified to fit the proper schema.
+     *
+     * @param {*} params
+     * @return {boolean} whether or not the URL needs to be upgraded.
+     */
+    _normalizePatchRangeParams(params) {
+      const hasBasePatchNum = params.basePatchNum !== null &&
+          params.basePatchNum !== undefined;
+      const hasPatchNum = params.patchNum !== null &&
+          params.patchNum !== undefined;
+      let needsRedirect = false;
+
+      // Diffing a patch against itself is invalid, so if the base and revision
+      // patches are equal clear the base.
+      // NOTE: while selecting numbered parents of a merge is not yet
+      // implemented, normalize parent base patches to be un-selected parents in
+      // the same way.
+      // TODO(issue 4760): Remove the isMergeParent check when PG supports
+      // diffing against numbered parents of a merge.
+      if (hasBasePatchNum &&
+          (this.patchNumEquals(params.basePatchNum, params.patchNum) ||
+              this.isMergeParent(params.basePatchNum))) {
+        needsRedirect = true;
+        params.basePatchNum = null;
+      } else if (hasBasePatchNum && !hasPatchNum) {
+        // Regexes set basePatchNum instead of patchNum when only one is
+        // specified. Redirect is not needed in this case.
         params.patchNum = params.basePatchNum;
         params.basePatchNum = null;
       }
-    }
+      // In GWTUI, edits are represented in URLs with either 0 or 'edit'.
+      // TODO(kaspern): Remove this normalization when GWT UI is gone.
+      if (this.patchNumEquals(params.basePatchNum, 0)) {
+        params.basePatchNum = this.EDIT_NAME;
+        needsRedirect = true;
+      }
+      if (this.patchNumEquals(params.patchNum, 0)) {
+        params.patchNum = this.EDIT_NAME;
+        needsRedirect = true;
+      }
+      return needsRedirect;
+    },
 
-    // Matches /c/<changeNum>/[<basePatchNum>..][<patchNum>][/].
-    page(/^\/c\/(\d+)\/?(((\d+)(\.\.(\d+))?))?\/?$/, function(ctx) {
+    /**
+     * Redirect the user to login using the given return-URL for redirection
+     * after authentication success.
+     * @param {string} returnUrl
+     */
+    _redirectToLogin(returnUrl) {
+      const basePath = this.getBaseUrl() || '';
+      page(
+          '/login/' + encodeURIComponent(returnUrl.substring(basePath.length)));
+    },
+
+    /**
+     * Hashes parsed by page.js exclude "inner" hashes, so a URL like "/a#b#c"
+     * is parsed to have a hash of "b" rather than "b#c". Instead, this method
+     * parses hashes correctly. Will return an empty string if there is no hash.
+     * @param {!string} canonicalPath
+     * @return {!string} Everything after the first '#' ("a#b#c" -> "b#c").
+     */
+    _getHashFromCanonicalPath(canonicalPath) {
+      return canonicalPath.split('#').slice(1).join('#');
+    },
+
+    _parseLineAddress(hash) {
+      const match = hash.match(LINE_ADDRESS_PATTERN);
+      if (!match) { return null; }
+      return {
+        leftSide: !!match[1],
+        lineNum: parseInt(match[2], 10),
+      };
+    },
+
+    /**
+     * Check to see if the user is logged in and return a promise that only
+     * resolves if the user is logged in. If the user us not logged in, the
+     * promise is rejected and the page is redirected to the login flow.
+     * @param {!Object} data The parsed route data.
+     * @return {!Promise<!Object>} A promise yielding the original route data
+     *     (if it resolves).
+     */
+    _redirectIfNotLoggedIn(data) {
+      return this.$.restAPI.getLoggedIn().then(loggedIn => {
+        if (loggedIn) {
+          return Promise.resolve();
+        } else {
+          this._redirectToLogin(data.canonicalPath);
+          return Promise.reject();
+        }
+      });
+    },
+
+    /**  Page.js middleware that warms the REST API's logged-in cache line. */
+    _loadUserMiddleware(ctx, next) {
+      this.$.restAPI.getLoggedIn().then(() => { next(); });
+    },
+
+    /**
+     * Map a route to a method on the router.
+     *
+     * @param {!string|!RegExp} pattern The page.js pattern for the route.
+     * @param {!string} handlerName The method name for the handler. If the
+     *     route is matched, the handler will be executed with `this` referring
+     *     to the component. Its return value will be discarded so that it does
+     *     not interfere with page.js.
+     * @param  {?boolean=} opt_authRedirect If true, then auth is checked before
+     *     executing the handler. If the user is not logged in, it will redirect
+     *     to the login flow and the handler will not be executed. The login
+     *     redirect specifies the matched URL to be used after successfull auth.
+     */
+    _mapRoute(pattern, handlerName, opt_authRedirect) {
+      if (!this[handlerName]) {
+        console.error('Attempted to map route to unknown method: ',
+            handlerName);
+        return;
+      }
+      page(pattern, this._loadUserMiddleware.bind(this), data => {
+        const promise = opt_authRedirect ?
+          this._redirectIfNotLoggedIn(data) : Promise.resolve();
+        promise.then(() => { this[handlerName](data); });
+      });
+    },
+
+    _startRouter() {
+      const base = this.getBaseUrl();
+      if (base) {
+        page.base(base);
+      }
+
+      const reporting = getReporting();
+
+      Gerrit.Nav.setup(url => { page.show(url); },
+          this._generateUrl.bind(this));
+
+      // Middleware
+      page((ctx, next) => {
+        document.body.scrollTop = 0;
+
+        // Fire asynchronously so that the URL is changed by the time the event
+        // is processed.
+        this.async(() => {
+          this.fire('location-change', {
+            hash: window.location.hash,
+            pathname: window.location.pathname,
+          });
+          reporting.locationChanged();
+        }, 1);
+        next();
+      });
+
+      this._mapRoute(RoutePattern.ROOT, '_handleRootRoute');
+
+      this._mapRoute(RoutePattern.DASHBOARD, '_handleDashboardRoute');
+
+      this._mapRoute(RoutePattern.GROUP_INFO, '_handleGroupInfoRoute', true);
+
+      this._mapRoute(RoutePattern.GROUP_AUDIT_LOG, '_handleGroupAuditLogRoute',
+          true);
+
+      this._mapRoute(RoutePattern.GROUP_MEMBERS, '_handleGroupMembersRoute',
+          true);
+
+      this._mapRoute(RoutePattern.GROUP_LIST_OFFSET,
+          '_handleGroupListOffsetRoute', true);
+
+      this._mapRoute(RoutePattern.GROUP_LIST_FILTER_OFFSET,
+          '_handleGroupListFilterOffsetRoute', true);
+
+      this._mapRoute(RoutePattern.GROUP_LIST_FILTER,
+          '_handleGroupListFilterRoute', true);
+
+      this._mapRoute(RoutePattern.GROUP, '_handleGroupRoute', true);
+
+      this._mapRoute(RoutePattern.PROJECT_COMMANDS,
+          '_handleProjectCommandsRoute', true);
+
+      this._mapRoute(RoutePattern.PROJECT_ACCESS,
+          '_handleProjectAccessRoute');
+
+      this._mapRoute(RoutePattern.BRANCH_LIST_OFFSET,
+          '_handleBranchListOffsetRoute');
+
+      this._mapRoute(RoutePattern.BRANCH_LIST_FILTER_OFFSET,
+          '_handleBranchListFilterOffsetRoute');
+
+      this._mapRoute(RoutePattern.BRANCH_LIST_FILTER,
+          '_handleBranchListFilterRoute');
+
+      this._mapRoute(RoutePattern.TAG_LIST_OFFSET,
+          '_handleTagListOffsetRoute');
+
+      this._mapRoute(RoutePattern.TAG_LIST_FILTER_OFFSET,
+          '_handleTagListFilterOffsetRoute');
+
+      this._mapRoute(RoutePattern.TAG_LIST_FILTER,
+          '_handleTagListFilterRoute');
+
+      this._mapRoute(RoutePattern.LEGACY_CREATE_GROUP,
+          '_handleCreateGroupRoute', true);
+
+      this._mapRoute(RoutePattern.LEGACY_CREATE_PROJECT,
+          '_handleCreateProjectRoute', true);
+
+      this._mapRoute(RoutePattern.PROJECT_LIST_OFFSET,
+          '_handleProjectListOffsetRoute');
+
+      this._mapRoute(RoutePattern.PROJECT_LIST_FILTER_OFFSET,
+          '_handleProjectListFilterOffsetRoute');
+
+      this._mapRoute(RoutePattern.PROJECT_LIST_FILTER,
+          '_handleProjectListFilterRoute');
+
+      this._mapRoute(RoutePattern.PROJECT, '_handleProjectRoute');
+
+      this._mapRoute(RoutePattern.PLUGINS, '_handlePassThroughRoute');
+
+      this._mapRoute(RoutePattern.PLUGIN_LIST_OFFSET,
+          '_handlePluginListOffsetRoute', true);
+
+      this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER_OFFSET,
+          '_handlePluginListFilterOffsetRoute', true);
+
+      this._mapRoute(RoutePattern.PLUGIN_LIST_FILTER,
+          '_handlePluginListFilterRoute', true);
+
+      this._mapRoute(RoutePattern.PLUGIN_LIST, '_handlePluginListRoute', true);
+
+      this._mapRoute(RoutePattern.QUERY_LEGACY_SUFFIX,
+          '_handleQueryLegacySuffixRoute');
+
+      this._mapRoute(RoutePattern.ADMIN_PLACEHOLDER,
+          '_handleAdminPlaceholderRoute', true);
+
+      this._mapRoute(RoutePattern.QUERY, '_handleQueryRoute');
+
+      this._mapRoute(RoutePattern.DIFF_LEGACY_LINENUM, '_handleLegacyLinenum');
+
+      this._mapRoute(RoutePattern.CHANGE_NUMBER_LEGACY,
+          '_handleChangeNumberLegacyRoute');
+
+      this._mapRoute(RoutePattern.DIFF_EDIT, '_handleDiffEditRoute', true);
+
+      this._mapRoute(RoutePattern.CHANGE_OR_DIFF, '_handleChangeOrDiffRoute');
+
+      this._mapRoute(RoutePattern.CHANGE_LEGACY, '_handleChangeLegacyRoute');
+
+      this._mapRoute(RoutePattern.DIFF_LEGACY, '_handleDiffLegacyRoute');
+
+      this._mapRoute(RoutePattern.AGREEMENTS, '_handleAgreementsRoute', true);
+
+      this._mapRoute(RoutePattern.SETTINGS_LEGACY,
+          '_handleSettingsLegacyRoute', true);
+
+      this._mapRoute(RoutePattern.SETTINGS, '_handleSettingsRoute', true);
+
+      this._mapRoute(RoutePattern.REGISTER, '_handleRegisterRoute');
+
+      this._mapRoute(RoutePattern.LOG_IN_OR_OUT, '_handlePassThroughRoute');
+
+      this._mapRoute(RoutePattern.IMPROPERLY_ENCODED_PLUS,
+          '_handleImproperlyEncodedPlusRoute');
+
+      // Note: this route should appear last so it only catches URLs unmatched
+      // by other patterns.
+      this._mapRoute(RoutePattern.DEFAULT, '_handleDefaultRoute');
+
+      page.start();
+    },
+
+    /**
+     * @param {!Object} data
+     * @return {Promise|null} if handling the route involves asynchrony, then a
+     *     promise is returned. Otherwise, synchronous handling returns null.
+     */
+    _handleRootRoute(data) {
+      if (data.querystring.match(/^closeAfterLogin/)) {
+        // Close child window on redirect after login.
+        window.close();
+        return null;
+      }
+      let hash = this._getHashFromCanonicalPath(data.canonicalPath);
+      // For backward compatibility with GWT links.
+      if (hash) {
+        // In certain login flows the server may redirect to a hash without
+        // a leading slash, which page.js doesn't handle correctly.
+        if (hash[0] !== '/') {
+          hash = '/' + hash;
+        }
+        if (hash.includes('/ /') && data.canonicalPath.includes('/+/')) {
+          // Path decodes all '+' to ' ' -- this breaks project-based URLs.
+          // See Issue 6888.
+          hash = hash.replace('/ /', '/+/');
+        }
+        const base = this.getBaseUrl();
+        let newUrl = base + hash;
+        if (hash.startsWith('/VE/')) {
+          newUrl = base + '/settings' + hash;
+        }
+        this._redirect(newUrl);
+        return null;
+      }
+      return this.$.restAPI.getLoggedIn().then(loggedIn => {
+        if (loggedIn) {
+          this._redirect('/dashboard/self');
+        } else {
+          this._redirect('/q/status:open');
+        }
+      });
+    },
+
+    _handleDashboardRoute(data) {
+      if (!data.params[0]) {
+        this._redirect('/dashboard/self');
+        return;
+      }
+
+      return this.$.restAPI.getLoggedIn().then(loggedIn => {
+        if (!loggedIn) {
+          if (data.params[0].toLowerCase() === 'self') {
+            this._redirectToLogin(data.canonicalPath);
+          } else {
+            // TODO: encode user or use _generateUrl.
+            this._redirect('/q/owner:' + data.params[0]);
+          }
+        } else {
+          this._setParams({
+            view: Gerrit.Nav.View.DASHBOARD,
+            user: data.params[0],
+          });
+        }
+      });
+    },
+
+    _handleGroupInfoRoute(data) {
+      this._redirect('/admin/groups/' + encodeURIComponent(data.params[0]));
+    },
+
+    _handleGroupAuditLogRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-group-audit-log',
+        detailType: 'audit-log',
+        groupId: data.params[0],
+      });
+    },
+
+    _handleGroupMembersRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-group-members',
+        detailType: 'members',
+        groupId: data.params[0],
+      });
+    },
+
+    _handleGroupListOffsetRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-admin-group-list',
+        offset: data.params[1] || 0,
+        filter: null,
+        openCreateModal: data.hash === 'create',
+      });
+    },
+
+    _handleGroupListFilterOffsetRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-admin-group-list',
+        offset: data.params.offset,
+        filter: data.params.filter,
+      });
+    },
+
+    _handleGroupListFilterRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-admin-group-list',
+        filter: data.params.filter || null,
+      });
+    },
+
+    _handleGroupRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-group',
+        groupId: data.params[0],
+      });
+    },
+
+    _handleProjectCommandsRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-commands',
+        detailType: 'commands',
+        project: data.params[0],
+      });
+    },
+
+    _handleProjectAccessRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-access',
+        detailType: 'access',
+        project: data.params[0],
+      });
+    },
+
+    _handleBranchListOffsetRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-detail-list',
+        detailType: 'branches',
+        project: data.params[0],
+        offset: data.params[2] || 0,
+        filter: null,
+      });
+    },
+
+    _handleBranchListFilterOffsetRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-detail-list',
+        detailType: 'branches',
+        project: data.params.project,
+        offset: data.params.offset,
+        filter: data.params.filter,
+      });
+    },
+
+    _handleBranchListFilterRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-detail-list',
+        detailType: 'branches',
+        project: data.params.project,
+        filter: data.params.filter || null,
+      });
+    },
+
+    _handleTagListOffsetRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-detail-list',
+        detailType: 'tags',
+        project: data.params[0],
+        offset: data.params[2] || 0,
+        filter: null,
+      });
+    },
+
+    _handleTagListFilterOffsetRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-detail-list',
+        detailType: 'tags',
+        project: data.params.project,
+        offset: data.params.offset,
+        filter: data.params.filter,
+      });
+    },
+
+    _handleTagListFilterRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-detail-list',
+        detailType: 'tags',
+        project: data.params.project,
+        filter: data.params.filter || null,
+      });
+    },
+
+    _handleProjectListOffsetRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-list',
+        offset: data.params[1] || 0,
+        filter: null,
+        openCreateModal: data.hash === 'create',
+      });
+    },
+
+    _handleProjectListFilterOffsetRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-list',
+        offset: data.params.offset,
+        filter: data.params.filter,
+      });
+    },
+
+    _handleProjectListFilterRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-project-list',
+        filter: data.params.filter || null,
+      });
+    },
+
+    _handleCreateProjectRoute(data) {
+      // Redirects the legacy route to the new route, which displays the project
+      // list with a hash 'create'.
+      this._redirect('/admin/projects#create');
+    },
+
+    _handleCreateGroupRoute(data) {
+      // Redirects the legacy route to the new route, which displays the group
+      // list with a hash 'create'.
+      this._redirect('/admin/groups#create');
+    },
+
+    _handleProjectRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        project: data.params[0],
+        adminView: 'gr-project',
+      });
+    },
+
+    _handlePluginListOffsetRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-plugin-list',
+        offset: data.params[1] || 0,
+        filter: null,
+      });
+    },
+
+    _handlePluginListFilterOffsetRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-plugin-list',
+        offset: data.params.offset,
+        filter: data.params.filter,
+      });
+    },
+
+    _handlePluginListFilterRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-plugin-list',
+        filter: data.params.filter || null,
+      });
+    },
+
+    _handlePluginListRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.ADMIN,
+        adminView: 'gr-plugin-list',
+      });
+    },
+
+    _handleAdminPlaceholderRoute(data) {
+      data.params.view = Gerrit.Nav.View.ADMIN;
+      data.params.placeholder = true;
+      this._setParams(data.params);
+    },
+
+    _handleQueryRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.SEARCH,
+        query: data.params[0],
+        offset: data.params[2],
+      });
+    },
+
+    _handleQueryLegacySuffixRoute(ctx) {
+      this._redirect(ctx.path.replace(LEGACY_QUERY_SUFFIX_PATTERN, ''));
+    },
+
+    _handleChangeNumberLegacyRoute(ctx) {
+      this._redirect('/c/' + encodeURIComponent(ctx.params[0]));
+    },
+
+    _handleChangeOrDiffRoute(ctx) {
+      const isDiffView = ctx.params[8];
+
       // Parameter order is based on the regex group number matched.
-      var params = {
+      const params = {
+        project: ctx.params[0],
+        changeNum: ctx.params[1],
+        basePatchNum: ctx.params[4],
+        patchNum: ctx.params[6],
+        path: ctx.params[8],
+        view: isDiffView ? Gerrit.Nav.View.DIFF : Gerrit.Nav.View.CHANGE,
+      };
+
+      if (isDiffView) {
+        const address = this._parseLineAddress(ctx.hash);
+        if (address) {
+          params.leftSide = address.leftSide;
+          params.lineNum = address.lineNum;
+        }
+      }
+
+      this._redirectOrNavigate(params);
+    },
+
+    _handleChangeLegacyRoute(ctx) {
+      // Parameter order is based on the regex group number matched.
+      const params = {
         changeNum: ctx.params[0],
         basePatchNum: ctx.params[3],
         patchNum: ctx.params[5],
-        view: 'gr-change-view',
+        view: Gerrit.Nav.View.CHANGE,
       };
 
-      // Don't allow diffing the same patch number against itself.
-      if (params.basePatchNum != null &&
-          params.basePatchNum === params.patchNum) {
-        page.redirect('/c/' +
-            encodeURIComponent(params.changeNum) +
-            '/' +
-            encodeURIComponent(params.patchNum) +
-            '/');
-        return;
-      }
-      normalizePatchRangeParams(params);
-      app.params = params;
-    });
+      this._normalizeLegacyRouteParams(params);
+    },
 
-    // Matches /c/<changeNum>/[<basePatchNum>..]<patchNum>/<path>.
-    page(/^\/c\/(\d+)\/((\d+)(\.\.(\d+))?)\/(.+)/, function(ctx) {
+    _handleLegacyLinenum(ctx) {
+      this._redirect(ctx.path.replace(LEGACY_LINENUM_PATTERN, '#$1'));
+    },
+
+    _handleDiffLegacyRoute(ctx) {
       // Parameter order is based on the regex group number matched.
-      var params = {
+      const params = {
         changeNum: ctx.params[0],
         basePatchNum: ctx.params[2],
         patchNum: ctx.params[4],
         path: ctx.params[5],
-        view: 'gr-diff-view',
+        view: Gerrit.Nav.View.DIFF,
       };
-      // Don't allow diffing the same patch number against itself.
-      if (params.basePatchNum === params.patchNum) {
-        // TODO(kaspern): Utilize gr-url-encoding-behavior.html when the router
-        // is replaced with a Polymer counterpart.
-        // @see Issue 4255 regarding double-encoding.
-        var path = encodeURIComponent(encodeURIComponent(params.path));
-        // @see Issue 4577 regarding more readable URLs.
-        path = path.replace(/%252F/g, '/');
-        path = path.replace(/%2520/g, '+');
 
-        page.redirect('/c/' +
-            encodeURIComponent(params.changeNum) +
-            '/' +
-            encodeURIComponent(params.patchNum) +
-            '/' +
-            path);
-        return;
+      const address = this._parseLineAddress(ctx.hash);
+      if (address) {
+        params.leftSide = address.leftSide;
+        params.lineNum = address.lineNum;
       }
 
-      // Check if path has an '@' which indicates it was using GWT style line
-      // numbers. Even if the filename had an '@' in it, it would have already
-      // been URI encoded. Redirect to hash version of path.
-      if (ctx.path.indexOf('@') !== -1) {
-        page.redirect(ctx.path.replace('@', '#'));
-        return;
+      this._normalizeLegacyRouteParams(params);
+    },
+
+    _handleDiffEditRoute(ctx) {
+      // Parameter order is based on the regex group number matched.
+      this._redirectOrNavigate({
+        project: ctx.params[0],
+        changeNum: ctx.params[1],
+        path: ctx.params[2],
+        view: Gerrit.Nav.View.EDIT,
+      });
+    },
+
+    /**
+     * Normalize the patch range params for a the change or diff view and
+     * redirect if URL upgrade is needed.
+     */
+    _redirectOrNavigate(params) {
+      const needsRedirect = this._normalizePatchRangeParams(params);
+      if (needsRedirect) {
+        this._redirect(this._generateUrl(params));
+      } else {
+        this._setParams(params);
+        this.$.restAPI.setInProjectLookup(params.changeNum,
+            params.project);
       }
+    },
 
-      normalizePatchRangeParams(params);
-      app.params = params;
-    });
+    _handleAgreementsRoute(data) {
+      data.params.view = Gerrit.Nav.View.AGREEMENTS;
+      this._setParams(data.params);
+    },
 
-    page(/^\/settings\/(agreements|new-agreement)/, loadUser, function(data) {
-      restAPI.getLoggedIn().then(function(loggedIn) {
-        if (loggedIn) {
-          data.params.view = 'gr-cla-view';
-          app.params = data.params;
-        } else {
-          page.redirect('/login/' + encodeURIComponent(data.canonicalPath));
-        }
+    _handleSettingsLegacyRoute(data) {
+      this._setParams({
+        view: Gerrit.Nav.View.SETTINGS,
+        emailToken: data.params[0],
       });
-    });
+    },
 
-    page(/^\/settings\/VE\/(\S+)/, function(data) {
-      restAPI.getLoggedIn().then(function(loggedIn) {
-        if (loggedIn) {
-          app.params = {
-            view: 'gr-settings-view',
-            emailToken: data.params[0],
-          };
-        } else {
-          page.show('/login/' + encodeURIComponent(data.canonicalPath));
-        }
-      });
-    });
+    _handleSettingsRoute(data) {
+      this._setParams({view: Gerrit.Nav.View.SETTINGS});
+    },
 
-    page(/^\/settings\/?/, function(data) {
-      restAPI.getLoggedIn().then(function(loggedIn) {
-        if (loggedIn) {
-          app.params = {view: 'gr-settings-view'};
-        } else {
-          page.show('/login/' + encodeURIComponent(data.canonicalPath));
-        }
-      });
-    });
+    _handleRegisterRoute(ctx) {
+      this._setParams({justRegistered: true});
+      let path = ctx.params[0] || '/';
 
-    page(/^\/register(\/.*)?/, function(ctx) {
-      app.params = {justRegistered: true};
-      var path = ctx.params[0] || '/';
+      // Prevent redirect looping.
+      if (path.startsWith('/register')) { path = '/'; }
+
       if (path[0] !== '/') { return; }
-      page.show(base + path);
-    });
+      this._redirect(this.getBaseUrl() + path);
+    },
 
-    page.start();
-  }
+    /**
+     * Handler for routes that should pass through the router and not be caught
+     * by the catchall _handleDefaultRoute handler.
+     */
+    _handlePassThroughRoute() {
+      location.reload();
+    },
 
-  Polymer({
-    is: 'gr-router',
-    start: function() {
-      if (!app) { return; }
-      startRouter();
+
+    /**
+     * URL may sometimes have /+/ encoded to / /.
+     * Context: Issue 6888, Issue 7100
+     */
+    _handleImproperlyEncodedPlusRoute(ctx) {
+      let hash = this._getHashFromCanonicalPath(ctx.canonicalPath);
+      if (hash.length) { hash = '#' + hash; }
+      this._redirect(`/c/${ctx.params[0]}/+/${ctx.params[1]}${hash}`);
+    },
+
+    /**
+     * Catchall route for when no other route is matched.
+     */
+    _handleDefaultRoute() {
+      // Note: the app's 404 display is tightly-coupled with catching 404
+      // network responses, so we simulate a 404 response status to display it.
+      // TODO: Decouple the gr-app error view from network responses.
+      this._app.dispatchEvent(new CustomEvent('page-error',
+          {detail: {response: {status: 404}}}));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
new file mode 100644
index 0000000..8186fde
--- /dev/null
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router_test.html
@@ -0,0 +1,1203 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-router</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-router.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-router></gr-router>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-router tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('_getHashFromCanonicalPath', () => {
+      let url = '/foo/bar';
+      let hash = element._getHashFromCanonicalPath(url);
+      assert.equal(hash, '');
+
+      url = '';
+      hash = element._getHashFromCanonicalPath(url);
+      assert.equal(hash, '');
+
+      url = '/foo#bar';
+      hash = element._getHashFromCanonicalPath(url);
+      assert.equal(hash, 'bar');
+
+      url = '/foo#bar#baz';
+      hash = element._getHashFromCanonicalPath(url);
+      assert.equal(hash, 'bar#baz');
+
+      url = '#foo#bar#baz';
+      hash = element._getHashFromCanonicalPath(url);
+      assert.equal(hash, 'foo#bar#baz');
+    });
+
+    suite('_parseLineAddress', () => {
+      test('returns null for empty and invalid hashes', () => {
+        let actual = element._parseLineAddress('');
+        assert.isNull(actual);
+
+        actual = element._parseLineAddress('foobar');
+        assert.isNull(actual);
+
+        actual = element._parseLineAddress('foo123');
+        assert.isNull(actual);
+
+        actual = element._parseLineAddress('123bar');
+        assert.isNull(actual);
+      });
+
+      test('parses correctly', () => {
+        let actual = element._parseLineAddress('1234');
+        assert.isOk(actual);
+        assert.equal(actual.lineNum, 1234);
+        assert.isFalse(actual.leftSide);
+
+        actual = element._parseLineAddress('a4');
+        assert.isOk(actual);
+        assert.equal(actual.lineNum, 4);
+        assert.isTrue(actual.leftSide);
+
+        actual = element._parseLineAddress('b77');
+        assert.isOk(actual);
+        assert.equal(actual.lineNum, 77);
+        assert.isTrue(actual.leftSide);
+      });
+    });
+
+    test('_startRouter requires auth for the right handlers', () => {
+      // This test encodes the lists of route handler methods that gr-router
+      // automatically checks for authentication before triggering.
+
+      const requiresAuth = {};
+      const doesNotRequireAuth = {};
+      sandbox.stub(Gerrit.Nav, 'setup');
+      sandbox.stub(window.page, 'start');
+      sandbox.stub(window.page, 'base');
+      sandbox.stub(window, 'page');
+      sandbox.stub(element, '_mapRoute', (pattern, methodName, usesAuth) => {
+        if (usesAuth) {
+          requiresAuth[methodName] = true;
+        } else {
+          doesNotRequireAuth[methodName] = true;
+        }
+      });
+      element._startRouter();
+
+      const actualRequiresAuth = Object.keys(requiresAuth);
+      actualRequiresAuth.sort();
+      const actualDoesNotRequireAuth = Object.keys(doesNotRequireAuth);
+      actualDoesNotRequireAuth.sort();
+
+      const shouldRequireAutoAuth = [
+        '_handleAdminPlaceholderRoute',
+        '_handleAgreementsRoute',
+        '_handleCreateGroupRoute',
+        '_handleCreateProjectRoute',
+        '_handleDiffEditRoute',
+        '_handleGroupAuditLogRoute',
+        '_handleGroupInfoRoute',
+        '_handleGroupListFilterOffsetRoute',
+        '_handleGroupListFilterRoute',
+        '_handleGroupListOffsetRoute',
+        '_handleGroupMembersRoute',
+        '_handleGroupRoute',
+        '_handlePluginListFilterOffsetRoute',
+        '_handlePluginListFilterRoute',
+        '_handlePluginListOffsetRoute',
+        '_handlePluginListRoute',
+        '_handleProjectCommandsRoute',
+        '_handleSettingsLegacyRoute',
+        '_handleSettingsRoute',
+      ];
+      assert.deepEqual(actualRequiresAuth, shouldRequireAutoAuth);
+
+      const unauthenticatedHandlers = [
+        '_handleBranchListFilterOffsetRoute',
+        '_handleBranchListFilterRoute',
+        '_handleBranchListOffsetRoute',
+        '_handleChangeNumberLegacyRoute',
+        '_handleChangeOrDiffRoute',
+        '_handleDefaultRoute',
+        '_handleChangeLegacyRoute',
+        '_handleDiffLegacyRoute',
+        '_handleLegacyLinenum',
+        '_handleImproperlyEncodedPlusRoute',
+        '_handlePassThroughRoute',
+        '_handleProjectAccessRoute',
+        '_handleProjectListFilterOffsetRoute',
+        '_handleProjectListFilterRoute',
+        '_handleProjectListOffsetRoute',
+        '_handleProjectRoute',
+        '_handleQueryLegacySuffixRoute',
+        '_handleQueryRoute',
+        '_handleRegisterRoute',
+        '_handleTagListFilterOffsetRoute',
+        '_handleTagListFilterRoute',
+        '_handleTagListOffsetRoute',
+      ];
+
+      // Handler names that check authentication themselves, and thus don't need
+      // it performed for them.
+      const selfAuthenticatingHandlers = [
+        '_handleDashboardRoute',
+        '_handleRootRoute',
+      ];
+
+      const shouldNotRequireAuth = unauthenticatedHandlers
+          .concat(selfAuthenticatingHandlers);
+      shouldNotRequireAuth.sort();
+
+      assert.deepEqual(actualDoesNotRequireAuth, shouldNotRequireAuth);
+    });
+
+    test('_redirectIfNotLoggedIn while logged in', () => {
+      sandbox.stub(element.$.restAPI, 'getLoggedIn')
+          .returns(Promise.resolve(true));
+      const data = {canonicalPath: ''};
+      const redirectStub = sandbox.stub(element, '_redirectToLogin');
+      return element._redirectIfNotLoggedIn(data).then(() => {
+        assert.isFalse(redirectStub.called);
+      });
+    });
+
+    test('_redirectIfNotLoggedIn while logged out', () => {
+      sandbox.stub(element.$.restAPI, 'getLoggedIn')
+          .returns(Promise.resolve(false));
+      const redirectStub = sandbox.stub(element, '_redirectToLogin');
+      const data = {canonicalPath: ''};
+      return new Promise(resolve => {
+        element._redirectIfNotLoggedIn(data)
+            .then(() => {
+              assert.isTrue(false, 'Should never execute');
+            })
+            .catch(() => {
+              assert.isTrue(redirectStub.calledOnce);
+              resolve();
+            });
+      });
+    });
+
+    suite('generateUrl', () => {
+      test('search', () => {
+        let params = {
+          view: Gerrit.Nav.View.SEARCH,
+          owner: 'a%b',
+          project: 'c%d',
+          branch: 'e%f',
+          topic: 'g%h',
+          statuses: ['op%en'],
+        };
+        assert.equal(element._generateUrl(params),
+            '/q/owner:a%2525b+project:c%2525d+branch:e%2525f+' +
+            'topic:"g%2525h"+status:op%2525en');
+
+        params = {
+          view: Gerrit.Nav.View.SEARCH,
+          statuses: ['a', 'b', 'c'],
+        };
+        assert.equal(element._generateUrl(params),
+            '/q/(status:a OR status:b OR status:c)');
+      });
+
+      test('change', () => {
+        const params = {
+          view: Gerrit.Nav.View.CHANGE,
+          changeNum: '1234',
+          project: 'test',
+        };
+        assert.equal(element._generateUrl(params), '/c/test/+/1234');
+
+        params.patchNum = 10;
+        assert.equal(element._generateUrl(params), '/c/test/+/1234/10');
+
+        params.basePatchNum = 5;
+        assert.equal(element._generateUrl(params), '/c/test/+/1234/5..10');
+      });
+
+      test('diff', () => {
+        const params = {
+          view: Gerrit.Nav.View.DIFF,
+          changeNum: '42',
+          path: 'x+y/path.cpp',
+          patchNum: 12,
+        };
+        assert.equal(element._generateUrl(params),
+            '/c/42/12/x%252By/path.cpp');
+
+        params.project = 'test';
+        assert.equal(element._generateUrl(params),
+            '/c/test/+/42/12/x%252By/path.cpp');
+
+        params.basePatchNum = 6;
+        assert.equal(element._generateUrl(params),
+            '/c/test/+/42/6..12/x%252By/path.cpp');
+
+        params.path = 'foo bar/my+file.txt%';
+        params.patchNum = 2;
+        delete params.basePatchNum;
+        assert.equal(element._generateUrl(params),
+            '/c/test/+/42/2/foo+bar/my%252Bfile.txt%2525');
+
+        params.path = 'file.cpp';
+        params.lineNum = 123;
+        assert.equal(element._generateUrl(params),
+            '/c/test/+/42/2/file.cpp#123');
+
+        params.leftSide = true;
+        assert.equal(element._generateUrl(params),
+            '/c/test/+/42/2/file.cpp#b123');
+      });
+
+      test('_getPatchRangeExpression', () => {
+        const params = {};
+        let actual = element._getPatchRangeExpression(params);
+        assert.equal(actual, '');
+
+        params.patchNum = 4;
+        actual = element._getPatchRangeExpression(params);
+        assert.equal(actual, '4');
+
+        params.basePatchNum = 2;
+        actual = element._getPatchRangeExpression(params);
+        assert.equal(actual, '2..4');
+
+        delete params.patchNum;
+        actual = element._getPatchRangeExpression(params);
+        assert.equal(actual, '2..');
+      });
+    });
+
+    suite('param normalization', () => {
+      let projectLookupStub;
+
+      setup(() => {
+        projectLookupStub = sandbox
+            .stub(element.$.restAPI, 'getFromProjectLookup');
+        sandbox.stub(element, '_generateUrl');
+      });
+
+      suite('_normalizeLegacyRouteParams', () => {
+        let rangeStub;
+        let redirectStub;
+
+        setup(() => {
+          rangeStub = sandbox.stub(element, '_normalizePatchRangeParams')
+              .returns(Promise.resolve());
+          redirectStub = sandbox.stub(element, '_redirect');
+        });
+
+        test('w/o changeNum', () => {
+          projectLookupStub.returns(Promise.resolve('foo/bar'));
+          const params = {};
+          return element._normalizeLegacyRouteParams(params).then(() => {
+            assert.isFalse(projectLookupStub.called);
+            assert.isFalse(rangeStub.called);
+            assert.isNotOk(params.project);
+            assert.isFalse(redirectStub.called);
+          });
+        });
+
+        test('w/ changeNum', () => {
+          projectLookupStub.returns(Promise.resolve('foo/bar'));
+          const params = {changeNum: 1234};
+          return element._normalizeLegacyRouteParams(params).then(() => {
+            assert.isTrue(projectLookupStub.called);
+            assert.isTrue(rangeStub.called);
+            assert.equal(params.project, 'foo/bar');
+            assert.isTrue(redirectStub.calledOnce);
+          });
+        });
+
+        test('halts on project lookup failure', () => {
+          projectLookupStub.returns(Promise.resolve(undefined));
+
+          const params = {changeNum: 1234};
+          return element._normalizeLegacyRouteParams(params).then(() => {
+            assert.isTrue(projectLookupStub.called);
+            assert.isFalse(rangeStub.called);
+            assert.isUndefined(params.project);
+            assert.isFalse(redirectStub.called);
+          });
+        });
+      });
+
+      suite('_normalizePatchRangeParams', () => {
+        test('range n..n normalizes to n', () => {
+          const params = {basePatchNum: 4, patchNum: 4};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.isNotOk(params.basePatchNum);
+          assert.equal(params.patchNum, 4);
+        });
+
+        test('range n.. normalizes to n', () => {
+          const params = {basePatchNum: 4};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isFalse(needsRedirect);
+          assert.isNotOk(params.basePatchNum);
+          assert.equal(params.patchNum, 4);
+        });
+
+        test('range 0..n normalizes to edit..n', () => {
+          const params = {basePatchNum: 0, patchNum: 4};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.equal(params.basePatchNum, 'edit');
+          assert.equal(params.patchNum, 4);
+        });
+
+        test('range n..0 normalizes to n..edit', () => {
+          const params = {basePatchNum: 4, patchNum: 0};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.equal(params.basePatchNum, 4);
+          assert.equal(params.patchNum, 'edit');
+        });
+
+        test('range 0..0 normalizes to edit', () => {
+          const params = {basePatchNum: 0, patchNum: 0};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.isNotOk(params.basePatchNum);
+          assert.equal(params.patchNum, 'edit');
+        });
+
+        // TODO(issue 4760): Remove when PG supports diffing against numbered
+        // parents of a merge.
+        test('range -n..m normalizes to m', () => {
+          const params = {basePatchNum: -2, patchNum: 4};
+          const needsRedirect = element._normalizePatchRangeParams(params);
+          assert.isTrue(needsRedirect);
+          assert.isNotOk(params.basePatchNum);
+          assert.equal(params.patchNum, 4);
+        });
+      });
+    });
+
+    suite('route handlers', () => {
+      let redirectStub;
+      let setParamsStub;
+
+      // Simple route handlers are direct mappings from parsed route data to a
+      // new set of app.params. This test helper asserts that passing `data`
+      // into `methodName` results in setting the params specified in `params`.
+      function assertDataToParams(data, methodName, params) {
+        element[methodName](data);
+        assert.deepEqual(setParamsStub.lastCall.args[0], params);
+      }
+
+      setup(() => {
+        redirectStub = sandbox.stub(element, '_redirect');
+        setParamsStub = sandbox.stub(element, '_setParams');
+      });
+
+      test('_handleAdminPlaceholderRoute', () => {
+        element._handleAdminPlaceholderRoute({params: {}});
+        assert.equal(setParamsStub.lastCall.args[0].view,
+            Gerrit.Nav.View.ADMIN);
+        assert.isTrue(setParamsStub.lastCall.args[0].placeholder);
+      });
+
+      test('_handleAgreementsRoute', () => {
+        element._handleAgreementsRoute({params: {}});
+        assert.isTrue(setParamsStub.calledOnce);
+        assert.equal(setParamsStub.lastCall.args[0].view,
+            Gerrit.Nav.View.AGREEMENTS);
+      });
+
+      test('_handleSettingsLegacyRoute', () => {
+        const data = {params: {0: 'my-token'}};
+        assertDataToParams(data, '_handleSettingsLegacyRoute', {
+          view: Gerrit.Nav.View.SETTINGS,
+          emailToken: 'my-token',
+        });
+      });
+
+      test('_handleSettingsRoute', () => {
+        const data = {};
+        assertDataToParams(data, '_handleSettingsRoute', {
+          view: Gerrit.Nav.View.SETTINGS,
+        });
+      });
+
+      test('_handleDefaultRoute', () => {
+        element._app = {dispatchEvent: sinon.stub()};
+        element._handleDefaultRoute();
+        assert.isTrue(element._app.dispatchEvent.calledOnce);
+        assert.equal(
+            element._app.dispatchEvent.lastCall.args[0].detail.response.status,
+            404);
+      });
+
+      test('_handleImproperlyEncodedPlusRoute', () => {
+        // Regression test for Issue 7100.
+        element._handleImproperlyEncodedPlusRoute(
+            {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(
+            redirectStub.lastCall.args[0],
+            '/c/test/+/42');
+
+        sandbox.stub(element, '_getHashFromCanonicalPath').returns('foo');
+        element._handleImproperlyEncodedPlusRoute(
+            {canonicalPath: '/c/test/%20/42', params: ['test', '42']});
+        assert.equal(
+            redirectStub.lastCall.args[0],
+            '/c/test/+/42#foo');
+      });
+
+      test('_handleQueryLegacySuffixRoute', () => {
+        element._handleQueryLegacySuffixRoute({path: '/q/foo+bar,n,z'});
+        assert.isTrue(redirectStub.calledOnce);
+        assert.equal(redirectStub.lastCall.args[0], '/q/foo+bar');
+      });
+
+      test('_handleQueryRoute', () => {
+        const data = {params: ['project:foo/bar/baz']};
+        assertDataToParams(data, '_handleQueryRoute', {
+          view: Gerrit.Nav.View.SEARCH,
+          query: 'project:foo/bar/baz',
+          offset: undefined,
+        });
+
+        data.params.push(',123', '123');
+        assertDataToParams(data, '_handleQueryRoute', {
+          view: Gerrit.Nav.View.SEARCH,
+          query: 'project:foo/bar/baz',
+          offset: '123',
+        });
+      });
+
+      suite('_handleRegisterRoute', () => {
+        test('happy path', () => {
+          const ctx = {params: ['/foo/bar']};
+          element._handleRegisterRoute(ctx);
+          assert.isTrue(redirectStub.calledWithExactly('/foo/bar'));
+          assert.isTrue(setParamsStub.calledOnce);
+          assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+        });
+
+        test('no param', () => {
+          const ctx = {params: ['']};
+          element._handleRegisterRoute(ctx);
+          assert.isTrue(redirectStub.calledWithExactly('/'));
+          assert.isTrue(setParamsStub.calledOnce);
+          assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+        });
+
+        test('prevent redirect', () => {
+          const ctx = {params: ['/register']};
+          element._handleRegisterRoute(ctx);
+          assert.isTrue(redirectStub.calledWithExactly('/'));
+          assert.isTrue(setParamsStub.calledOnce);
+          assert.isTrue(setParamsStub.lastCall.args[0].justRegistered);
+        });
+      });
+
+      suite('_handleRootRoute', () => {
+        test('closes for closeAfterLogin', () => {
+          const data = {querystring: 'closeAfterLogin', canonicalPath: ''};
+          const closeStub = sandbox.stub(window, 'close');
+          const result = element._handleRootRoute(data);
+          assert.isNotOk(result);
+          assert.isTrue(closeStub.called);
+          assert.isFalse(redirectStub.called);
+        });
+
+        test('redirects to dahsboard if logged in', () => {
+          sandbox.stub(element.$.restAPI, 'getLoggedIn')
+              .returns(Promise.resolve(true));
+          const data = {
+            canonicalPath: '/', path: '/', querystring: '', hash: '',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isOk(result);
+          return result.then(() => {
+            assert.isTrue(redirectStub.calledWithExactly('/dashboard/self'));
+          });
+        });
+
+        test('redirects to open changes if not logged in', () => {
+          sandbox.stub(element.$.restAPI, 'getLoggedIn')
+              .returns(Promise.resolve(false));
+          const data = {
+            canonicalPath: '/', path: '/', querystring: '', hash: '',
+          };
+          const result = element._handleRootRoute(data);
+          assert.isOk(result);
+          return result.then(() => {
+            assert.isTrue(redirectStub.calledWithExactly('/q/status:open'));
+          });
+        });
+
+        suite('GWT hash-path URLs', () => {
+          test('redirects hash-path URLs', () => {
+            const data = {
+              canonicalPath: '/#/foo/bar/baz',
+              hash: '/foo/bar/baz',
+              querystring: '',
+            };
+            const result = element._handleRootRoute(data);
+            assert.isNotOk(result);
+            assert.isTrue(redirectStub.called);
+            assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+          });
+
+          test('redirects hash-path URLs w/o leading slash', () => {
+            const data = {
+              canonicalPath: '/#foo/bar/baz',
+              querystring: '',
+              hash: 'foo/bar/baz',
+            };
+            const result = element._handleRootRoute(data);
+            assert.isNotOk(result);
+            assert.isTrue(redirectStub.called);
+            assert.isTrue(redirectStub.calledWithExactly('/foo/bar/baz'));
+          });
+
+          test('normalizes "/ /" in hash to "/+/"', () => {
+            const data = {
+              canonicalPath: '/#/foo/bar/+/123/4',
+              querystring: '',
+              hash: '/foo/bar/ /123/4',
+            };
+            const result = element._handleRootRoute(data);
+            assert.isNotOk(result);
+            assert.isTrue(redirectStub.called);
+            assert.isTrue(redirectStub.calledWithExactly('/foo/bar/+/123/4'));
+          });
+
+          test('prepends baseurl to hash-path', () => {
+            const data = {
+              canonicalPath: '/#/foo/bar',
+              querystring: '',
+              hash: '/foo/bar',
+            };
+            sandbox.stub(element, 'getBaseUrl').returns('/baz');
+            const result = element._handleRootRoute(data);
+            assert.isNotOk(result);
+            assert.isTrue(redirectStub.called);
+            assert.isTrue(redirectStub.calledWithExactly('/baz/foo/bar'));
+          });
+
+          test('normalizes /VE/ settings hash-paths', () => {
+            const data = {
+              canonicalPath: '/#/VE/foo/bar',
+              querystring: '',
+              hash: '/VE/foo/bar',
+            };
+            const result = element._handleRootRoute(data);
+            assert.isNotOk(result);
+            assert.isTrue(redirectStub.called);
+            assert.isTrue(redirectStub.calledWithExactly(
+                '/settings/VE/foo/bar'));
+          });
+
+          test('does not drop "inner hashes"', () => {
+            const data = {
+              canonicalPath: '/#/foo/bar#baz',
+              querystring: '',
+              hash: '/foo/bar',
+            };
+            const result = element._handleRootRoute(data);
+            assert.isNotOk(result);
+            assert.isTrue(redirectStub.called);
+            assert.isTrue(redirectStub.calledWithExactly('/foo/bar#baz'));
+          });
+        });
+      });
+
+      suite('_handleDashboardRoute', () => {
+        let redirectToLoginStub;
+
+        setup(() => {
+          redirectToLoginStub = sandbox.stub(element, '_redirectToLogin');
+        });
+
+        test('no user specified', () => {
+          const data = {canonicalPath: '/dashboard', params: {}};
+          const result = element._handleDashboardRoute(data);
+          assert.isNotOk(result);
+          assert.isFalse(setParamsStub.called);
+          assert.isFalse(redirectToLoginStub.called);
+          assert.isTrue(redirectStub.called);
+          assert.equal(redirectStub.lastCall.args[0], '/dashboard/self');
+        });
+
+        test('own dahsboard but signed out redirects to login', () => {
+          sandbox.stub(element.$.restAPI, 'getLoggedIn')
+              .returns(Promise.resolve(false));
+          const data = {canonicalPath: '/dashboard', params: {0: 'seLF'}};
+          const result = element._handleDashboardRoute(data);
+          assert.isOk(result);
+          return result.then(() => {
+            assert.isTrue(redirectToLoginStub.calledOnce);
+            assert.isFalse(redirectStub.called);
+            assert.isFalse(setParamsStub.called);
+          });
+        });
+
+        test('non-self dahsboard but signed out does not redirect', () => {
+          sandbox.stub(element.$.restAPI, 'getLoggedIn')
+              .returns(Promise.resolve(false));
+          const data = {canonicalPath: '/dashboard', params: {0: 'foo'}};
+          const result = element._handleDashboardRoute(data);
+          assert.isOk(result);
+          return result.then(() => {
+            assert.isFalse(redirectToLoginStub.called);
+            assert.isFalse(setParamsStub.called);
+            assert.isTrue(redirectStub.calledOnce);
+            assert.equal(redirectStub.lastCall.args[0], '/q/owner:foo');
+          });
+        });
+
+        test('dahsboard while signed in sets params', () => {
+          sandbox.stub(element.$.restAPI, 'getLoggedIn')
+              .returns(Promise.resolve(true));
+          const data = {canonicalPath: '/dashboard', params: {0: 'foo'}};
+          const result = element._handleDashboardRoute(data);
+          assert.isOk(result);
+          return result.then(() => {
+            assert.isFalse(redirectToLoginStub.called);
+            assert.isFalse(redirectStub.called);
+            assert.isTrue(setParamsStub.calledOnce);
+            assert.deepEqual(setParamsStub.lastCall.args[0], {
+              view: Gerrit.Nav.View.DASHBOARD,
+              user: 'foo',
+            });
+          });
+        });
+      });
+
+      suite('group routes', () => {
+        test('_handleGroupInfoRoute', () => {
+          const data = {params: {0: 1234}};
+          element._handleGroupInfoRoute(data);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.equal(redirectStub.lastCall.args[0], '/admin/groups/1234');
+        });
+
+        test('_handleGroupAuditLogRoute', () => {
+          const data = {params: {0: 1234}};
+          assertDataToParams(data, '_handleGroupAuditLogRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-group-audit-log',
+            detailType: 'audit-log',
+            groupId: 1234,
+          });
+        });
+
+        test('_handleGroupMembersRoute', () => {
+          const data = {params: {0: 1234}};
+          assertDataToParams(data, '_handleGroupMembersRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-group-members',
+            detailType: 'members',
+            groupId: 1234,
+          });
+        });
+
+        test('_handleGroupListOffsetRoute', () => {
+          const data = {params: {}};
+          assertDataToParams(data, '_handleGroupListOffsetRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-admin-group-list',
+            offset: 0,
+            filter: null,
+            openCreateModal: false,
+          });
+
+          data.params[1] = 42;
+          assertDataToParams(data, '_handleGroupListOffsetRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-admin-group-list',
+            offset: 42,
+            filter: null,
+            openCreateModal: false,
+          });
+
+          data.hash = 'create';
+          assertDataToParams(data, '_handleGroupListOffsetRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-admin-group-list',
+            offset: 42,
+            filter: null,
+            openCreateModal: true,
+          });
+        });
+
+        test('_handleGroupListFilterOffsetRoute', () => {
+          const data = {params: {filter: 'foo', offset: 42}};
+          assertDataToParams(data, '_handleGroupListFilterOffsetRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-admin-group-list',
+            offset: 42,
+            filter: 'foo',
+          });
+        });
+
+        test('_handleGroupListFilterRoute', () => {
+          const data = {params: {filter: 'foo'}};
+          assertDataToParams(data, '_handleGroupListFilterRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-admin-group-list',
+            filter: 'foo',
+          });
+        });
+
+        test('_handleGroupRoute', () => {
+          const data = {params: {0: 4321}};
+          assertDataToParams(data, '_handleGroupRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-group',
+            groupId: 4321,
+          });
+        });
+      });
+
+      suite('project routes', () => {
+        test('_handleProjectRoute', () => {
+          const data = {params: {0: 4321}};
+          assertDataToParams(data, '_handleProjectRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-project',
+            project: 4321,
+          });
+        });
+
+        test('_handleProjectCommandsRoute', () => {
+          const data = {params: {0: 4321}};
+          assertDataToParams(data, '_handleProjectCommandsRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-project-commands',
+            detailType: 'commands',
+            project: 4321,
+          });
+        });
+
+        test('_handleProjectAccessRoute', () => {
+          const data = {params: {0: 4321}};
+          assertDataToParams(data, '_handleProjectAccessRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-project-access',
+            detailType: 'access',
+            project: 4321,
+          });
+        });
+
+        suite('branch list routes', () => {
+          test('_handleBranchListOffsetRoute', () => {
+            const data = {params: {0: 4321}};
+            assertDataToParams(data, '_handleBranchListOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'branches',
+              project: 4321,
+              offset: 0,
+              filter: null,
+            });
+
+            data.params[2] = 42;
+            assertDataToParams(data, '_handleBranchListOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'branches',
+              project: 4321,
+              offset: 42,
+              filter: null,
+            });
+          });
+
+          test('_handleBranchListFilterOffsetRoute', () => {
+            const data = {params: {project: 4321, filter: 'foo', offset: 42}};
+            assertDataToParams(data, '_handleBranchListFilterOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'branches',
+              project: 4321,
+              offset: 42,
+              filter: 'foo',
+            });
+          });
+
+          test('_handleBranchListFilterRoute', () => {
+            const data = {params: {project: 4321, filter: 'foo'}};
+            assertDataToParams(data, '_handleBranchListFilterRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'branches',
+              project: 4321,
+              filter: 'foo',
+            });
+          });
+        });
+
+        suite('tag list routes', () => {
+          test('_handleTagListOffsetRoute', () => {
+            const data = {params: {0: 4321}};
+            assertDataToParams(data, '_handleTagListOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'tags',
+              project: 4321,
+              offset: 0,
+              filter: null,
+            });
+          });
+
+          test('_handleTagListFilterOffsetRoute', () => {
+            const data = {params: {project: 4321, filter: 'foo', offset: 42}};
+            assertDataToParams(data, '_handleTagListFilterOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'tags',
+              project: 4321,
+              offset: 42,
+              filter: 'foo',
+            });
+          });
+
+          test('_handleTagListFilterRoute', () => {
+            const data = {params: {project: 4321}};
+            assertDataToParams(data, '_handleTagListFilterRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'tags',
+              project: 4321,
+              filter: null,
+            });
+
+            data.params.filter = 'foo';
+            assertDataToParams(data, '_handleTagListFilterRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-detail-list',
+              detailType: 'tags',
+              project: 4321,
+              filter: 'foo',
+            });
+          });
+        });
+
+        suite('project list routes', () => {
+          test('_handleProjectListOffsetRoute', () => {
+            const data = {params: {}};
+            assertDataToParams(data, '_handleProjectListOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-list',
+              offset: 0,
+              filter: null,
+              openCreateModal: false,
+            });
+
+            data.params[1] = 42;
+            assertDataToParams(data, '_handleProjectListOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-list',
+              offset: 42,
+              filter: null,
+              openCreateModal: false,
+            });
+
+            data.hash = 'create';
+            assertDataToParams(data, '_handleProjectListOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-list',
+              offset: 42,
+              filter: null,
+              openCreateModal: true,
+            });
+          });
+
+          test('_handleProjectListFilterOffsetRoute', () => {
+            const data = {params: {filter: 'foo', offset: 42}};
+            assertDataToParams(data, '_handleProjectListFilterOffsetRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-list',
+              offset: 42,
+              filter: 'foo',
+            });
+          });
+
+          test('_handleProjectListFilterRoute', () => {
+            const data = {params: {}};
+            assertDataToParams(data, '_handleProjectListFilterRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-list',
+              filter: null,
+            });
+
+            data.params.filter = 'foo';
+            assertDataToParams(data, '_handleProjectListFilterRoute', {
+              view: Gerrit.Nav.View.ADMIN,
+              adminView: 'gr-project-list',
+              filter: 'foo',
+            });
+          });
+        });
+      });
+
+      suite('plugin routes', () => {
+        test('_handlePluginListOffsetRoute', () => {
+          const data = {params: {}};
+          assertDataToParams(data, '_handlePluginListOffsetRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-plugin-list',
+            offset: 0,
+            filter: null,
+          });
+
+          data.params[1] = 42;
+          assertDataToParams(data, '_handlePluginListOffsetRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-plugin-list',
+            offset: 42,
+            filter: null,
+          });
+        });
+
+        test('_handlePluginListFilterOffsetRoute', () => {
+          const data = {params: {filter: 'foo', offset: 42}};
+          assertDataToParams(data, '_handlePluginListFilterOffsetRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-plugin-list',
+            offset: 42,
+            filter: 'foo',
+          });
+        });
+
+        test('_handlePluginListFilterRoute', () => {
+          const data = {params: {}};
+          assertDataToParams(data, '_handlePluginListFilterRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-plugin-list',
+            filter: null,
+          });
+
+          data.params.filter = 'foo';
+          assertDataToParams(data, '_handlePluginListFilterRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-plugin-list',
+            filter: 'foo',
+          });
+        });
+
+        test('_handlePluginListRoute', () => {
+          const data = {params: {}};
+          assertDataToParams(data, '_handlePluginListRoute', {
+            view: Gerrit.Nav.View.ADMIN,
+            adminView: 'gr-plugin-list',
+          });
+        });
+      });
+
+      suite('change/diff routes', () => {
+        test('_handleChangeNumberLegacyRoute', () => {
+          const data = {params: {0: 12345}};
+          element._handleChangeNumberLegacyRoute(data);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly('/c/12345'));
+        });
+
+        test('_handleChangeLegacyRoute', () => {
+          const normalizeRouteStub = sandbox.stub(element,
+              '_normalizeLegacyRouteParams');
+          const ctx = {
+            params: [
+              1234, // 0 Change number
+              null, // 1 Unused
+              null, // 2 Unused
+              6, // 3 Base patch number
+              null, // 4 Unused
+              9, // 5 Patch number
+            ],
+          };
+          element._handleChangeLegacyRoute(ctx);
+          assert.isTrue(normalizeRouteStub.calledOnce);
+          assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
+            changeNum: 1234,
+            basePatchNum: 6,
+            patchNum: 9,
+            view: Gerrit.Nav.View.CHANGE,
+          });
+        });
+
+        test('_handleDiffLegacyRoute', () => {
+          const normalizeRouteStub = sandbox.stub(element,
+              '_normalizeLegacyRouteParams');
+          const ctx = {
+            params: [
+              1234, // 0 Change number
+              null, // 1 Unused
+              3, // 2 Base patch number
+              null, // 3 Unused
+              8, // 4 Patch number
+              'foo/bar', // 5 Diff path
+            ],
+            path: '/c/1234/3..8/foo/bar',
+            hash: 'b123',
+          };
+          element._handleDiffLegacyRoute(ctx);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(normalizeRouteStub.calledOnce);
+          assert.deepEqual(normalizeRouteStub.lastCall.args[0], {
+            changeNum: 1234,
+            basePatchNum: 3,
+            patchNum: 8,
+            view: Gerrit.Nav.View.DIFF,
+            path: 'foo/bar',
+            lineNum: 123,
+            leftSide: true,
+          });
+        });
+
+        test('_handleLegacyLinenum w/ @321', () => {
+          const ctx = {path: '/c/1234/3..8/foo/bar@321'};
+          element._handleLegacyLinenum(ctx);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly(
+              '/c/1234/3..8/foo/bar#321'));
+        });
+
+        test('_handleLegacyLinenum w/ @b123', () => {
+          const ctx = {path: '/c/1234/3..8/foo/bar@b123'};
+          element._handleLegacyLinenum(ctx);
+          assert.isTrue(redirectStub.calledOnce);
+          assert.isTrue(redirectStub.calledWithExactly(
+              '/c/1234/3..8/foo/bar#b123'));
+        });
+
+        suite('_handleChangeOrDiffRoute', () => {
+          let normalizeRangeStub;
+
+          function makeParams(path, hash) {
+            return {
+              params: [
+                'foo/bar', // 0 Project
+                1234, // 1 Change number
+                null, // 2 Unused
+                null, // 3 Unused
+                4, // 4 Base patch number
+                null, // 5 Unused
+                7, // 6 Patch number
+                null, // 7 Unused,
+                path, // 8 Diff path
+              ],
+              hash,
+            };
+          }
+
+          setup(() => {
+            normalizeRangeStub = sandbox.stub(element,
+                '_normalizePatchRangeParams');
+            sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+          });
+
+          test('needs redirect', () => {
+            normalizeRangeStub.returns(true);
+            sandbox.stub(element, '_generateUrl').returns('foo');
+            const ctx = makeParams(null, '');
+            element._handleChangeOrDiffRoute(ctx);
+            assert.isTrue(normalizeRangeStub.called);
+            assert.isFalse(setParamsStub.called);
+            assert.isTrue(redirectStub.calledOnce);
+            assert.isTrue(redirectStub.calledWithExactly('foo'));
+          });
+
+          test('change view', () => {
+            normalizeRangeStub.returns(false);
+            sandbox.stub(element, '_generateUrl').returns('foo');
+            const ctx = makeParams(null, '');
+            assertDataToParams(ctx, '_handleChangeOrDiffRoute', {
+              view: Gerrit.Nav.View.CHANGE,
+              project: 'foo/bar',
+              changeNum: 1234,
+              basePatchNum: 4,
+              patchNum: 7,
+              path: null,
+            });
+            assert.isFalse(redirectStub.called);
+            assert.isTrue(normalizeRangeStub.called);
+          });
+
+          test('diff view', () => {
+            normalizeRangeStub.returns(false);
+            sandbox.stub(element, '_generateUrl').returns('foo');
+            const ctx = makeParams('foo/bar/baz', 'b44');
+            assertDataToParams(ctx, '_handleChangeOrDiffRoute', {
+              view: Gerrit.Nav.View.DIFF,
+              project: 'foo/bar',
+              changeNum: 1234,
+              basePatchNum: 4,
+              patchNum: 7,
+              path: 'foo/bar/baz',
+              leftSide: true,
+              lineNum: 44,
+            });
+            assert.isFalse(redirectStub.called);
+            assert.isTrue(normalizeRangeStub.called);
+          });
+        });
+
+        test('_handleDiffEditRoute', () => {
+          const normalizeRangeSpy =
+              sandbox.spy(element, '_normalizePatchRangeParams');
+          sandbox.stub(element.$.restAPI, 'setInProjectLookup');
+          const ctx = {
+            params: [
+              'foo/bar', // 0 Project
+              1234, // 1 Change number
+              'foo/bar/baz', // 2 File path
+            ],
+          };
+          const appParams = {
+            project: 'foo/bar',
+            changeNum: 1234,
+            view: Gerrit.Nav.View.EDIT,
+            path: 'foo/bar/baz',
+          };
+
+          element._handleDiffEditRoute(ctx);
+          assert.isFalse(redirectStub.called);
+          assert.isTrue(normalizeRangeSpy.calledOnce);
+          assert.deepEqual(normalizeRangeSpy.lastCall.args[0], appParams);
+          assert.isFalse(normalizeRangeSpy.lastCall.returnValue);
+          assert.deepEqual(setParamsStub.lastCall.args[0], appParams);
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
index 7a63810..d5b394c 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.html
@@ -14,17 +14,17 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
 <link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-search-bar">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: inline-block;
       }
@@ -38,25 +38,22 @@
         flex: 1;
         font: inherit;
         outline: none;
-        padding: 0 .25em 0 .25em;
-      }
-      gr-button {
-        background-color: #f1f2f3;
-        border-radius: 0 2px 2px 0;
-        border-left-width: 0;
+        padding: .25em;
       }
     </style>
     <form>
       <gr-autocomplete
+          show-search-icon
           id="searchInput"
           text="{{_inputVal}}"
           query="[[query]]"
           on-commit="_handleInputCommit"
-          allowNonSuggestedValues
+          allow-non-suggested-values
           multi
           borderless
-          tab-complete-without-commit></gr-autocomplete>
-      <gr-button id="searchButton">Search</gr-button>
+          threshold="[[_threshold]]"
+          tab-complete
+          vertical-offset="30"></gr-autocomplete>
       <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     </form>
   </template>
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
index 4452b04..a7a9fe5 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar.js
@@ -14,11 +14,12 @@
 (function() {
   'use strict';
 
-  // Possible static search options for auto complete.
-  var SEARCH_OPERATORS = [
+  // Possible static search options for auto complete, without negations.
+  const SEARCH_OPERATORS = [
     'added:',
     'age:',
     'age:1week', // Give an example age
+    'assignee:',
     'author:',
     'branch:',
     'bug:',
@@ -39,20 +40,25 @@
     'has:edit',
     'has:star',
     'has:stars',
+    'has:unresolved',
+    'hashtag:',
     'intopic:',
     'is:',
     'is:abandoned',
+    'is:assigned',
     'is:closed',
-    'is:draft',
+    'is:ignored',
     'is:mergeable',
     'is:merged',
     'is:open',
     'is:owner',
     'is:pending',
+    'is:private',
     'is:reviewed',
     'is:reviewer',
     'is:starred',
     'is:watched',
+    'is:wip',
     'label:',
     'message:',
     'owner:',
@@ -71,7 +77,6 @@
     'status:',
     'status:abandoned',
     'status:closed',
-    'status:draft',
     'status:merged',
     'status:open',
     'status:pending',
@@ -80,24 +85,26 @@
     'tr:',
   ];
 
-  var SELF_EXPRESSION = 'self';
+  // All of the ops, with corresponding negations.
+  const SEARCH_OPERATORS_WITH_NEGATIONS =
+      SEARCH_OPERATORS.concat(SEARCH_OPERATORS.map(op => `-${op}`));
 
-  var MAX_AUTOCOMPLETE_RESULTS = 10;
+  const SELF_EXPRESSION = 'self';
+  const ME_EXPRESSION = 'me';
 
-  var TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
+  const MAX_AUTOCOMPLETE_RESULTS = 10;
+
+  const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+\s*/g;
 
   Polymer({
     is: 'gr-search-bar',
 
     behaviors: [
+      Gerrit.AnonymousNameBehavior,
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.URLEncodingBehavior,
     ],
 
-    listeners: {
-      'searchButton.tap': '_preventDefaultAndNavigateToInputVal',
-    },
-
     keyBindings: {
       '/': '_handleForwardSlashKey',
     },
@@ -111,22 +118,33 @@
       },
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
       query: {
         type: Function,
-        value: function() {
+        value() {
           return this._getSearchSuggestions.bind(this);
         },
       },
       _inputVal: String,
+      _threshold: {
+        type: Number,
+        value: 1,
+      },
+      _config: Object,
     },
 
-    _valueChanged: function(value) {
+    attached() {
+      this.$.restAPI.getConfig().then(cfg => {
+        this._config = cfg;
+      });
+    },
+
+    _valueChanged(value) {
       this._inputVal = value;
     },
 
-    _handleInputCommit: function(e) {
+    _handleInputCommit(e) {
       this._preventDefaultAndNavigateToInputVal(e);
     },
 
@@ -138,9 +156,9 @@
      *
      * @param {!Event} e
      */
-    _preventDefaultAndNavigateToInputVal: function(e) {
+    _preventDefaultAndNavigateToInputVal(e) {
       e.preventDefault();
-      var target = Polymer.dom(e).rootTarget;
+      const target = Polymer.dom(e).rootTarget;
       // If the target is the #searchInput or has a sub-input component, that
       // is what holds the focus as opposed to the target from the DOM event.
       if (target.$.input) {
@@ -153,6 +171,10 @@
       }
     },
 
+    _accountOrAnon(name) {
+      return this.getUserName(this._config, name, false);
+    },
+
     /**
      * Fetch from the API the predicted accounts.
      * @param {string} predicate - The first part of the search term, e.g.
@@ -162,22 +184,26 @@
      * @return {!Promise} This returns a promise that resolves to an array of
      *     strings.
      */
-    _fetchAccounts: function(predicate, expression) {
+    _fetchAccounts(predicate, expression) {
       if (expression.length === 0) { return Promise.resolve([]); }
       return this.$.restAPI.getSuggestedAccounts(
           expression,
           MAX_AUTOCOMPLETE_RESULTS)
-          .then(function(accounts) {
+          .then(accounts => {
             if (!accounts) { return []; }
-            return accounts.map(function(acct) {
-              return predicate + ':"' + acct.name + ' <' + acct.email + '>"';
-            });
-          }).then(function(accounts) {
+            return accounts.map(acct => acct.email ?
+              `${predicate}:${acct.email}` :
+              `${predicate}:"${this._accountOrAnon(acct)}"`);
+          }).then(accounts => {
             // When the expression supplied is a beginning substring of 'self',
             // add it as an autocomplete option.
-            return SELF_EXPRESSION.indexOf(expression) === 0 ?
-                accounts.concat([predicate + ':' + SELF_EXPRESSION]) :
-                accounts;
+            if (SELF_EXPRESSION.startsWith(expression)) {
+              return accounts.concat([predicate + ':' + SELF_EXPRESSION]);
+            } else if (ME_EXPRESSION.startsWith(expression)) {
+              return accounts.concat([predicate + ':' + ME_EXPRESSION]);
+            } else {
+              return accounts;
+            }
           });
     },
 
@@ -190,15 +216,15 @@
      * @return {!Promise} This returns a promise that resolves to an array of
      *     strings.
      */
-    _fetchGroups: function(predicate, expression) {
+    _fetchGroups(predicate, expression) {
       if (expression.length === 0) { return Promise.resolve([]); }
       return this.$.restAPI.getSuggestedGroups(
           expression,
           MAX_AUTOCOMPLETE_RESULTS)
-          .then(function(groups) {
+          .then(groups => {
             if (!groups) { return []; }
-            var keys = Object.keys(groups);
-            return keys.map(function(key) { return predicate + ':' + key; });
+            const keys = Object.keys(groups);
+            return keys.map(key => predicate + ':' + key);
           });
     },
 
@@ -211,14 +237,14 @@
      * @return {!Promise} This returns a promise that resolves to an array of
      *     strings.
      */
-    _fetchProjects: function(predicate, expression) {
+    _fetchProjects(predicate, expression) {
       return this.$.restAPI.getSuggestedProjects(
           expression,
           MAX_AUTOCOMPLETE_RESULTS)
-          .then(function(projects) {
+          .then(projects => {
             if (!projects) { return []; }
-            var keys = Object.keys(projects);
-            return keys.map(function(key) { return predicate + ':' + key; });
+            const keys = Object.keys(projects);
+            return keys.map(key => predicate + ':' + key);
           });
     },
 
@@ -229,11 +255,11 @@
      * @return {!Promise} This returns a promise that resolves to an array of
      *     strings.
      */
-    _fetchSuggestions: function(input) {
+    _fetchSuggestions(input) {
       // Split the input on colon to get a two part predicate/expression.
-      var splitInput = input.split(':');
-      var predicate = splitInput[0];
-      var expression = splitInput[1] || '';
+      const splitInput = input.split(':');
+      const predicate = splitInput[0];
+      const expression = splitInput[1] || '';
       // Switch on the predicate to determine what to autocomplete.
       switch (predicate) {
         case 'ownerin':
@@ -258,10 +284,8 @@
           return this._fetchAccounts(predicate, expression);
 
         default:
-          return Promise.resolve(SEARCH_OPERATORS
-              .filter(function(operator) {
-                return operator.indexOf(input) !== -1;
-              }));
+          return Promise.resolve(SEARCH_OPERATORS_WITH_NEGATIONS
+              .filter(operator => operator.includes(input)));
       }
     },
 
@@ -271,19 +295,19 @@
      * @return {!Promise} This returns a promise that resolves to an array of
      *     strings.
      */
-    _getSearchSuggestions: function(input) {
+    _getSearchSuggestions(input) {
       // Allow spaces within quoted terms.
-      var tokens = input.match(TOKENIZE_REGEX);
-      var trimmedInput = tokens[tokens.length - 1].toLowerCase();
+      const tokens = input.match(TOKENIZE_REGEX);
+      const trimmedInput = tokens[tokens.length - 1].toLowerCase();
 
       return this._fetchSuggestions(trimmedInput)
-          .then(function(operators) {
+          .then(operators => {
             if (!operators || !operators.length) { return []; }
             return operators
                 // Prioritize results that start with the input.
-                .sort(function(a, b) {
-                  var aContains = a.toLowerCase().indexOf(trimmedInput);
-                  var bContains = b.toLowerCase().indexOf(trimmedInput);
+                .sort((a, b) => {
+                  const aContains = a.toLowerCase().indexOf(trimmedInput);
+                  const bContains = b.toLowerCase().indexOf(trimmedInput);
                   if (aContains === bContains) {
                     return a.localeCompare(b);
                   }
@@ -298,7 +322,7 @@
                 // Return only the first {MAX_AUTOCOMPLETE_RESULTS} results.
                 .slice(0, MAX_AUTOCOMPLETE_RESULTS - 1)
                 // Map to an object to play nice with gr-autocomplete.
-                .map(function(operator) {
+                .map(operator => {
                   return {
                     name: operator,
                     value: operator,
@@ -307,7 +331,7 @@
           });
     },
 
-    _handleForwardSlashKey: function(e) {
+    _handleForwardSlashKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
diff --git a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
index 3ddc96b..9551c79 100644
--- a/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
+++ b/polygerrit-ui/app/elements/core/gr-search-bar/gr-search-bar_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-search-bar.html">
 <script src="../../../scripts/util.js"></script>
 
@@ -35,37 +35,32 @@
 </test-fixture>
 
 <script>
-  suite('gr-search-bar tests', function() {
-    var element;
+  suite('gr-search-bar tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
-    test('value is propagated to _inputVal', function() {
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('value is propagated to _inputVal', () => {
       element.value = 'foo';
       assert.equal(element._inputVal, 'foo');
     });
 
-    function getActiveElement() {
+    getActiveElement = () => {
       return document.activeElement.shadowRoot ?
           document.activeElement.shadowRoot.activeElement :
           document.activeElement;
-    }
+    };
 
-    test('tap on search button triggers nav', function(done) {
-      sinon.stub(page, 'show', function() {
-        page.show.restore();
-        assert.notEqual(getActiveElement(), element.$.searchInput);
-        assert.notEqual(getActiveElement(), element.$.searchButton);
-        done();
-      });
-      element.value = 'test';
-      MockInteractions.tap(element.$.searchButton);
-    });
-
-    test('enter in search input triggers nav', function(done) {
-      sinon.stub(page, 'show', function() {
+    test('enter in search input triggers nav', done => {
+      sandbox.stub(page, 'show', () => {
         page.show.restore();
         assert.notEqual(getActiveElement(), element.$.searchInput);
         assert.notEqual(getActiveElement(), element.$.searchButton);
@@ -76,106 +71,120 @@
           null, 'enter');
     });
 
-    test('search query should be double-escaped', function() {
-      var showStub = sinon.stub(page, 'show');
+    test('search query should be double-escaped', () => {
+      const showStub = sandbox.stub(page, 'show');
       element.$.searchInput.text = 'fate/stay';
       MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
           null, 'enter');
       assert.equal(showStub.lastCall.args[0], '/q/fate%252Fstay');
-      showStub.restore();
     });
 
-    test('input blurred after commit', function() {
-      var showStub = sinon.stub(page, 'show');
-      var blurSpy = sinon.spy(element.$.searchInput.$.input, 'blur');
+    test('input blurred after commit', () => {
+      sandbox.stub(page, 'show');
+      const blurSpy = sandbox.spy(element.$.searchInput.$.input, 'blur');
       element.$.searchInput.text = 'fate/stay';
       MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
           null, 'enter');
       assert.isTrue(blurSpy.called);
-      showStub.restore();
-      blurSpy.restore();
     });
 
-    test('empty search query does not trigger nav', function() {
-      var showSpy = sinon.spy(page, 'show');
+    test('empty search query does not trigger nav', () => {
+      const showSpy = sandbox.spy(page, 'show');
       element.value = '';
       MockInteractions.pressAndReleaseKeyOn(element.$.searchInput.$.input, 13,
           null, 'enter');
       assert.isFalse(showSpy.called);
     });
 
-    test('keyboard shortcuts', function() {
-      var focusSpy = sinon.spy(element.$.searchInput, 'focus');
-      var selectAllSpy = sinon.spy(element.$.searchInput, 'selectAll');
+    test('keyboard shortcuts', () => {
+      const focusSpy = sandbox.spy(element.$.searchInput, 'focus');
+      const selectAllSpy = sandbox.spy(element.$.searchInput, 'selectAll');
       MockInteractions.pressAndReleaseKeyOn(document.body, 191, null, '/');
       assert.isTrue(focusSpy.called);
       assert.isTrue(selectAllSpy.called);
     });
 
-    suite('_getSearchSuggestions', function() {
-      setup(function() {
-        sinon.stub(element.$.restAPI, 'getSuggestedAccounts', function() {
-          return Promise.resolve([
+    suite('_getSearchSuggestions', () => {
+      test('Autocompletes accounts', () => {
+        sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+          Promise.resolve([
             {
               name: 'fred',
               email: 'fred@goog.co',
             },
-          ]);
-        });
-        sinon.stub(element.$.restAPI, 'getSuggestedGroups', function() {
-          return Promise.resolve({
-            Polygerrit: 0,
-            gerrit: 0,
-            gerrittest: 0,
-          });
-        });
-        sinon.stub(element.$.restAPI, 'getSuggestedProjects', function() {
-          return Promise.resolve({
-            Polygerrit: 0,
-          });
+          ])
+        );
+        return element._getSearchSuggestions('owner:fr').then(s => {
+          assert.equal(s[0].value, 'owner:fred@goog.co');
         });
       });
 
-      teardown(function() {
-        element.$.restAPI.getSuggestedAccounts.restore();
-        element.$.restAPI.getSuggestedGroups.restore();
-        element.$.restAPI.getSuggestedProjects.restore();
-      });
-
-      test('Autocompletes accounts', function(done) {
-        element._getSearchSuggestions('owner:fr').then(function(s) {
-          assert.equal(s[0].value, 'owner:"fred <fred@goog.co>"');
-          done();
-        });
-      });
-
-      test('Inserts self as option when valid', function(done) {
-        element._getSearchSuggestions('owner:s').then(function(s) {
+      test('Inserts self as option when valid', done => {
+        sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+          Promise.resolve([
+            {
+              name: 'fred',
+              email: 'fred@goog.co',
+            },
+          ])
+        );
+        element._getSearchSuggestions('owner:s').then(s => {
           assert.equal(s[0].value, 'owner:self');
-        }).then(function() {
-          element._getSearchSuggestions('owner:selfs').then(function(s) {
+        }).then(() => {
+          element._getSearchSuggestions('owner:selfs').then(s => {
             assert.notEqual(s[0].value, 'owner:self');
             done();
           });
         });
       });
 
-      test('Autocompletes groups', function(done) {
-        element._getSearchSuggestions('ownerin:pol').then(function(s) {
+      test('Inserts me as option when valid', done => {
+        sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+          Promise.resolve([
+            {
+              name: 'fred',
+              email: 'fred@goog.co',
+            },
+          ])
+        );
+        element._getSearchSuggestions('owner:m').then(s => {
+          assert.equal(s[0].value, 'owner:me');
+        }).then(() => {
+          element._getSearchSuggestions('owner:meme').then(s => {
+            assert.notEqual(s[0].value, 'owner:me');
+            done();
+          });
+        });
+      });
+
+      test('Autocompletes groups', done => {
+        sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
+          Promise.resolve({
+            Polygerrit: 0,
+            gerrit: 0,
+            gerrittest: 0,
+          })
+        );
+        element._getSearchSuggestions('ownerin:pol').then(s => {
           assert.equal(s[0].value, 'ownerin:Polygerrit');
           done();
         });
       });
 
-      test('Autocompletes projects', function(done) {
-        element._getSearchSuggestions('project:pol').then(function(s) {
+      test('Autocompletes projects', done => {
+        sandbox.stub(element.$.restAPI, 'getSuggestedProjects', () =>
+          Promise.resolve({
+            Polygerrit: 0,
+          })
+        );
+        element._getSearchSuggestions('project:pol').then(s => {
           assert.equal(s[0].value, 'project:Polygerrit');
           done();
         });
       });
 
-      test('Autocompletes simple searches', function(done) {
-        element._getSearchSuggestions('is:o').then(function(s) {
+      test('Autocompletes simple searches', done => {
+        element._getSearchSuggestions('is:o').then(s => {
           assert.equal(s[0].name, 'is:open');
           assert.equal(s[0].value, 'is:open');
           assert.equal(s[1].name, 'is:owner');
@@ -184,31 +193,73 @@
         });
       });
 
-      test('Does not autocomplete with no match', function(done) {
-        element._getSearchSuggestions('asdasdasdasd').then(function(s) {
+      test('Does not autocomplete with no match', done => {
+        element._getSearchSuggestions('asdasdasdasd').then(s => {
           assert.equal(s.length, 0);
           done();
         });
       });
 
-      test('Autocomplete doesnt override exact matches to input',
-          function(done) {
-        element._getSearchSuggestions('ownerin:gerrit').then(function(s) {
+      test('Autocomplete doesnt override exact matches to input', done => {
+        sandbox.stub(element.$.restAPI, 'getSuggestedGroups', () =>
+          Promise.resolve({
+            Polygerrit: 0,
+            gerrit: 0,
+            gerrittest: 0,
+          })
+        );
+        element._getSearchSuggestions('ownerin:gerrit').then(s => {
           assert.equal(s[0].value, 'ownerin:gerrit');
           done();
         });
       });
 
-      test('Autocomplete respects spaces', function(done) {
-        element._getSearchSuggestions('is:ope').then(function(s) {
+      test('Autocomplete respects spaces', done => {
+        sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+          Promise.resolve([
+            {
+              name: 'fred',
+              email: 'fred@goog.co',
+            },
+          ])
+        );
+        element._getSearchSuggestions('is:ope').then(s => {
           assert.equal(s[0].name, 'is:open');
           assert.equal(s[0].value, 'is:open');
-          element._getSearchSuggestions('is:ope ').then(function(s) {
+          element._getSearchSuggestions('is:ope ').then(s => {
             assert.equal(s.length, 0);
             done();
           });
         });
       });
+
+      test('Autocompletes accounts with no email', done => {
+        sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+          Promise.resolve([
+            {
+              name: 'fred',
+            },
+          ])
+        );
+        element._getSearchSuggestions('owner:fr').then(s => {
+          assert.equal(s[0].value, 'owner:"fred"');
+          done();
+        });
+      });
+
+      test('Autocompletes accounts with email', done => {
+        sandbox.stub(element.$.restAPI, 'getSuggestedAccounts', () =>
+          Promise.resolve([
+            {
+              email: 'fred@goog.co',
+            },
+          ])
+        );
+        element._getSearchSuggestions('owner:fr').then(s => {
+          assert.equal(s[0].value, 'owner:fred@goog.co');
+          done();
+        });
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
new file mode 100644
index 0000000..68e2ff8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-comment-api">
+  <template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-comment-api.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
new file mode 100644
index 0000000..ef39e1f
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
@@ -0,0 +1,174 @@
+// 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.
+(function() {
+  'use strict';
+
+  const PARENT = 'PARENT';
+
+  Polymer({
+    is: 'gr-comment-api',
+
+    properties: {
+      /** @type {number} */
+      _changeNum: Number,
+      /** @type {!Object|undefined} */
+      _comments: Object,
+      /** @type {!Object|undefined} */
+      _drafts: Object,
+      /** @type {!Object|undefined} */
+      _robotComments: Object,
+    },
+
+    behaviors: [
+      Gerrit.PatchSetBehavior,
+    ],
+
+    /**
+     * Load all comments (with drafts and robot comments) for the given change
+     * number. The returned promise resolves when the comments have loaded, but
+     * does not yield the comment data.
+     *
+     * @param {number} changeNum
+     * @return {!Promise}
+     */
+    loadAll(changeNum) {
+      this._changeNum = changeNum;
+
+      // Reset comment arrays.
+      this._comments = undefined;
+      this._drafts = undefined;
+      this._robotComments = undefined;
+
+      const promises = [];
+      promises.push(this.$.restAPI.getDiffComments(changeNum)
+          .then(comments => { this._comments = comments; }));
+      promises.push(this.$.restAPI.getDiffRobotComments(changeNum)
+          .then(robotComments => { this._robotComments = robotComments; }));
+      promises.push(this.$.restAPI.getDiffDrafts(changeNum)
+          .then(drafts => { this._drafts = drafts; }));
+
+      return Promise.all(promises);
+    },
+
+    /**
+     * Get an object mapping file paths to a boolean representing whether that
+     * path contains diff comments in the given patch set (including drafts and
+     * robot comments).
+     *
+     * Paths with comments are mapped to true, whereas paths without comments
+     * are not mapped.
+     *
+     * @param {!Object} patchRange The patch-range object containing patchNum
+     *     and basePatchNum properties to represent the range.
+     * @return {Object}
+     */
+    getPaths(patchRange) {
+      const responses = [this._comments, this._drafts, this._robotComments];
+      const commentMap = {};
+      for (const response of responses) {
+        for (const path in response) {
+          if (response.hasOwnProperty(path) &&
+              response[path].some(c => this._isInPatchRange(c, patchRange))) {
+            commentMap[path] = true;
+          }
+        }
+      }
+      return commentMap;
+    },
+
+    /**
+     * Get the comments (with drafts and robot comments) for a path and
+     * patch-range. Returns an object with left and right properties mapping to
+     * arrays of comments in on either side of the patch range for that path.
+     *
+     * @param {!string} path
+     * @param {!Object} patchRange The patch-range object containing patchNum
+     *     and basePatchNum properties to represent the range.
+     * @param {Object=} opt_projectConfig Optional project config object to
+     *     include in the meta sub-object.
+     * @return {Object}
+     */
+    getCommentsForPath(path, patchRange, opt_projectConfig) {
+      const comments = this._comments[path] || [];
+      const drafts = this._drafts[path] || [];
+      const robotComments = this._robotComments[path] || [];
+
+      drafts.forEach(d => { d.__draft = true; });
+
+      const all = comments.concat(drafts).concat(robotComments);
+
+      const baseComments = all.filter(c =>
+          this._isInBaseOfPatchRange(c, patchRange));
+      const revisionComments = all.filter(c =>
+          this._isInRevisionOfPatchRange(c, patchRange));
+
+      return {
+        meta: {
+          changeNum: this._changeNum,
+          path,
+          patchRange,
+          projectConfig: opt_projectConfig,
+        },
+        left: baseComments,
+        right: revisionComments,
+      };
+    },
+
+    /**
+     * Whether the given comment should be included in the base side of the
+     * given patch range.
+     * @param {!Object} comment
+     * @param {!Object} range
+     * @return {boolean}
+     */
+    _isInBaseOfPatchRange(comment, range) {
+      // If the base of the range is the parent of the patch:
+      if (range.basePatchNum === PARENT &&
+          comment.side === PARENT &&
+          this.patchNumEquals(comment.patch_set, range.patchNum)) {
+        return true;
+      }
+      // If the base of the range is not the parent of the patch:
+      if (range.basePatchNum !== PARENT &&
+          comment.side !== PARENT &&
+          this.patchNumEquals(comment.patch_set, range.basePatchNum)) {
+        return true;
+      }
+      return false;
+    },
+
+    /**
+     * Whether the given comment should be included in the revision side of the
+     * given patch range.
+     * @param {!Object} comment
+     * @param {!Object} range
+     * @return {boolean}
+     */
+    _isInRevisionOfPatchRange(comment, range) {
+      return comment.side !== PARENT &&
+          this.patchNumEquals(comment.patch_set, range.patchNum);
+    },
+
+    /**
+     * Whether the given comment should be included in the given patch range.
+     * @param {!Object} comment
+     * @param {!Object} range
+     * @return {boolean|undefined}
+     */
+    _isInPatchRange(comment, range) {
+      return this._isInBaseOfPatchRange(comment, range) ||
+          this._isInRevisionOfPatchRange(comment, range);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
new file mode 100644
index 0000000..09403a4
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
@@ -0,0 +1,196 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-comment-api</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="./gr-comment-api.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-comment-api></gr-comment-api>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-comment-api tests', () => {
+    const PARENT = 'PARENT';
+
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('loads logged-out', () => {
+      const changeNum = 1234;
+
+      sandbox.stub(element.$.restAPI, 'getLoggedIn')
+          .returns(Promise.resolve(false));
+      sandbox.stub(element.$.restAPI, 'getDiffComments')
+          .returns(Promise.resolve({
+            'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+          }));
+      sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
+          .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+      sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+          .returns(Promise.resolve({}));
+
+      return element.loadAll(changeNum).then(() => {
+        assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
+            changeNum));
+        assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
+            changeNum));
+        assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
+            changeNum));
+        assert.isOk(element._comments);
+        assert.isOk(element._robotComments);
+        assert.deepEqual(element._drafts, {});
+      });
+    });
+
+    test('loads logged-in', () => {
+      const changeNum = 1234;
+
+      sandbox.stub(element.$.restAPI, 'getLoggedIn')
+          .returns(Promise.resolve(true));
+      sandbox.stub(element.$.restAPI, 'getDiffComments')
+          .returns(Promise.resolve({
+            'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+          }));
+      sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
+          .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+      sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+          .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
+
+      return element.loadAll(changeNum).then(() => {
+        assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
+            changeNum));
+        assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
+            changeNum));
+        assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
+            changeNum));
+        assert.isOk(element._comments);
+        assert.isOk(element._robotComments);
+        assert.notDeepEqual(element._drafts, {});
+      });
+    });
+
+    test('_isInBaseOfPatchRange', () => {
+      const comment = {patch_set: 1};
+      const patchRange = {basePatchNum: 1, patchNum: 2};
+      assert.isTrue(element._isInBaseOfPatchRange(comment, patchRange));
+
+      patchRange.basePatchNum = PARENT;
+      assert.isFalse(element._isInBaseOfPatchRange(comment, patchRange));
+
+      comment.side = PARENT;
+      assert.isFalse(element._isInBaseOfPatchRange(comment, patchRange));
+
+      comment.patch_set = 2;
+      assert.isTrue(element._isInBaseOfPatchRange(comment, patchRange));
+    });
+
+    test('_isInRevisionOfPatchRange', () => {
+      const comment = {patch_set: 123};
+      const patchRange = {basePatchNum: 122, patchNum: 124};
+      assert.isFalse(element._isInRevisionOfPatchRange(comment, patchRange));
+
+      patchRange.patchNum = 123;
+      assert.isTrue(element._isInRevisionOfPatchRange(comment, patchRange));
+
+      comment.side = PARENT;
+      assert.isFalse(element._isInRevisionOfPatchRange(comment, patchRange));
+    });
+
+    suite('comment ranges and paths', () => {
+      setup(() => {
+        element._changeNum = 1234;
+        element._drafts = {};
+        element._robotComments = {};
+        element._comments = {
+          'file/one': [
+            {patch_set: 2, side: PARENT},
+            {patch_set: 2},
+          ],
+          'file/two': [
+            {patch_set: 2},
+            {patch_set: 3},
+          ],
+          'file/three': [
+            {patch_set: 2, side: PARENT},
+            {patch_set: 3},
+          ],
+        };
+      });
+
+      test('getPaths', () => {
+        const patchRange = {basePatchNum: 1, patchNum: 4};
+        let paths = element.getPaths(patchRange);
+        assert.equal(Object.keys(paths).length, 0);
+
+        patchRange.basePatchNum = PARENT;
+        patchRange.patchNum = 3;
+        paths = element.getPaths(patchRange);
+        assert.notProperty(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+
+        patchRange.patchNum = 2;
+        paths = element.getPaths(patchRange);
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+      });
+
+      test('getCommentsForPath', () => {
+        const patchRange = {basePatchNum: 1, patchNum: 3};
+        let path = 'file/one';
+        let comments = element.getCommentsForPath(path, patchRange);
+        assert.equal(comments.meta.changeNum, 1234);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 0);
+
+        path = 'file/two';
+        comments = element.getCommentsForPath(path, patchRange);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 1);
+
+        patchRange.basePatchNum = 2;
+        comments = element.getCommentsForPath(path, patchRange);
+        assert.equal(comments.left.length, 1);
+        assert.equal(comments.right.length, 1);
+
+        patchRange.basePatchNum = PARENT;
+        path = 'file/three';
+        comments = element.getCommentsForPath(path, patchRange);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 1);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
new file mode 100644
index 0000000..999c883
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-confirm-delete-comment-dialog">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+      }
+      :host([disabled]) {
+        opacity: .5;
+        pointer-events: none;
+      }
+      .main {
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+      label {
+        cursor: pointer;
+        display: block;
+        width: 100%;
+      }
+      iron-autogrow-textarea {
+        font-family: var(--monospace-font-family);
+        padding: 0;
+        width: 73ch; /* Add a char to account for the border. */
+
+        --iron-autogrow-textarea {
+          border: 1px solid #cdcdcd;
+          box-sizing: border-box;
+          font-family: var(--monospace-font-family);
+        }
+      }
+    </style>
+    <gr-confirm-dialog
+        confirm-label="Delete"
+        on-confirm="_handleConfirmTap"
+        on-cancel="_handleCancelTap">
+      <div class="header">Delete Comment</div>
+      <div class="main">
+        <label for="messageInput">Enter comment delete reason</label>
+        <iron-autogrow-textarea
+            id="messageInput"
+            class="message"
+            autocomplete="on"
+            placeholder="<Insert reasoning here>"
+            bind-value="{{message}}"></iron-autogrow-textarea>
+      </div>
+    </gr-confirm-dialog>
+  </template>
+  <script src="gr-confirm-delete-comment-dialog.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
new file mode 100644
index 0000000..e0eb078
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.js
@@ -0,0 +1,50 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-confirm-delete-comment-dialog',
+
+    /**
+     * Fired when the confirm button is pressed.
+     *
+     * @event confirm
+     */
+
+    /**
+     * Fired when the cancel button is pressed.
+     *
+     * @event cancel
+     */
+
+    properties: {
+      message: String,
+    },
+
+    resetFocus() {
+      this.$.messageInput.textarea.focus();
+    },
+
+    _handleConfirmTap(e) {
+      e.preventDefault();
+      this.fire('confirm', {reason: this.message}, {bubbles: false});
+    },
+
+    _handleCancelTap(e) {
+      e.preventDefault();
+      this.fire('cancel', null, {bubbles: false});
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
index 6b1e59e..ddf3896 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-image.js
@@ -17,9 +17,12 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderImage) { return; }
 
-  function GrDiffBuilderImage(diff, comments, prefs, outputEl, baseImage,
-      revisionImage) {
-    GrDiffBuilderSideBySide.call(this, diff, comments, prefs, outputEl, []);
+  const IMAGE_MIME_PATTERN = /^image\/(bmp|gif|jpeg|jpg|png|tiff|webp)$/;
+
+  function GrDiffBuilderImage(
+      diff, comments, prefs, projectName, outputEl, baseImage, revisionImage) {
+    GrDiffBuilderSideBySide.call(
+        this, diff, comments, prefs, projectName, outputEl, []);
     this._baseImage = baseImage;
     this._revisionImage = revisionImage;
   }
@@ -29,7 +32,7 @@
   GrDiffBuilderImage.prototype.constructor = GrDiffBuilderImage;
 
   GrDiffBuilderImage.prototype.renderDiffImages = function() {
-    var section = this._createElement('tbody', 'image-diff');
+    const section = this._createElement('tbody', 'image-diff');
 
     this._emitImagePair(section);
     this._emitImageLabels(section);
@@ -38,25 +41,30 @@
   };
 
   GrDiffBuilderImage.prototype._emitImagePair = function(section) {
-    var tr = this._createElement('tr');
+    const tr = this._createElement('tr');
 
     tr.appendChild(this._createElement('td'));
-    tr.appendChild(this._createImageCell(this._baseImage, 'left'));
+    tr.appendChild(this._createImageCell(this._baseImage, 'left', section));
 
     tr.appendChild(this._createElement('td'));
-    tr.appendChild(this._createImageCell(this._revisionImage, 'right'));
+    tr.appendChild(this._createImageCell(
+        this._revisionImage, 'right', section));
 
     section.appendChild(tr);
   };
 
-  GrDiffBuilderImage.prototype._createImageCell = function(image, className) {
-    var td = this._createElement('td', className);
-    if (image) {
-      var imageEl = this._createElement('img');
+  GrDiffBuilderImage.prototype._createImageCell = function(image, className,
+      section) {
+    const td = this._createElement('td', className);
+    if (image && IMAGE_MIME_PATTERN.test(image.type)) {
+      const imageEl = this._createElement('img');
+      imageEl.onload = function() {
+        image._height = imageEl.naturalHeight;
+        image._width = imageEl.naturalWidth;
+        this._updateImageLabel(section, className, image);
+      }.bind(this);
       imageEl.src = 'data:' + image.type + ';base64, ' + image.body;
-      image._height = imageEl.naturalHeight;
-      image._width = imageEl.naturalWidth;
-      imageEl.addEventListener('error', function(e) {
+      imageEl.addEventListener('error', () => {
         imageEl.remove();
         td.textContent = '[Image failed to load]';
       });
@@ -65,20 +73,61 @@
     return td;
   };
 
+  GrDiffBuilderImage.prototype._updateImageLabel = function(section, className,
+      image) {
+    const label = Polymer.dom(section)
+        .querySelector('.' + className + ' span.label');
+    this._setLabelText(label, image);
+  };
+
+  GrDiffBuilderImage.prototype._setLabelText = function(label, image) {
+    label.textContent = this._getImageLabel(image);
+  };
+
   GrDiffBuilderImage.prototype._emitImageLabels = function(section) {
-    var tr = this._createElement('tr');
+    const tr = this._createElement('tr');
+
+    let addNamesInLabel = false;
+
+    if (this._baseImage && this._revisionImage &&
+        this._baseImage._name !== this._revisionImage._name) {
+      addNamesInLabel = true;
+    }
 
     tr.appendChild(this._createElement('td'));
-    var td = this._createElement('td', 'left');
-    var label = this._createElement('label');
-    label.textContent = this._getImageLabel(this._baseImage);
+    let td = this._createElement('td', 'left');
+    let label = this._createElement('label');
+    let nameSpan;
+    let labelSpan = this._createElement('span', 'label');
+
+    if (addNamesInLabel) {
+      nameSpan = this._createElement('span', 'name');
+      nameSpan.textContent = this._baseImage._name;
+      label.appendChild(nameSpan);
+      label.appendChild(this._createElement('br'));
+    }
+
+    this._setLabelText(labelSpan, this._baseImage, addNamesInLabel);
+
+    label.appendChild(labelSpan);
     td.appendChild(label);
     tr.appendChild(td);
 
     tr.appendChild(this._createElement('td'));
     td = this._createElement('td', 'right');
     label = this._createElement('label');
-    label.textContent = this._getImageLabel(this._revisionImage);
+    labelSpan = this._createElement('span', 'label');
+
+    if (addNamesInLabel) {
+      nameSpan = this._createElement('span', 'name');
+      nameSpan.textContent = this._revisionImage._name;
+      label.appendChild(nameSpan);
+      label.appendChild(this._createElement('br'));
+    }
+
+    this._setLabelText(labelSpan, this._revisionImage, addNamesInLabel);
+
+    label.appendChild(labelSpan);
     td.appendChild(label);
     tr.appendChild(td);
 
@@ -87,7 +136,7 @@
 
   GrDiffBuilderImage.prototype._getImageLabel = function(image) {
     if (image) {
-      var type = image.type || image._expectedType;
+      const type = image.type || image._expectedType;
       if (image._width && image._height) {
         return image._width + '⨉' + image._height + ' ' + type;
       } else {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
index cbc00b8..06989d2 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-side-by-side.js
@@ -17,20 +17,25 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderSideBySide) { return; }
 
-  function GrDiffBuilderSideBySide(diff, comments, prefs, outputEl, layers) {
-    GrDiffBuilder.call(this, diff, comments, prefs, outputEl, layers);
+  function GrDiffBuilderSideBySide(
+      diff, comments, prefs, projectName, outputEl, layers) {
+    GrDiffBuilder.call(
+        this, diff, comments, prefs, projectName, outputEl, layers);
   }
   GrDiffBuilderSideBySide.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderSideBySide.prototype.constructor = GrDiffBuilderSideBySide;
 
   GrDiffBuilderSideBySide.prototype.buildSectionElement = function(group) {
-    var sectionEl = this._createElement('tbody', 'section');
+    const sectionEl = this._createElement('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (this._isTotal(group)) {
       sectionEl.classList.add('total');
     }
-    var pairs = group.getSideBySidePairs();
-    for (var i = 0; i < pairs.length; i++) {
+    if (group.dueToRebase) {
+      sectionEl.classList.add('dueToRebase');
+    }
+    const pairs = group.getSideBySidePairs();
+    for (let i = 0; i < pairs.length; i++) {
       sectionEl.appendChild(this._createRow(sectionEl, pairs[i].left,
           pairs[i].right));
     }
@@ -38,11 +43,15 @@
   };
 
   GrDiffBuilderSideBySide.prototype.addColumns = function(outputEl, fontSize) {
-    var width = fontSize * 4;
-    var colgroup = document.createElement('colgroup');
+    const width = fontSize * 4;
+    const colgroup = document.createElement('colgroup');
+
+    // Add the blame column.
+    let col = this._createElement('col', 'blame');
+    colgroup.appendChild(col);
 
     // Add left-side line number.
-    var col = document.createElement('col');
+    col = document.createElement('col');
     col.setAttribute('width', width);
     colgroup.appendChild(col);
 
@@ -62,10 +71,13 @@
 
   GrDiffBuilderSideBySide.prototype._createRow = function(section, leftLine,
       rightLine) {
-    var row = this._createElement('tr');
+    const row = this._createElement('tr');
     row.classList.add('diff-row', 'side-by-side');
     row.setAttribute('left-type', leftLine.type);
     row.setAttribute('right-type', rightLine.type);
+    row.tabIndex = -1;
+
+    row.appendChild(this._createBlameCell(leftLine));
 
     this._appendPair(section, row, leftLine, leftLine.beforeNumber,
         GrDiffBuilder.Side.LEFT);
@@ -76,15 +88,15 @@
 
   GrDiffBuilderSideBySide.prototype._appendPair = function(section, row, line,
       lineNumber, side) {
-    var lineEl = this._createLineEl(line, lineNumber, line.type, side);
+    const lineEl = this._createLineEl(line, lineNumber, line.type, side);
     lineEl.classList.add(side);
     row.appendChild(lineEl);
-    var action = this._createContextControl(section, line);
+    const action = this._createContextControl(section, line);
     if (action) {
       row.appendChild(action);
     } else {
-      var textEl = this._createTextEl(line, side);
-      var threadGroupEl = this._commentThreadGroupForLine(line, side);
+      const textEl = this._createTextEl(line, side);
+      const threadGroupEl = this._commentThreadGroupForLine(line, side);
       if (threadGroupEl) {
         textEl.appendChild(threadGroupEl);
       }
@@ -94,7 +106,7 @@
 
   GrDiffBuilderSideBySide.prototype._getNextContentOnSide = function(
       content, side) {
-    var tr = content.parentElement.parentElement;
+    let tr = content.parentElement.parentElement;
     while (tr = tr.nextSibling) {
       content = tr.querySelector(
           'td.content .contentText[data-side="' + side + '"]');
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
index 55a6bea..a033c7b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder-unified.js
@@ -17,31 +17,40 @@
   // Prevent redefinition.
   if (window.GrDiffBuilderUnified) { return; }
 
-  function GrDiffBuilderUnified(diff, comments, prefs, outputEl, layers) {
-    GrDiffBuilder.call(this, diff, comments, prefs, outputEl, layers);
+  function GrDiffBuilderUnified(
+      diff, comments, prefs, projectName, outputEl, layers) {
+    GrDiffBuilder.call(
+        this, diff, comments, prefs, projectName, outputEl, layers);
   }
   GrDiffBuilderUnified.prototype = Object.create(GrDiffBuilder.prototype);
   GrDiffBuilderUnified.prototype.constructor = GrDiffBuilderUnified;
 
   GrDiffBuilderUnified.prototype.buildSectionElement = function(group) {
-    var sectionEl = this._createElement('tbody', 'section');
+    const sectionEl = this._createElement('tbody', 'section');
     sectionEl.classList.add(group.type);
     if (this._isTotal(group)) {
       sectionEl.classList.add('total');
     }
+    if (group.dueToRebase) {
+      sectionEl.classList.add('dueToRebase');
+    }
 
-    for (var i = 0; i < group.lines.length; ++i) {
+    for (let i = 0; i < group.lines.length; ++i) {
       sectionEl.appendChild(this._createRow(sectionEl, group.lines[i]));
     }
     return sectionEl;
   };
 
   GrDiffBuilderUnified.prototype.addColumns = function(outputEl, fontSize) {
-    var width = fontSize * 4;
-    var colgroup = document.createElement('colgroup');
+    const width = fontSize * 4;
+    const colgroup = document.createElement('colgroup');
+
+    // Add the blame column.
+    let col = this._createElement('col', 'blame');
+    colgroup.appendChild(col);
 
     // Add left-side line number.
-    var col = document.createElement('col');
+    col = document.createElement('col');
     col.setAttribute('width', width);
     colgroup.appendChild(col);
 
@@ -57,8 +66,10 @@
   };
 
   GrDiffBuilderUnified.prototype._createRow = function(section, line) {
-    var row = this._createElement('tr', line.type);
-    var lineEl = this._createLineEl(line, line.beforeNumber,
+    const row = this._createElement('tr', line.type);
+    row.appendChild(this._createBlameCell(line));
+
+    let lineEl = this._createLineEl(line, line.beforeNumber,
         GrDiffLine.Type.REMOVE);
     lineEl.classList.add('left');
     row.appendChild(lineEl);
@@ -67,13 +78,14 @@
     lineEl.classList.add('right');
     row.appendChild(lineEl);
     row.classList.add('diff-row', 'unified');
+    row.tabIndex = -1;
 
-    var action = this._createContextControl(section, line);
+    const action = this._createContextControl(section, line);
     if (action) {
       row.appendChild(action);
     } else {
-      var textEl = this._createTextEl(line);
-      var threadGroupEl = this._commentThreadGroupForLine(line);
+      const textEl = this._createTextEl(line);
+      const threadGroupEl = this._commentThreadGroupForLine(line);
       if (threadGroupEl) {
         textEl.appendChild(threadGroupEl);
       }
@@ -84,7 +96,7 @@
 
   GrDiffBuilderUnified.prototype._getNextContentOnSide = function(
       content, side) {
-    var tr = content.parentElement.parentElement;
+    let tr = content.parentElement.parentElement;
     while (tr = tr.nextSibling) {
       if (tr.classList.contains('both') || (
           (side === 'left' && tr.classList.contains('remove')) ||
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index a936934..dd18b65 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -48,12 +48,12 @@
     (function() {
       'use strict';
 
-      var DiffViewMode = {
+      const DiffViewMode = {
         SIDE_BY_SIDE: 'SIDE_BY_SIDE',
         UNIFIED: 'UNIFIED_DIFF',
       };
 
-      var TimingLabel = {
+      const TimingLabel = {
         TOTAL: 'Diff Total Render',
         CONTENT: 'Diff Content Render',
         SYNTAX: 'Diff Syntax Render',
@@ -61,9 +61,9 @@
 
       // If any line of the diff is more than the character limit, then disable
       // syntax highlighting for the entire file.
-      var SYNTAX_MAX_LINE_LENGTH = 500;
+      const SYNTAX_MAX_LINE_LENGTH = 500;
 
-      var TRAILING_WHITESPACE_PATTERN = /\s+$/;
+      const TRAILING_WHITESPACE_PATTERN = /\s+$/;
 
       Polymer({
         is: 'gr-diff-builder',
@@ -94,6 +94,7 @@
           isImageDiff: Boolean,
           baseImage: Object,
           revisionImage: Object,
+          projectName: String,
           _builder: Object,
           _groups: Array,
           _layers: Array,
@@ -108,7 +109,7 @@
           '_groupsChanged(_groups.splices)',
         ],
 
-        attached: function() {
+        attached() {
           // Setup annotation layers.
           this._layers = [
             this._createTrailingWhitespaceLayer(),
@@ -118,19 +119,18 @@
             this.$.rangeLayer,
           ];
 
-          this.async(function() {
+          this.async(() => {
             this._preRenderThread();
           });
         },
 
-        render: function(comments, prefs) {
+        render(comments, prefs) {
           this.$.syntaxLayer.enabled = prefs.syntax_highlighting;
           this._showTabs = !!prefs.show_tabs;
           this._showTrailingWhitespace = !!prefs.show_whitespace_errors;
 
-          // Stop the processor (if it's running).
-          this.$.processor.cancel();
-          this.$.syntaxLayer.cancel();
+          // Stop the processor and syntax layer (if they're running).
+          this.cancel();
 
           this._builder = this._getDiffBuilder(this.diff, comments, prefs);
 
@@ -140,33 +140,35 @@
           this._clearDiffContent();
           this._builder.addColumns(this.diffElement, prefs.font_size);
 
-          var reporting = this.$.reporting;
+          const reporting = this.$.reporting;
 
           reporting.time(TimingLabel.TOTAL);
           reporting.time(TimingLabel.CONTENT);
           this.dispatchEvent(new CustomEvent('render-start', {bubbles: true}));
-          return this.$.processor.process(this.diff.content).then(function() {
-            if (this.isImageDiff) {
-              this._builder.renderDiffImages();
-            }
-            this.dispatchEvent(new CustomEvent('render-content',
-                {bubbles: true}));
+          return this.$.processor.process(this.diff.content, this.isImageDiff)
+              .then(() => {
+                if (this.isImageDiff) {
+                  this._builder.renderDiffImages();
+                }
+                this.dispatchEvent(new CustomEvent('render-content',
+                    {bubbles: true}));
 
-            if (this._anyLineTooLong()) {
-              this.$.syntaxLayer.enabled = false;
-            }
+                if (this._anyLineTooLong()) {
+                  this.$.syntaxLayer.enabled = false;
+                }
 
-            reporting.timeEnd(TimingLabel.CONTENT);
-            reporting.time(TimingLabel.SYNTAX);
-            return this.$.syntaxLayer.process().then(function() {
-              reporting.timeEnd(TimingLabel.SYNTAX);
-              reporting.timeEnd(TimingLabel.TOTAL);
-              this.dispatchEvent(new CustomEvent('render', {bubbles: true}));
-            }.bind(this));
-          }.bind(this));
+                reporting.timeEnd(TimingLabel.CONTENT);
+                reporting.time(TimingLabel.SYNTAX);
+                return this.$.syntaxLayer.process().then(() => {
+                  reporting.timeEnd(TimingLabel.SYNTAX);
+                  reporting.timeEnd(TimingLabel.TOTAL);
+                  this.dispatchEvent(
+                      new CustomEvent('render', {bubbles: true}));
+                });
+              });
         },
 
-        getLineElByChild: function(node) {
+        getLineElByChild(node) {
           while (node) {
             if (node instanceof Element) {
               if (node.classList.contains('lineNum')) {
@@ -181,154 +183,160 @@
           return null;
         },
 
-        getLineNumberByChild: function(node) {
-          var lineEl = this.getLineElByChild(node);
+        getLineNumberByChild(node) {
+          const lineEl = this.getLineElByChild(node);
           return lineEl ?
-              parseInt(lineEl.getAttribute('data-value'), 10) : null;
+              parseInt(lineEl.getAttribute('data-value'), 10) :
+              null;
         },
 
-        getContentByLine: function(lineNumber, opt_side, opt_root) {
+        getContentByLine(lineNumber, opt_side, opt_root) {
           return this._builder.getContentByLine(lineNumber, opt_side, opt_root);
         },
 
-        getContentByLineEl: function(lineEl) {
-          var root = Polymer.dom(lineEl.parentElement);
-          var side = this.getSideByLineEl(lineEl);
-          var line = lineEl.getAttribute('data-value');
+        getContentByLineEl(lineEl) {
+          const root = Polymer.dom(lineEl.parentElement);
+          const side = this.getSideByLineEl(lineEl);
+          const line = lineEl.getAttribute('data-value');
           return this.getContentByLine(line, side, root);
         },
 
-        getLineElByNumber: function(lineNumber, opt_side) {
-          var sideSelector = !!opt_side ? ('.' + opt_side) : '';
+        getLineElByNumber(lineNumber, opt_side) {
+          const sideSelector = opt_side ? ('.' + opt_side) : '';
           return this.diffElement.querySelector(
               '.lineNum[data-value="' + lineNumber + '"]' + sideSelector);
         },
 
-        getContentsByLineRange: function(startLine, endLine, opt_side) {
-          var result = [];
+        getContentsByLineRange(startLine, endLine, opt_side) {
+          const result = [];
           this._builder.findLinesByRange(startLine, endLine, opt_side, null,
               result);
           return result;
         },
 
-        getSideByLineEl: function(lineEl) {
+        getSideByLineEl(lineEl) {
           return lineEl.classList.contains(GrDiffBuilder.Side.RIGHT) ?
-              GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
+          GrDiffBuilder.Side.RIGHT : GrDiffBuilder.Side.LEFT;
         },
 
-        createCommentThreadGroup: function(changeNum, patchNum, path,
-            isOnParent, commentSide, projectConfig) {
+        createCommentThreadGroup(changeNum, patchNum, path,
+            isOnParent, commentSide) {
           return this._builder.createCommentThreadGroup(changeNum, patchNum,
-              path, isOnParent, commentSide, projectConfig);
+              path, isOnParent, commentSide);
         },
 
-        emitGroup: function(group, sectionEl) {
+        emitGroup(group, sectionEl) {
           this._builder.emitGroup(group, sectionEl);
         },
 
-        showContext: function(newGroups, sectionEl) {
-          var groups = this._builder.groups;
-          // TODO(viktard): Polyfill findIndex for IE10.
-          var contextIndex = groups.findIndex(function(group) {
-            return group.element == sectionEl;
-          });
-          groups.splice.apply(groups, [contextIndex, 1].concat(newGroups));
+        showContext(newGroups, sectionEl) {
+          const groups = this._builder.groups;
 
-          newGroups.forEach(function(newGroup) {
+          const contextIndex = groups.findIndex(group =>
+            group.element === sectionEl
+          );
+          groups.splice(...[contextIndex, 1].concat(newGroups));
+
+          for (const newGroup of newGroups) {
             this._builder.emitGroup(newGroup, sectionEl);
-          }, this);
+          }
           sectionEl.parentNode.removeChild(sectionEl);
 
-          this.async(function() {
-            this.fire('render');
-          }, 1);
+          this.async(() => this.fire('render-content'), 1);
         },
 
-        _getDiffBuilder: function(diff, comments, prefs) {
+        cancel() {
+          this.$.processor.cancel();
+          this.$.syntaxLayer.cancel();
+        },
+
+        _getDiffBuilder(diff, comments, prefs) {
           if (this.isImageDiff) {
             return new GrDiffBuilderImage(diff, comments, prefs,
-                this.diffElement, this.baseImage, this.revisionImage);
+                this.projectName, this.diffElement, this.baseImage,
+                this.revisionImage);
           } else if (this.viewMode === DiffViewMode.SIDE_BY_SIDE) {
-            return new GrDiffBuilderSideBySide(
-                diff, comments, prefs, this.diffElement, this._layers);
+            return new GrDiffBuilderSideBySide(diff, comments, prefs,
+                this.projectName, this.diffElement, this._layers);
           } else if (this.viewMode === DiffViewMode.UNIFIED) {
-            return new GrDiffBuilderUnified(
-                diff, comments, prefs, this.diffElement, this._layers);
+            return new GrDiffBuilderUnified(diff, comments, prefs,
+                this.projectName, this.diffElement, this._layers);
           }
           throw Error('Unsupported diff view mode: ' + this.viewMode);
         },
 
-        _clearDiffContent: function() {
+        _clearDiffContent() {
           this.diffElement.innerHTML = null;
         },
 
-        _getCommentLocations: function(comments) {
-          var result = {
+        _getCommentLocations(comments) {
+          const result = {
             left: {},
             right: {},
           };
-          for (var side in comments) {
+          for (const side in comments) {
             if (side !== GrDiffBuilder.Side.LEFT &&
                 side !== GrDiffBuilder.Side.RIGHT) {
               continue;
             }
-            comments[side].forEach(function(c) {
+            for (const c of comments[side]) {
               result[side][c.line || GrDiffLine.FILE] = true;
-            });
+            }
           }
           return result;
         },
 
-        _groupsChanged: function(changeRecord) {
+        _groupsChanged(changeRecord) {
           if (!changeRecord) { return; }
-          changeRecord.indexSplices.forEach(function(splice) {
-            var group;
-            for (var i = 0; i < splice.addedCount; i++) {
+          for (const splice of changeRecord.indexSplices) {
+            let group;
+            for (let i = 0; i < splice.addedCount; i++) {
               group = splice.object[splice.index + i];
               this._builder.groups.push(group);
               this._builder.emitGroup(group);
             }
-          }, this);
+          }
         },
 
-        _createIntralineLayer: function() {
+        _createIntralineLayer() {
           return {
             // Take a DIV.contentText element and a line object with intraline
             // differences to highlight and apply them to the element as
             // annotations.
-            annotate: function(el, line) {
-              var HL_CLASS = 'style-scope gr-diff intraline';
-              line.highlights.forEach(function(highlight) {
+            annotate(el, line) {
+              const HL_CLASS = 'style-scope gr-diff intraline';
+              for (const highlight of line.highlights) {
                 // The start and end indices could be the same if a highlight is
                 // meant to start at the end of a line and continue onto the
                 // next one. Ignore it.
-                if (highlight.startIndex === highlight.endIndex) { return; }
+                if (highlight.startIndex === highlight.endIndex) { continue; }
 
                 // If endIndex isn't present, continue to the end of the line.
-                var endIndex = highlight.endIndex === undefined ?
-                    line.text.length : highlight.endIndex;
+                const endIndex = highlight.endIndex === undefined ?
+                    line.text.length :
+                    highlight.endIndex;
 
                 GrAnnotation.annotateElement(
                     el,
                     highlight.startIndex,
                     endIndex - highlight.startIndex,
                     HL_CLASS);
-              });
+              }
             },
           };
         },
 
-        _createTabIndicatorLayer: function() {
-          var show = function() { return this._showTabs; }.bind(this);
+        _createTabIndicatorLayer() {
+          const show = () => this._showTabs;
           return {
-            annotate: function(el, line) {
+            annotate(el, line) {
               // If visible tabs are disabled, do nothing.
               if (!show()) { return; }
 
               // Find and annotate the locations of tabs.
-              var split = line.text.split('\t');
+              const split = line.text.split('\t');
               if (!split) { return; }
-              for (var i = 0, pos = 0; i < split.length - 1; i++) {
+              for (let i = 0, pos = 0; i < split.length - 1; i++) {
                 // Skip forward by the length of the content
                 pos += split[i].length;
 
@@ -342,22 +350,22 @@
           };
         },
 
-        _createTrailingWhitespaceLayer: function() {
-          var show = function() {
+        _createTrailingWhitespaceLayer() {
+          const show = function() {
             return this._showTrailingWhitespace;
           }.bind(this);
 
           return {
-            annotate: function(el, line) {
+            annotate(el, line) {
               if (!show()) { return; }
 
-              var match = line.text.match(TRAILING_WHITESPACE_PATTERN);
+              const match = line.text.match(TRAILING_WHITESPACE_PATTERN);
               if (match) {
                 // Normalize string positions in case there is unicode before or
                 // within the match.
-                var index = GrAnnotation.getStringLength(
+                const index = GrAnnotation.getStringLength(
                     line.text.substr(0, match.index));
-                var length = GrAnnotation.getStringLength(match[0]);
+                const length = GrAnnotation.getStringLength(match[0]);
                 GrAnnotation.annotateElement(el, index, length,
                     'style-scope gr-diff trailing-whitespace');
               }
@@ -374,27 +382,32 @@
          * already exist and the user's comment will be quick to load.
          * @see https://gerrit-review.googlesource.com/c/82213/
          */
-        _preRenderThread: function() {
-          var thread = document.createElement('gr-diff-comment-thread');
+        _preRenderThread() {
+          const thread = document.createElement('gr-diff-comment-thread');
           thread.setAttribute('hidden', true);
           thread.addDraft();
-          var parent = Polymer.dom(this.root);
+          const parent = Polymer.dom(this.root);
           parent.appendChild(thread);
           Polymer.dom.flush();
           parent.removeChild(thread);
         },
 
         /**
-         * @return {Boolean} whether any of the lines in _groups are longer
+         * @return {boolean} whether any of the lines in _groups are longer
          * than SYNTAX_MAX_LINE_LENGTH.
          */
-        _anyLineTooLong: function() {
-          return this._groups.reduce(function(acc, group) {
-            return acc || group.lines.reduce(function(acc, line) {
+        _anyLineTooLong() {
+          return this._groups.reduce((acc, group) => {
+            return acc || group.lines.reduce((acc, line) => {
               return acc || line.text.length >= SYNTAX_MAX_LINE_LENGTH;
             }, false);
           }, false);
         },
+
+        setBlame(blame) {
+          if (!this._builder || !blame) { return; }
+          this._builder.setBlame(blame);
+        },
       });
     })();
   </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
index 214454a..e199a75 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.js
@@ -14,8 +14,8 @@
 (function(window, GrDiffGroup, GrDiffLine) {
   'use strict';
 
-  var HTML_ENTITY_PATTERN = /[&<>"'`\/]/g;
-  var HTML_ENTITY_MAP = {
+  const HTML_ENTITY_PATTERN = /[&<>"'`\/]/g;
+  const HTML_ENTITY_MAP = {
     '&': '&amp;',
     '<': '&lt;',
     '>': '&gt;',
@@ -28,22 +28,24 @@
   // Prevent redefinition.
   if (window.GrDiffBuilder) { return; }
 
-  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+  const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  function GrDiffBuilder(diff, comments, prefs, outputEl, layers) {
+  function GrDiffBuilder(diff, comments, prefs, projectName, outputEl, layers) {
     this._diff = diff;
     this._comments = comments;
     this._prefs = prefs;
+    this._projectName = projectName;
     this._outputEl = outputEl;
     this.groups = [];
+    this._blameInfo = null;
 
     this.layers = layers || [];
 
-    this.layers.forEach(function(layer) {
+    for (const layer of this.layers) {
       if (layer.addListener) {
         layer.addListener(this._handleLayerUpdate.bind(this));
       }
-    }.bind(this));
+    }
   }
 
   GrDiffBuilder.LESS_THAN_CODE = '<'.charCodeAt(0);
@@ -76,7 +78,7 @@
     ALL: 'all',
   };
 
-  var PARTIAL_CONTEXT_AMOUNT = 10;
+  const PARTIAL_CONTEXT_AMOUNT = 10;
 
   /**
    * Abstract method
@@ -96,16 +98,16 @@
   };
 
   GrDiffBuilder.prototype.emitGroup = function(group, opt_beforeSection) {
-    var element = this.buildSectionElement(group);
+    const element = this.buildSectionElement(group);
     this._outputEl.insertBefore(element, opt_beforeSection);
     group.element = element;
   };
 
   GrDiffBuilder.prototype.renderSection = function(element) {
-    for (var i = 0; i < this.groups.length; i++) {
-      var group = this.groups[i];
+    for (let i = 0; i < this.groups.length; i++) {
+      const group = this.groups[i];
       if (group.element === element) {
-        var newElement = this.buildSectionElement(group);
+        const newElement = this.buildSectionElement(group);
         group.element.parentElement.replaceChild(newElement, group.element);
         group.element = newElement;
         break;
@@ -115,14 +117,14 @@
 
   GrDiffBuilder.prototype.getGroupsByLineRange = function(
       startLine, endLine, opt_side) {
-    var groups = [];
-    for (var i = 0; i < this.groups.length; i++) {
-      var group = this.groups[i];
+    const groups = [];
+    for (let i = 0; i < this.groups.length; i++) {
+      const group = this.groups[i];
       if (group.lines.length === 0) {
         continue;
       }
-      var groupStartLine = 0;
-      var groupEndLine = 0;
+      let groupStartLine = 0;
+      let groupEndLine = 0;
       if (opt_side) {
         groupStartLine = group.lineRange[opt_side].start;
         groupEndLine = group.lineRange[opt_side].end;
@@ -131,7 +133,7 @@
       if (groupStartLine === 0) { // Line was removed or added.
         groupStartLine = groupEndLine;
       }
-      if (groupEndLine === 0) {  // Line was removed or added.
+      if (groupEndLine === 0) { // Line was removed or added.
         groupEndLine = groupStartLine;
       }
       if (startLine <= groupEndLine && endLine >= groupStartLine) {
@@ -143,8 +145,8 @@
 
   GrDiffBuilder.prototype.getContentByLine = function(lineNumber, opt_side,
       opt_root) {
-    var root = Polymer.dom(opt_root || this._outputEl);
-    var sideSelector = !!opt_side ? ('.' + opt_side) : '';
+    const root = Polymer.dom(opt_root || this._outputEl);
+    const sideSelector = opt_side ? ('.' + opt_side) : '';
     return root.querySelector('td.lineNum[data-value="' + lineNumber +
         '"]' + sideSelector + ' ~ td.content .contentText');
   };
@@ -152,27 +154,27 @@
   /**
    * Find line elements or line objects by a range of line numbers and a side.
    *
-   * @param {Number} start The first line number
-   * @param {Number} end The last line number
-   * @param {String} opt_side The side of the range. Either 'left' or 'right'.
-   * @param {Array<GrDiffLine>} out_lines The output list of line objects. Use
+   * @param {number} start The first line number
+   * @param {number} end The last line number
+   * @param {string} opt_side The side of the range. Either 'left' or 'right'.
+   * @param {!Array<GrDiffLine>} out_lines The output list of line objects. Use
    *     null if not desired.
-   * @param  {Array<HTMLElement>} out_elements The output list of line elements.
+   * @param  {!Array<HTMLElement>} out_elements The output list of line elements.
    *     Use null if not desired.
    */
   GrDiffBuilder.prototype.findLinesByRange = function(start, end, opt_side,
       out_lines, out_elements) {
-    var groups = this.getGroupsByLineRange(start, end, opt_side);
-    groups.forEach(function(group) {
-      var content = null;
-      group.lines.forEach(function(line) {
+    const groups = this.getGroupsByLineRange(start, end, opt_side);
+    for (const group of groups) {
+      let content = null;
+      for (const line of group.lines) {
         if ((opt_side === 'left' && line.type === GrDiffLine.Type.ADD) ||
             (opt_side === 'right' && line.type === GrDiffLine.Type.REMOVE)) {
-          return;
+          continue;
         }
-        var lineNumber = opt_side === 'left' ?
+        const lineNumber = opt_side === 'left' ?
             line.beforeNumber : line.afterNumber;
-        if (lineNumber < start || lineNumber > end) { return; }
+        if (lineNumber < start || lineNumber > end) { continue; }
 
         if (out_lines) { out_lines.push(line); }
         if (out_elements) {
@@ -184,8 +186,8 @@
           }
           if (content) { out_elements.push(content); }
         }
-      }.bind(this));
-    }.bind(this));
+      }
+    }
   };
 
   /**
@@ -193,12 +195,12 @@
    * diff content.
    */
   GrDiffBuilder.prototype._renderContentByRange = function(start, end, side) {
-    var lines = [];
-    var elements = [];
-    var line;
-    var el;
+    const lines = [];
+    const elements = [];
+    let line;
+    let el;
     this.findLinesByRange(start, end, side, lines, elements);
-    for (var i = 0; i < lines.length; i++) {
+    for (let i = 0; i < lines.length; i++) {
       line = lines[i];
       el = elements[i];
       el.parentElement.replaceChild(this._createTextEl(line, side).firstChild,
@@ -209,7 +211,7 @@
   GrDiffBuilder.prototype.getSectionsByLineRange = function(
       startLine, endLine, opt_side) {
     return this.getGroupsByLineRange(startLine, endLine, opt_side).map(
-        function(group) { return group.element; });
+        group => { return group.element; });
   };
 
   GrDiffBuilder.prototype._commentIsAtLineNum = function(side, lineNum) {
@@ -219,15 +221,15 @@
   // TODO(wyatta): Move this completely into the processor.
   GrDiffBuilder.prototype._insertContextGroups = function(groups, lines,
       hiddenRange) {
-    var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
-    var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
-    var linesAfterCtx = lines.slice(hiddenRange[1]);
+    const linesBeforeCtx = lines.slice(0, hiddenRange[0]);
+    const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
+    const linesAfterCtx = lines.slice(hiddenRange[1]);
 
     if (linesBeforeCtx.length > 0) {
       groups.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
     }
 
-    var ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+    const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
     ctxLine.contextGroup =
         new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
     groups.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
@@ -243,8 +245,8 @@
       return null;
     }
 
-    var td = this._createElement('td');
-    var showPartialLinks =
+    const td = this._createElement('td');
+    const showPartialLinks =
         line.contextGroup.lines.length > PARTIAL_CONTEXT_AMOUNT;
 
     if (showPartialLinks) {
@@ -266,14 +268,14 @@
   };
 
   GrDiffBuilder.prototype._createContextButton = function(type, section, line) {
-    var contextLines = line.contextGroup.lines;
-    var context = PARTIAL_CONTEXT_AMOUNT;
+    const contextLines = line.contextGroup.lines;
+    const context = PARTIAL_CONTEXT_AMOUNT;
 
-    var button = this._createElement('gr-button', 'showContext');
+    const button = this._createElement('gr-button', 'showContext');
     button.setAttribute('link', true);
 
-    var text;
-    var groups = []; // The groups that replace this one if tapped.
+    let text;
+    const groups = []; // The groups that replace this one if tapped.
 
     if (type === GrDiffBuilder.ContextButtonType.ALL) {
       text = 'Show ' + contextLines.length + ' common line';
@@ -291,10 +293,10 @@
 
     button.textContent = text;
 
-    button.addEventListener('tap', function(e) {
+    button.addEventListener('tap', e => {
       e.detail = {
-        groups: groups,
-        section: section,
+        groups,
+        section,
       };
       // Let it bubble up the DOM tree.
     });
@@ -310,15 +312,15 @@
                (c.line === undefined && lineNum === GrDiffLine.FILE);
       };
     }
-    var leftComments =
+    const leftComments =
         comments[GrDiffBuilder.Side.LEFT].filter(byLineNum(line.beforeNumber));
-    var rightComments =
+    const rightComments =
         comments[GrDiffBuilder.Side.RIGHT].filter(byLineNum(line.afterNumber));
 
-    leftComments.forEach(function(c) { c.__commentSide = 'left'; });
-    rightComments.forEach(function(c) { c.__commentSide = 'right'; });
+    leftComments.forEach(c => { c.__commentSide = 'left'; });
+    rightComments.forEach(c => { c.__commentSide = 'right'; });
 
-    var result;
+    let result;
 
     switch (opt_side) {
       case GrDiffBuilder.Side.LEFT:
@@ -336,41 +338,41 @@
   };
 
   GrDiffBuilder.prototype.createCommentThreadGroup = function(changeNum,
-      patchNum, path, isOnParent, projectConfig, range) {
-    var threadGroupEl =
+      patchNum, path, isOnParent, range) {
+    const threadGroupEl =
         document.createElement('gr-diff-comment-thread-group');
     threadGroupEl.changeNum = changeNum;
     threadGroupEl.patchForNewThreads = patchNum;
     threadGroupEl.path = path;
     threadGroupEl.isOnParent = isOnParent;
-    threadGroupEl.projectConfig = projectConfig;
+    threadGroupEl.projectName = this._projectName;
     threadGroupEl.range = range;
     return threadGroupEl;
   };
 
-  GrDiffBuilder.prototype._commentThreadGroupForLine =
-      function(line, opt_side) {
-    var comments = this._getCommentsForLine(this._comments, line, opt_side);
+  GrDiffBuilder.prototype._commentThreadGroupForLine = function(line,
+      opt_side) {
+    const comments =
+        this._getCommentsForLine(this._comments, line, opt_side);
     if (!comments || comments.length === 0) {
       return null;
     }
 
-    var patchNum = this._comments.meta.patchRange.patchNum;
-    var isOnParent = comments[0].side === 'PARENT' || false;
+    let patchNum = this._comments.meta.patchRange.patchNum;
+    let isOnParent = comments[0].side === 'PARENT' || false;
     if (line.type === GrDiffLine.Type.REMOVE ||
-        opt_side === GrDiffBuilder.Side.LEFT) {
+    opt_side === GrDiffBuilder.Side.LEFT) {
       if (this._comments.meta.patchRange.basePatchNum === 'PARENT') {
         isOnParent = true;
       } else {
         patchNum = this._comments.meta.patchRange.basePatchNum;
       }
     }
-    var threadGroupEl = this.createCommentThreadGroup(
+    const threadGroupEl = this.createCommentThreadGroup(
         this._comments.meta.changeNum,
         patchNum,
         this._comments.meta.path,
-        isOnParent,
-        this._comments.meta.projectConfig);
+        isOnParent);
     threadGroupEl.comments = comments;
     if (opt_side) {
       threadGroupEl.setAttribute('data-side', opt_side);
@@ -380,10 +382,17 @@
 
   GrDiffBuilder.prototype._createLineEl = function(line, number, type,
       opt_class) {
-    var td = this._createElement('td');
+    const td = this._createElement('td');
     if (opt_class) {
       td.classList.add(opt_class);
     }
+
+    if (line.type === GrDiffLine.Type.REMOVE) {
+      td.setAttribute('aria-label', `${number} removed`);
+    } else if (line.type === GrDiffLine.Type.ADD) {
+      td.setAttribute('aria-label', `${number} added`);
+    }
+
     if (line.type === GrDiffLine.Type.BLANK) {
       return td;
     } else if (line.type === GrDiffLine.Type.CONTEXT_CONTROL) {
@@ -397,22 +406,21 @@
   };
 
   GrDiffBuilder.prototype._createTextEl = function(line, opt_side) {
-    var td = this._createElement('td');
-    var text = line.text;
+    const td = this._createElement('td');
+    const text = line.text;
     if (line.type !== GrDiffLine.Type.BLANK) {
       td.classList.add('content');
     }
     td.classList.add(line.type);
-    var html = this._escapeHTML(text);
+    let html = this._escapeHTML(text);
     html = this._addTabWrappers(html, this._prefs.tab_size);
-
     if (!this._prefs.line_wrapping &&
         this._textLength(text, this._prefs.tab_size) >
         this._prefs.line_length) {
       html = this._addNewlines(text, html);
     }
 
-    var contentText = this._createElement('div', 'contentText');
+    const contentText = this._createElement('div', 'contentText');
     if (opt_side) {
       contentText.setAttribute('data-side', opt_side);
     }
@@ -425,9 +433,9 @@
       contentText.innerHTML = html;
     }
 
-    this.layers.forEach(function(layer) {
+    for (const layer of this.layers) {
       layer.annotate(contentText, line);
-    });
+    }
 
     td.appendChild(contentText);
 
@@ -436,12 +444,12 @@
 
   /**
    * Returns the text length after normalizing unicode and tabs.
-   * @return {Number} The normalized length of the text.
+   * @return {number} The normalized length of the text.
    */
   GrDiffBuilder.prototype._textLength = function(text, tabSize) {
     text = text.replace(REGEX_ASTRAL_SYMBOL, '_');
-    var numChars = 0;
-    for (var i = 0; i < text.length; i++) {
+    let numChars = 0;
+    for (let i = 0; i < text.length; i++) {
       if (text[i] === '\t') {
         numChars += tabSize - (numChars % tabSize);
       } else {
@@ -467,7 +475,7 @@
              html.charCodeAt(index) !== GrDiffBuilder.GREATER_THAN_CODE) {
         index++;
       }
-      index++;  // skip the ">" itself
+      index++; // skip the ">" itself
     }
     // An HTML entity (e.g., &lt;) counts as one character.
     if (index < html.length &&
@@ -489,11 +497,11 @@
   };
 
   GrDiffBuilder.prototype._addNewlines = function(text, html) {
-    var htmlIndex = 0;
-    var indices = [];
-    var numChars = 0;
-    var prevHtmlIndex = 0;
-    for (var i = 0; i < text.length; i++) {
+    let htmlIndex = 0;
+    const indices = [];
+    let numChars = 0;
+    let prevHtmlIndex = 0;
+    for (let i = 0; i < text.length; i++) {
       if (numChars > 0 && numChars % this._prefs.line_length === 0) {
         indices.push(htmlIndex);
       }
@@ -513,11 +521,11 @@
       }
       prevHtmlIndex = htmlIndex;
     }
-    var result = html;
+    let result = html;
     // Since the result string is being altered in place, start from the end
     // of the string so that the insertion indices are not affected as the
     // result string changes.
-    for (var i = indices.length - 1; i >= 0; i--) {
+    for (let i = indices.length - 1; i >= 0; i--) {
       result = result.slice(0, indices[i]) + GrDiffBuilder.LINE_FEED_HTML +
           result.slice(indices[i]);
     }
@@ -529,19 +537,19 @@
    * elements in place of tab characters. In each case tab elements are given
    * the width needed to reach the next tab-stop.
    *
-   * @param {String} A line of text potentially containing tab characters.
-   * @param {Number} The width for tabs.
-   * @return {String} An HTML string potentially containing tab elements.
+   * @param {string} A line of text potentially containing tab characters.
+   * @param {number} The width for tabs.
+   * @return {string} An HTML string potentially containing tab elements.
    */
   GrDiffBuilder.prototype._addTabWrappers = function(line, tabSize) {
     if (!line.length) { return ''; }
 
-    var result = '';
-    var offset = 0;
-    var split = line.split('\t');
-    var width;
+    let result = '';
+    let offset = 0;
+    const split = line.split('\t');
+    let width;
 
-    for (var i = 0; i < split.length - 1; i++) {
+    for (let i = 0; i < split.length - 1; i++) {
       offset += split[i].length;
       width = tabSize - (offset % tabSize);
       result += split[i] + this._getTabWrapper(width);
@@ -561,7 +569,7 @@
       throw Error('Invalid tab size from preferences.');
     }
 
-    var str = '<span class="style-scope gr-diff tab ';
+    let str = '<span class="style-scope gr-diff tab ';
     str += '" style="';
     // TODO(andybons): CSS tab-size is not supported in IE.
     str += 'tab-size:' + tabSize + ';';
@@ -571,7 +579,7 @@
   };
 
   GrDiffBuilder.prototype._createElement = function(tagName, className) {
-    var el = document.createElement(tagName);
+    const el = document.createElement(tagName);
     // When Shady DOM is being used, these classes are added to account for
     // Polymer's polyfill behavior. In order to guarantee sufficient
     // specificity within the CSS rules, these are added to every element.
@@ -579,7 +587,7 @@
     // automatically) are not being used for performance reasons, this is
     // done manually.
     el.classList.add('style-scope', 'gr-diff');
-    if (!!className) {
+    if (className) {
       el.classList.add(className);
     }
     return el;
@@ -593,7 +601,7 @@
    * Finds the next DIV.contentText element following the given element, and on
    * the same side. Will only search within a group.
    * @param {HTMLElement} content
-   * @param {String} side Either 'left' or 'right'
+   * @param {string} side Either 'left' or 'right'
    * @return {HTMLElement}
    */
   GrDiffBuilder.prototype._getNextContentOnSide = function(content, side) {
@@ -603,8 +611,8 @@
   /**
    * Determines whether the given group is either totally an addition or totally
    * a removal.
-   * @param {GrDiffGroup} group
-   * @return {Boolean}
+   * @param {!Object} group (GrDiffGroup)
+   * @return {boolean}
    */
   GrDiffBuilder.prototype._isTotal = function(group) {
     return group.type === GrDiffGroup.Type.DELTA &&
@@ -613,10 +621,110 @@
   };
 
   GrDiffBuilder.prototype._escapeHTML = function(str) {
-    return str.replace(HTML_ENTITY_PATTERN, function(s) {
+    return str.replace(HTML_ENTITY_PATTERN, s => {
       return HTML_ENTITY_MAP[s];
     });
   };
 
+  /**
+   * Set the blame information for the diff. For any already-rednered line,
+   * re-render its blame cell content.
+   * @param {Object} blame
+   */
+  GrDiffBuilder.prototype.setBlame = function(blame) {
+    this._blameInfo = blame;
+
+    // TODO(wyatta): make this loop asynchronous.
+    for (const commit of blame) {
+      for (const range of commit.ranges) {
+        for (let i = range.start; i <= range.end; i++) {
+          // TODO(wyatta): this query is expensive, but, when traversing a
+          // range, the lines are consecutive, and given the previous blame
+          // cell, the next one can be reached cheaply.
+          const el = this._getBlameByLineNum(i);
+          if (!el) { continue; }
+          // Remove the element's children (if any).
+          while (el.hasChildNodes()) {
+            el.removeChild(el.lastChild);
+          }
+          const blame = this._getBlameForBaseLine(i, commit);
+          el.appendChild(blame);
+        }
+      }
+    }
+  };
+
+  /**
+   * Find the blame cell for a given line number.
+   * @param {number} lineNum
+   * @return {HTMLTableDataCellElement}
+   */
+  GrDiffBuilder.prototype._getBlameByLineNum = function(lineNum) {
+    const root = Polymer.dom(this._outputEl);
+    return root.querySelector(`td.blame[data-line-number="${lineNum}"]`);
+  };
+
+  /**
+   * Given a base line number, return the commit containing that line in the
+   * current set of blame information. If no blame information has been
+   * provided, null is returned.
+   * @param {number} lineNum
+   * @return {Object} The commit information.
+   */
+  GrDiffBuilder.prototype._getBlameCommitForBaseLine = function(lineNum) {
+    if (!this._blameInfo) { return null; }
+
+    for (const blameCommit of this._blameInfo) {
+      for (const range of blameCommit.ranges) {
+        if (range.start <= lineNum && range.end >= lineNum) {
+          return blameCommit;
+        }
+      }
+    }
+    return null;
+  };
+
+  /**
+   * Given the number of a base line, get the content for the blame cell of that
+   * line. If there is no blame information for that line, returns null.
+   * @param {number} lineNum
+   * @param {Object=} opt_commit Optionally provide the commit object, so that
+   *     it does not need to be searched.
+   * @return {HTMLSpanElement}
+   */
+  GrDiffBuilder.prototype._getBlameForBaseLine = function(lineNum, opt_commit) {
+    const commit = opt_commit || this._getBlameCommitForBaseLine(lineNum);
+    if (!commit) { return null; }
+
+    const isStartOfRange = commit.ranges.some(r => r.start === lineNum);
+
+    const date = (new Date(commit.time * 1000)).toLocaleDateString();
+    const blameNode = this._createElement('span',
+        isStartOfRange ? 'startOfRange' : '');
+    const shaNode = this._createElement('span', 'sha');
+    shaNode.innerText = commit.id.substr(0, 7);
+    blameNode.appendChild(shaNode);
+    blameNode.append(` on ${date} by ${commit.author}`);
+    return blameNode;
+  };
+
+  /**
+   * Create a blame cell for the given base line. Blame information will be
+   * included in the cell if available.
+   * @param {GrDiffLine} line
+   * @return {HTMLTableDataCellElement}
+   */
+  GrDiffBuilder.prototype._createBlameCell = function(line) {
+    const blameTd = this._createElement('td', 'blame');
+    blameTd.setAttribute('data-line-number', line.beforeNumber);
+    if (line.beforeNumber) {
+      const content = this._getBlameForBaseLine(line.beforeNumber);
+      if (content) {
+        blameTd.appendChild(content);
+      }
+    }
+    return blameTd;
+  };
+
   window.GrDiffBuilder = GrDiffBuilder;
 })(window, GrDiffGroup, GrDiffLine);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index 591fa9c..f9e465e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -20,6 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 <script src="../gr-diff/gr-diff-line.js"></script>
 <script src="../gr-diff/gr-diff-group.js"></script>
@@ -54,34 +55,41 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-builder tests', function() {
-    var element;
-    var builder;
+  suite('gr-diff-builder tests', () => {
+    let element;
+    let builder;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() { return Promise.resolve({}); },
       });
-      var prefs = {
+      const prefs = {
         line_length: 10,
         show_tabs: true,
         tab_size: 4,
       };
-      builder = new GrDiffBuilder({content: []}, {left: [], right: []}, prefs);
+      const projectName = 'my-project';
+      builder = new GrDiffBuilder(
+          {content: []}, {left: [], right: []}, prefs, projectName);
     });
 
-    test('context control buttons', function() {
-      var section = {};
-      var line = {contextGroup: {lines: []}};
+    teardown(() => { sandbox.restore(); });
+
+    test('context control buttons', () => {
+      const section = {};
+      const line = {contextGroup: {lines: []}};
 
       // Create 10 lines.
-      for (var i = 0; i < 10; i++) {
+      for (let i = 0; i < 10; i++) {
         line.contextGroup.lines.push('lorem upsum');
       }
 
       // Does not include +10 buttons when there are fewer than 11 lines.
-      var td = builder._createContextControl(section, line);
-      var buttons = td.querySelectorAll('gr-button.showContext');
+      let td = builder._createContextControl(section, line);
+      let buttons = td.querySelectorAll('gr-button.showContext');
 
       assert.equal(buttons.length, 1);
       assert.equal(buttons[0].textContent, 'Show 10 common lines');
@@ -99,8 +107,8 @@
       assert.equal(buttons[2].textContent, '+10↓');
     });
 
-    test('newlines 1', function() {
-      var text = 'abcdef';
+    test('newlines 1', () => {
+      let text = 'abcdef';
       assert.equal(builder._addNewlines(text, text), text);
       text = 'a'.repeat(20);
       assert.equal(builder._addNewlines(text, text),
@@ -109,9 +117,10 @@
           'a'.repeat(10));
     });
 
-    test('newlines 2', function() {
-      var text = '<span class="thumbsup">👍</span>';
-      var html = '&lt;span class=&quot;thumbsup&quot;&gt;👍&lt;&#x2F;span&gt;';
+    test('newlines 2', () => {
+      const text = '<span class="thumbsup">👍</span>';
+      const html =
+          '&lt;span class=&quot;thumbsup&quot;&gt;👍&lt;&#x2F;span&gt;';
       assert.equal(builder._addNewlines(text, html),
           '&lt;span clas' +
           GrDiffBuilder.LINE_FEED_HTML +
@@ -122,57 +131,55 @@
           'n&gt;');
     });
 
-    test('newlines 3', function() {
-      var text = '01234\t56789';
-      var html = '01234<span>\t</span>56789';
+    test('newlines 3', () => {
+      const text = '01234\t56789';
+      const html = '01234<span>\t</span>56789';
       assert.equal(builder._addNewlines(text, html),
           '01234<span>\t</span>5' +
           GrDiffBuilder.LINE_FEED_HTML +
           '6789');
     });
 
-    test('_addNewlines not called if line_wrapping is true', function(done) {
+    test('_addNewlines not called if line_wrapping is true', done => {
       builder._prefs = {line_wrapping: true, tab_size: 4, line_length: 50};
-      var text = (new Array(52)).join('a');
+      const text = (new Array(52)).join('a');
 
-      var line = {text: text, highlights: []};
-      var newLineStub = sinon.stub(builder, '_addNewlines');
+      const line = {text, highlights: []};
+      const newLineStub = sandbox.stub(builder, '_addNewlines');
       builder._createTextEl(line);
-      flush(function() {
+      flush(() => {
         assert.isFalse(newLineStub.called);
-        newLineStub.restore();
         done();
       });
     });
 
     test('_addNewlines called if line_wrapping is true and meets other ' +
-        'conditions', function(done) {
+        'conditions', done => {
       builder._prefs = {line_wrapping: false, tab_size: 4, line_length: 50};
-      var text = (new Array(52)).join('a');
+      const text = (new Array(52)).join('a');
 
-      var line = {text: text, highlights: []};
-      var newLineStub = sinon.stub(builder, '_addNewlines');
+      const line = {text, highlights: []};
+      const newLineStub = sandbox.stub(builder, '_addNewlines');
       builder._createTextEl(line);
 
-      flush(function() {
+      flush(() => {
         assert.isTrue(newLineStub.called);
-        newLineStub.restore();
         done();
       });
     });
 
-    test('_createTextEl linewrap with tabs', function() {
-      var text = _.times(7, _.constant('\t')).join('') + '!';
-      var line = {text: text, highlights: []};
-      var el = builder._createTextEl(line);
-      var tabEl = el.querySelector('.contentText > .br');
+    test('_createTextEl linewrap with tabs', () => {
+      const text = _.times(7, _.constant('\t')).join('') + '!';
+      const line = {text, highlights: []};
+      const el = builder._createTextEl(line);
+      const tabEl = el.querySelector('.contentText > .br');
       assert.isOk(tabEl);
       assert.equal(
           el.querySelector('.contentText .tab:nth-child(2)').nextSibling,
           tabEl);
     });
 
-    test('text length with tabs and unicode', function() {
+    test('text length with tabs and unicode', () => {
       assert.equal(builder._textLength('12345', 4), 5);
       assert.equal(builder._textLength('\t\t12', 4), 10);
       assert.equal(builder._textLength('abc💢123', 4), 7);
@@ -186,9 +193,9 @@
       assert.equal(builder._textLength('\t\t\t\t\t', 20), 100);
     });
 
-    test('tab wrapper insertion', function() {
-      var html = 'abc\tdef';
-      var wrapper = builder._getTabWrapper(
+    test('tab wrapper insertion', () => {
+      const html = 'abc\tdef';
+      const wrapper = builder._getTabWrapper(
           builder._prefs.tab_size - 3,
           builder._prefs.show_tabs);
       assert.ok(wrapper);
@@ -202,12 +209,12 @@
           true));
     });
 
-    test('comments', function() {
-      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+    test('comments', () => {
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = 3;
       line.afterNumber = 5;
 
-      var comments = {left: [], right: []};
+      let comments = {left: [], right: []};
       assert.deepEqual(builder._getCommentsForLine(comments, line), []);
       assert.deepEqual(builder._getCommentsForLine(comments, line,
           GrDiffBuilder.Side.LEFT), []);
@@ -229,19 +236,19 @@
           {id: 'r5', line: 5, __commentSide: 'right'}]);
       assert.deepEqual(builder._getCommentsForLine(comments, line,
           GrDiffBuilder.Side.LEFT), [{id: 'l3', line: 3,
-          __commentSide: 'left'}]);
+            __commentSide: 'left'}]);
       assert.deepEqual(builder._getCommentsForLine(comments, line,
           GrDiffBuilder.Side.RIGHT), [{id: 'r5', line: 5,
-          __commentSide: 'right'}]);
+            __commentSide: 'right'}]);
     });
 
-    test('comment thread group creation', function() {
-      var l3 = {id: 'l3', line: 3, updated: '2016-08-09 00:42:32.000000000',
-          __commentSide: 'left'};
-      var l5 = {id: 'l5', line: 5, updated: '2016-08-09 00:42:32.000000000',
-          __commentSide: 'left'};
-      var r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000',
-          __commentSide: 'right'};
+    test('comment thread group creation', () => {
+      const l3 = {id: 'l3', line: 3, updated: '2016-08-09 00:42:32.000000000',
+        __commentSide: 'left'};
+      const l5 = {id: 'l5', line: 5, updated: '2016-08-09 00:42:32.000000000',
+        __commentSide: 'left'};
+      const r5 = {id: 'r5', line: 5, updated: '2016-08-09 00:42:32.000000000',
+        __commentSide: 'right'};
 
       builder._comments = {
         meta: {
@@ -263,14 +270,14 @@
         assert.equal(threadGroupEl.patchForNewThreads, patchNum);
         assert.equal(threadGroupEl.path, '/path/to/foo');
         assert.equal(threadGroupEl.isOnParent, isOnParent);
-        assert.deepEqual(threadGroupEl.projectConfig, {foo: 'bar'});
+        assert.deepEqual(threadGroupEl.projectName, 'my-project');
         assert.deepEqual(threadGroupEl.comments, comments);
       }
 
-      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+      let line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = 5;
       line.afterNumber = 5;
-      var threadGroupEl = builder._commentThreadGroupForLine(line);
+      let threadGroupEl = builder._commentThreadGroupForLine(line);
       checkThreadGroupProps(threadGroupEl, '3', false, [l5, r5]);
 
       threadGroupEl =
@@ -306,64 +313,60 @@
       line.beforeNumber = 3;
       line.afterNumber = 5;
       threadGroupEl = builder._commentThreadGroupForLine(line);
-    checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
+      checkThreadGroupProps(threadGroupEl, '3', false, [l3, r5]);
     });
 
-    suite('_isTotal', function() {
-      test('is total for add', function() {
-        var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (var idx = 0; idx < 10; idx++) {
+    suite('_isTotal', () => {
+      test('is total for add', () => {
+        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+        for (let idx = 0; idx < 10; idx++) {
           group.addLine(new GrDiffLine(GrDiffLine.Type.ADD));
         }
         assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
       });
 
-      test('is total for remove', function() {
-        var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (var idx = 0; idx < 10; idx++) {
+      test('is total for remove', () => {
+        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+        for (let idx = 0; idx < 10; idx++) {
           group.addLine(new GrDiffLine(GrDiffLine.Type.REMOVE));
         }
         assert.isTrue(GrDiffBuilder.prototype._isTotal(group));
       });
 
-      test('not total for empty', function() {
-        var group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+      test('not total for empty', () => {
+        const group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
         assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
       });
 
-      test('not total for non-delta', function() {
-        var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-        for (var idx = 0; idx < 10; idx++) {
+      test('not total for non-delta', () => {
+        const group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+        for (let idx = 0; idx < 10; idx++) {
           group.addLine(new GrDiffLine(GrDiffLine.Type.BOTH));
         }
         assert.isFalse(GrDiffBuilder.prototype._isTotal(group));
       });
     });
 
-    suite('intraline differences', function() {
-      var el;
-      var str;
-      var annotateElementSpy;
-      var layer;
+    suite('intraline differences', () => {
+      let el;
+      let str;
+      let annotateElementSpy;
+      let layer;
 
       function slice(str, start, end) {
         return Array.from(str).slice(start, end).join('');
       }
 
-      setup(function() {
+      setup(() => {
         el = fixture('div-with-text');
         str = el.textContent;
-        annotateElementSpy = sinon.spy(GrAnnotation, 'annotateElement');
+        annotateElementSpy = sandbox.spy(GrAnnotation, 'annotateElement');
         layer = document.createElement('gr-diff-builder')
             ._createIntralineLayer();
       });
 
-      teardown(function() {
-        annotateElementSpy.restore();
-      });
-
-      test('annotate no highlights', function() {
-        var line = {
+      test('annotate no highlights', () => {
+        const line = {
           text: str,
           highlights: [],
         };
@@ -377,19 +380,19 @@
         assert.equal(str, el.childNodes[0].textContent);
       });
 
-      test('annotate with highlights', function() {
-        var line = {
+      test('annotate with highlights', () => {
+        const line = {
           text: str,
           highlights: [
             {startIndex: 6, endIndex: 12},
             {startIndex: 18, endIndex: 22},
           ],
         };
-        var str0 = slice(str, 0, 6);
-        var str1 = slice(str, 6, 12);
-        var str2 = slice(str, 12, 18);
-        var str3 = slice(str, 18, 22);
-        var str4 = slice(str, 22);
+        const str0 = slice(str, 0, 6);
+        const str1 = slice(str, 6, 12);
+        const str2 = slice(str, 12, 18);
+        const str3 = slice(str, 18, 22);
+        const str4 = slice(str, 22);
 
         layer.annotate(el, line);
 
@@ -412,16 +415,16 @@
         assert.equal(el.childNodes[4].textContent, str4);
       });
 
-      test('annotate without endIndex', function() {
-        var line = {
+      test('annotate without endIndex', () => {
+        const line = {
           text: str,
           highlights: [
             {startIndex: 28},
           ],
         };
 
-        var str0 = slice(str, 0, 28);
-        var str1 = slice(str, 28);
+        const str0 = slice(str, 0, 28);
+        const str1 = slice(str, 28);
 
         layer.annotate(el, line);
 
@@ -435,8 +438,8 @@
         assert.equal(el.childNodes[1].textContent, str1);
       });
 
-      test('annotate ignores empty highlights', function() {
-        var line = {
+      test('annotate ignores empty highlights', () => {
+        const line = {
           text: str,
           highlights: [
             {startIndex: 28, endIndex: 28},
@@ -449,20 +452,20 @@
         assert.equal(el.childNodes.length, 1);
       });
 
-      test('annotate handles unicode', function() {
+      test('annotate handles unicode', () => {
         // Put some unicode into the string:
         str = str.replace(/\s/g, '💢');
         el.textContent = str;
-        var line = {
+        const line = {
           text: str,
           highlights: [
             {startIndex: 6, endIndex: 12},
           ],
         };
 
-        var str0 = slice(str, 0, 6);
-        var str1 = slice(str, 6, 12);
-        var str2 = slice(str, 12);
+        const str0 = slice(str, 0, 6);
+        const str1 = slice(str, 6, 12);
+        const str2 = slice(str, 12);
 
         layer.annotate(el, line);
 
@@ -479,20 +482,20 @@
         assert.equal(el.childNodes[2].textContent, str2);
       });
 
-      test('annotate handles unicode w/o endIndex', function() {
+      test('annotate handles unicode w/o endIndex', () => {
         // Put some unicode into the string:
         str = str.replace(/\s/g, '💢');
         el.textContent = str;
 
-        var line = {
+        const line = {
           text: str,
           highlights: [
             {startIndex: 6},
           ],
         };
 
-        var str0 = slice(str, 0, 6);
-        var str1 = slice(str, 6);
+        const str0 = slice(str, 0, 6);
+        const str1 = slice(str, 6);
 
         layer.annotate(el, line);
 
@@ -507,87 +510,86 @@
       });
     });
 
-    suite('tab indicators', function() {
-      var sandbox;
-      var element;
-      var layer;
+    suite('tab indicators', () => {
+      let element;
+      let layer;
 
-      setup(function() {
-        sandbox = sinon.sandbox.create();
+      setup(() => {
         element = fixture('basic');
         element._showTabs = true;
         layer = element._createTabIndicatorLayer();
       });
 
-      teardown(function() {
-        sandbox.restore();
-      });
-
-      test('does nothing with empty line', function() {
-        var line = {text: ''};
-        var el = document.createElement('div');
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+      test('does nothing with empty line', () => {
+        const line = {text: ''};
+        const el = document.createElement('div');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('does nothing with no tabs', function() {
-        var str = 'lorem ipsum no tabs';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('does nothing with no tabs', () => {
+        const str = 'lorem ipsum no tabs';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('annotates tab at beginning', function() {
-        var str = '\tlorem upsum';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates tab at beginning', () => {
+        const str = '\tlorem upsum';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.equal(annotateElementStub.callCount, 1);
-        var args = annotateElementStub.getCalls()[0].args;
+        const args = annotateElementStub.getCalls()[0].args;
         assert.equal(args[0], el);
         assert.equal(args[1], 0, 'offset of tab indicator');
         assert.equal(args[2], 1, 'length of tab indicator');
         assert.include(args[3], 'tab-indicator');
       });
 
-      test('does not annotate when disabled', function() {
+      test('does not annotate when disabled', () => {
         element._showTabs = false;
 
-        var str = '\tlorem upsum';
-        var line = {text: str};
-        var el = document.createElement('div');
+        const str = '\tlorem upsum';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('annotates multiple in beginning', function() {
-        var str = '\t\tlorem upsum';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates multiple in beginning', () => {
+        const str = '\t\tlorem upsum';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.equal(annotateElementStub.callCount, 2);
 
-        var args = annotateElementStub.getCalls()[0].args;
+        let args = annotateElementStub.getCalls()[0].args;
         assert.equal(args[0], el);
         assert.equal(args[1], 0, 'offset of tab indicator');
         assert.equal(args[2], 1, 'length of tab indicator');
@@ -600,17 +602,18 @@
         assert.include(args[3], 'tab-indicator');
       });
 
-      test('annotates intermediate tabs', function() {
-        var str = 'lorem\tupsum';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates intermediate tabs', () => {
+        const str = 'lorem\tupsum';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
 
         layer.annotate(el, line);
 
         assert.equal(annotateElementStub.callCount, 1);
-        var args = annotateElementStub.getCalls()[0].args;
+        const args = annotateElementStub.getCalls()[0].args;
         assert.equal(args[0], el);
         assert.equal(args[1], 5, 'offset of tab indicator');
         assert.equal(args[2], 1, 'length of tab indicator');
@@ -618,108 +621,107 @@
       });
     });
 
-    suite('trailing whitespace', function() {
-      var sandbox;
-      var element;
-      var layer;
+    suite('trailing whitespace', () => {
+      let element;
+      let layer;
 
-      setup(function() {
-        sandbox = sinon.sandbox.create();
+      setup(() => {
         element = fixture('basic');
         element._showTrailingWhitespace = true;
         layer = element._createTrailingWhitespaceLayer();
       });
 
-      teardown(function() {
-        sandbox.restore();
-      });
-
-      test('does nothing with empty line', function() {
-        var line = {text: ''};
-        var el = document.createElement('div');
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+      test('does nothing with empty line', () => {
+        const line = {text: ''};
+        const el = document.createElement('div');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('does nothing with no trailing whitespace', function() {
-        var str = 'lorem ipsum blah blah';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('does nothing with no trailing whitespace', () => {
+        const str = 'lorem ipsum blah blah';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('annotates trailing spaces', function() {
-        var str = 'lorem ipsum   ';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates trailing spaces', () => {
+        const str = 'lorem ipsum   ';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 11);
         assert.equal(annotateElementStub.lastCall.args[2], 3);
       });
 
-      test('annotates trailing tabs', function() {
-        var str = 'lorem ipsum\t\t\t';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates trailing tabs', () => {
+        const str = 'lorem ipsum\t\t\t';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 11);
         assert.equal(annotateElementStub.lastCall.args[2], 3);
       });
 
-      test('annotates mixed trailing whitespace', function() {
-        var str = 'lorem ipsum\t \t';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('annotates mixed trailing whitespace', () => {
+        const str = 'lorem ipsum\t \t';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 11);
         assert.equal(annotateElementStub.lastCall.args[2], 3);
       });
 
-      test('unicode preceding trailing whitespace', function() {
-        var str = '💢\t';
-        var line = {text: str};
-        var el = document.createElement('div');
+      test('unicode preceding trailing whitespace', () => {
+        const str = '💢\t';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isTrue(annotateElementStub.called);
         assert.equal(annotateElementStub.lastCall.args[1], 1);
         assert.equal(annotateElementStub.lastCall.args[2], 1);
       });
 
-      test('does not annotate when disabled', function() {
+      test('does not annotate when disabled', () => {
         element._showTrailingWhitespace = false;
-        var str = 'lorem upsum\t \t ';
-        var line = {text: str};
-        var el = document.createElement('div');
+        const str = 'lorem upsum\t \t ';
+        const line = {text: str};
+        const el = document.createElement('div');
         el.textContent = str;
-        var annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
+        const annotateElementStub =
+            sandbox.stub(GrAnnotation, 'annotateElement');
         layer.annotate(el, line);
         assert.isFalse(annotateElementStub.called);
       });
     });
 
-    suite('rendering', function() {
-      var content;
-      var outputEl;
-      var sandbox;
+    suite('rendering', () => {
+      let content;
+      let outputEl;
 
-      setup(function(done) {
-        sandbox = sinon.sandbox.create();
-        var prefs = {
+      setup(done => {
+        const prefs = {
           line_length: 10,
           show_tabs: true,
           tab_size: 4,
@@ -729,13 +731,13 @@
         content = [
           {
             a: ['all work and no play make andybons a dull boy'],
-            b: ['elgoog elgoog elgoog']
+            b: ['elgoog elgoog elgoog'],
           },
           {
             ab: [
               'Non eram nescius, Brute, cum, quae summis ingeniis ',
               'exquisitaque doctrina philosophi Graeco sermone tractavissent',
-            ]
+            ],
           },
         ];
         stub('gr-reporting', {
@@ -744,30 +746,26 @@
         });
         element = fixture('basic');
         outputEl = element.queryEffectiveChildren('#diffTable');
-        sandbox.stub(element, '_getDiffBuilder', function() {
-          var builder = new GrDiffBuilder(
-              {content: content}, {left: [], right: []}, prefs, outputEl);
+        sandbox.stub(element, '_getDiffBuilder', () => {
+          const builder = new GrDiffBuilder(
+              {content}, {left: [], right: []}, prefs, 'my-project', outputEl);
           sandbox.stub(builder, 'addColumns');
           builder.buildSectionElement = function(group) {
-            var section = document.createElement('stub');
-            section.textContent = group.lines.reduce(function(acc, line) {
+            const section = document.createElement('stub');
+            section.textContent = group.lines.reduce((acc, line) => {
               return acc + line.text;
             }, '');
             return section;
           };
           return builder;
         });
-        element.diff = {content: content};
+        element.diff = {content};
         element.render({left: [], right: []}, prefs).then(done);
       });
 
-      teardown(function() {
-        sandbox.restore();
-      });
-
-      test('reporting', function(done) {
-        var timeStub = element.$.reporting.time;
-        var timeEndStub = element.$.reporting.timeEnd;
+      test('reporting', done => {
+        const timeStub = element.$.reporting.time;
+        const timeEndStub = element.$.reporting.timeEnd;
         assert.isTrue(timeStub.calledWithExactly('Diff Total Render'));
         assert.isTrue(timeStub.calledWithExactly('Diff Content Render'));
         assert.isTrue(timeStub.calledWithExactly('Diff Syntax Render'));
@@ -777,43 +775,43 @@
         done();
       });
 
-      test('renderSection', function() {
-        var section = outputEl.querySelector('stub:nth-of-type(2)');
-        var prevInnerHTML = section.innerHTML;
+      test('renderSection', () => {
+        let section = outputEl.querySelector('stub:nth-of-type(2)');
+        const prevInnerHTML = section.innerHTML;
         section.innerHTML = 'wiped';
         element._builder.renderSection(section);
         section = outputEl.querySelector('stub:nth-of-type(2)');
         assert.equal(section.innerHTML, prevInnerHTML);
       });
 
-      test('addColumns is called', function(done) {
+      test('addColumns is called', done => {
         element.render({left: [], right: []}, {}).then(done);
         assert.isTrue(element._builder.addColumns.called);
       });
 
-      test('getSectionsByLineRange one line', function() {
-        var section = outputEl.querySelector('stub:nth-of-type(2)');
-        var sections = element._builder.getSectionsByLineRange(1, 1, 'left');
+      test('getSectionsByLineRange one line', () => {
+        const section = outputEl.querySelector('stub:nth-of-type(2)');
+        const sections = element._builder.getSectionsByLineRange(1, 1, 'left');
         assert.equal(sections.length, 1);
         assert.strictEqual(sections[0], section);
       });
 
-      test('getSectionsByLineRange over diff', function() {
-        var section = [
+      test('getSectionsByLineRange over diff', () => {
+        const section = [
           outputEl.querySelector('stub:nth-of-type(2)'),
           outputEl.querySelector('stub:nth-of-type(3)'),
         ];
-        var sections = element._builder.getSectionsByLineRange(1, 2, 'left');
+        const sections = element._builder.getSectionsByLineRange(1, 2, 'left');
         assert.equal(sections.length, 2);
         assert.strictEqual(sections[0], section[0]);
         assert.strictEqual(sections[1], section[1]);
       });
 
-      test('render-start and render are fired', function(done) {
-        var dispatchEventStub = sinon.stub(element, 'dispatchEvent');
-        element.render({left: [], right: []}, {}).then(function() {
-          var firedEventTypes = dispatchEventStub.getCalls()
-              .map(function(c) { return c.args[0].type; });
+      test('render-start and render are fired', done => {
+        const dispatchEventStub = sandbox.stub(element, 'dispatchEvent');
+        element.render({left: [], right: []}, {}).then(() => {
+          const firedEventTypes = dispatchEventStub.getCalls()
+              .map(c => { return c.args[0].type; });
           assert.include(firedEventTypes, 'render-start');
           assert.include(firedEventTypes, 'render-content');
           assert.include(firedEventTypes, 'render');
@@ -821,18 +819,18 @@
         });
       });
 
-      test('rendering normal-sized diff does not disable syntax', function() {
+      test('rendering normal-sized diff does not disable syntax', () => {
         assert.isTrue(element.$.syntaxLayer.enabled);
       });
 
-      test('rendering large diff disables syntax', function(done) {
+      test('rendering large diff disables syntax', done => {
         // Before it renders, set the first diff line to 500 '*' characters.
         element.diff.content[0].a = [new Array(501).join('*')];
-        element.addEventListener('render', function() {
+        element.addEventListener('render', () => {
           assert.isFalse(element.$.syntaxLayer.enabled);
           done();
         });
-        var prefs = {
+        const prefs = {
           line_length: 10,
           show_tabs: true,
           tab_size: 4,
@@ -841,15 +839,23 @@
         };
         element.render({left: [], right: []}, prefs);
       });
+
+      test('cancel', () => {
+        const processorCancelStub = sandbox.stub(element.$.processor, 'cancel');
+        const syntaxCancelStub = sandbox.stub(element.$.syntaxLayer, 'cancel');
+        element.cancel();
+        assert.isTrue(processorCancelStub.called);
+        assert.isTrue(syntaxCancelStub.called);
+      });
     });
 
-    suite('mock-diff', function() {
-      var element;
-      var builder;
-      var diff;
-      var prefs;
+    suite('mock-diff', () => {
+      let element;
+      let builder;
+      let diff;
+      let prefs;
 
-      setup(function(done) {
+      setup(done => {
         element = fixture('mock-diff');
         diff = document.createElement('mock-diff-response').diffResponse;
         element.diff = diff;
@@ -860,14 +866,14 @@
           tab_size: 4,
         };
 
-        element.render({left: [], right: []}, prefs).then(function() {
+        element.render({left: [], right: []}, prefs).then(() => {
           builder = element._builder;
           done();
         });
       });
 
-      test('getContentByLine', function() {
-        var actual;
+      test('getContentByLine', () => {
+        let actual;
 
         actual = builder.getContentByLine(2, 'left');
         assert.equal(actual.textContent, diff.content[0].ab[1]);
@@ -882,19 +888,19 @@
         assert.equal(actual.textContent, diff.content[1].b[0]);
       });
 
-      test('findLinesByRange', function() {
-        var lines = [];
-        var elems = [];
-        var start = 6;
-        var end = 10;
-        var count = end - start + 1;
+      test('findLinesByRange', () => {
+        const lines = [];
+        const elems = [];
+        const start = 6;
+        const end = 10;
+        const count = end - start + 1;
 
         builder.findLinesByRange(start, end, 'right', lines, elems);
 
         assert.equal(lines.length, count);
         assert.equal(elems.length, count);
 
-        for (var i = 0; i < 5; i++) {
+        for (let i = 0; i < 5; i++) {
           assert.instanceOf(lines[i], GrDiffLine);
           assert.equal(lines[i].afterNumber, start + i);
           assert.instanceOf(elems[i], HTMLElement);
@@ -902,59 +908,57 @@
         }
       });
 
-      test('_renderContentByRange', function() {
-        var spy = sinon.spy(builder, '_createTextEl');
-        var start = 9;
-        var end = 14;
-        var count = end - start + 1;
+      test('_renderContentByRange', () => {
+        const spy = sandbox.spy(builder, '_createTextEl');
+        const start = 9;
+        const end = 14;
+        const count = end - start + 1;
 
         builder._renderContentByRange(start, end, 'left');
 
         assert.equal(spy.callCount, count);
-        spy.getCalls().forEach(function(call, i) {
+        spy.getCalls().forEach((call, i) => {
           assert.equal(call.args[0].beforeNumber, start + i);
         });
-
-        spy.restore();
       });
 
-      test('_getNextContentOnSide side-by-side left', function() {
-        var startElem = builder.getContentByLine(5, 'left',
+      test('_getNextContentOnSide side-by-side left', () => {
+        const startElem = builder.getContentByLine(5, 'left',
             element.$.diffTable);
-        var expectedStartString = diff.content[2].ab[0];
-        var expectedNextString = diff.content[2].ab[1];
+        const expectedStartString = diff.content[2].ab[0];
+        const expectedNextString = diff.content[2].ab[1];
         assert.equal(startElem.textContent, expectedStartString);
 
-        var nextElem = builder._getNextContentOnSide(startElem,
+        const nextElem = builder._getNextContentOnSide(startElem,
             'left');
         assert.equal(nextElem.textContent, expectedNextString);
       });
 
-      test('_getNextContentOnSide side-by-side right', function() {
-        var startElem = builder.getContentByLine(5, 'right',
+      test('_getNextContentOnSide side-by-side right', () => {
+        const startElem = builder.getContentByLine(5, 'right',
             element.$.diffTable);
-        var expectedStartString = diff.content[1].b[0];
-        var expectedNextString = diff.content[1].b[1];
+        const expectedStartString = diff.content[1].b[0];
+        const expectedNextString = diff.content[1].b[1];
         assert.equal(startElem.textContent, expectedStartString);
 
-        var nextElem = builder._getNextContentOnSide(startElem,
+        const nextElem = builder._getNextContentOnSide(startElem,
             'right');
         assert.equal(nextElem.textContent, expectedNextString);
       });
 
-      test('_getNextContentOnSide unified left', function(done) {
+      test('_getNextContentOnSide unified left', done => {
         // Re-render as unified:
         element.viewMode = 'UNIFIED_DIFF';
-        element.render({left: [], right: []}, prefs).then(function() {
+        element.render({left: [], right: []}, prefs).then(() => {
           builder = element._builder;
 
-          var startElem = builder.getContentByLine(5, 'left',
+          const startElem = builder.getContentByLine(5, 'left',
               element.$.diffTable);
-          var expectedStartString = diff.content[2].ab[0];
-          var expectedNextString = diff.content[2].ab[1];
+          const expectedStartString = diff.content[2].ab[0];
+          const expectedNextString = diff.content[2].ab[1];
           assert.equal(startElem.textContent, expectedStartString);
 
-          var nextElem = builder._getNextContentOnSide(startElem,
+          const nextElem = builder._getNextContentOnSide(startElem,
               'left');
           assert.equal(nextElem.textContent, expectedNextString);
 
@@ -962,19 +966,19 @@
         });
       });
 
-      test('_getNextContentOnSide unified right', function(done) {
+      test('_getNextContentOnSide unified right', done => {
         // Re-render as unified:
         element.viewMode = 'UNIFIED_DIFF';
-        element.render({left: [], right: []}, prefs).then(function() {
+        element.render({left: [], right: []}, prefs).then(() => {
           builder = element._builder;
 
-          var startElem = builder.getContentByLine(5, 'right',
+          const startElem = builder.getContentByLine(5, 'right',
               element.$.diffTable);
-          var expectedStartString = diff.content[1].b[0];
-          var expectedNextString = diff.content[1].b[1];
+          const expectedStartString = diff.content[1].b[0];
+          const expectedNextString = diff.content[1].b[1];
           assert.equal(startElem.textContent, expectedStartString);
 
-          var nextElem = builder._getNextContentOnSide(startElem,
+          const nextElem = builder._getNextContentOnSide(startElem,
               'right');
           assert.equal(nextElem.textContent, expectedNextString);
 
@@ -982,11 +986,11 @@
         });
       });
 
-      test('_escapeHTML', function() {
-        var input = '<script>alert("XSS");<' + '/script>';
-        var expected = '&lt;script&gt;alert(&quot;XSS&quot;);' +
+      test('_escapeHTML', () => {
+        let input = '<script>alert("XSS");<' + '/script>';
+        let expected = '&lt;script&gt;alert(&quot;XSS&quot;);' +
             '&lt;&#x2F;script&gt;';
-        var result = GrDiffBuilder.prototype._escapeHTML(input);
+        let result = GrDiffBuilder.prototype._escapeHTML(input);
         assert.equal(result, expected);
 
         input = '& < > " \' / `';
@@ -999,5 +1003,58 @@
         assert.equal(result, expected);
       });
     });
+
+    suite('blame', () => {
+      let mockBlame;
+
+      setup(() => {
+        mockBlame = [
+          {id: 'commit 1', ranges: [{start: 1, end: 2}, {start: 10, end: 16}]},
+          {id: 'commit 2', ranges: [{start: 4, end: 10}, {start: 17, end: 32}]},
+        ];
+      });
+
+      test('setBlame attempts to render each blamed line', () => {
+        const getBlameStub = sandbox.stub(builder, '_getBlameByLineNum')
+            .returns(null);
+        builder.setBlame(mockBlame);
+        assert.equal(getBlameStub.callCount, 32);
+      });
+
+      test('_getBlameCommitForBaseLine', () => {
+        builder.setBlame(mockBlame);
+        assert.isOk(builder._getBlameCommitForBaseLine(1));
+        assert.equal(builder._getBlameCommitForBaseLine(1).id, 'commit 1');
+
+        assert.isOk(builder._getBlameCommitForBaseLine(11));
+        assert.equal(builder._getBlameCommitForBaseLine(11).id, 'commit 1');
+
+        assert.isOk(builder._getBlameCommitForBaseLine(32));
+        assert.equal(builder._getBlameCommitForBaseLine(32).id, 'commit 2');
+
+        assert.isNull(builder._getBlameCommitForBaseLine(33));
+      });
+
+      test('_getBlameCommitForBaseLine w/o blame returns null', () => {
+        assert.isNull(builder._getBlameCommitForBaseLine(1));
+        assert.isNull(builder._getBlameCommitForBaseLine(11));
+        assert.isNull(builder._getBlameCommitForBaseLine(31));
+      });
+
+      test('_createBlameCell', () => {
+        const mocbBlameCell = document.createElement('span');
+        const getBlameStub = sinon.stub(builder, '_getBlameForBaseLine')
+            .returns(mocbBlameCell);
+        const line = new GrDiffLine(GrDiffLine.Type.BOTH);
+        line.beforeNumber = 3;
+        line.afterNumber = 5;
+
+        const result = builder._createBlameCell(line);
+
+        assert.isTrue(getBlameStub.calledWithExactly(3));
+        assert.equal(result.getAttribute('data-line-number'), '3');
+        assert.equal(result.firstChild, mocbBlameCell);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
index 30dfacf..fdb7b6a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.html
@@ -16,10 +16,11 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-diff-comment-thread/gr-diff-comment-thread.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-diff-comment-thread-group">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
         white-space: normal;
@@ -28,8 +29,7 @@
         margin-top: .2em;
       }
     </style>
-    <template is="dom-repeat" items="[[_threads]]"
-        as="thread">
+    <template is="dom-repeat" items="[[_threads]]" as="thread">
       <gr-diff-comment-thread
           comments="[[thread.comments]]"
           comment-side="[[thread.commentSide]]"
@@ -38,7 +38,7 @@
           location-range="[[thread.locationRange]]"
           patch-num="[[thread.patchNum]]"
           path="[[path]]"
-          project-config="[[projectConfig]]"></gr-diff-comment-thread>
+          project-name="[[projectName]]"></gr-diff-comment-thread>
     </template>
   </template>
   <script src="gr-diff-comment-thread-group.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
index df75d52..b6af0d8 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group.js
@@ -21,10 +21,10 @@
       changeNum: String,
       comments: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
+      projectName: String,
       patchForNewThreads: String,
-      projectConfig: Object,
       range: Object,
       isOnParent: {
         type: Boolean,
@@ -32,7 +32,7 @@
       },
       _threads: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
     },
 
@@ -40,16 +40,16 @@
       '_commentsChanged(comments.*)',
     ],
 
-    addNewThread: function(locationRange) {
+    addNewThread(locationRange) {
       this.push('_threads', {
         comments: [],
-        locationRange: locationRange,
+        locationRange,
         patchNum: this.patchForNewThreads,
       });
     },
 
-    removeThread: function(locationRange) {
-      for (var i = 0; i < this._threads.length; i++) {
+    removeThread(locationRange) {
+      for (let i = 0; i < this._threads.length; i++) {
         if (this._threads[i].locationRange === locationRange) {
           this.splice('_threads', i, 1);
           return;
@@ -57,10 +57,10 @@
       }
     },
 
-    getThreadForRange: function(rangeToCheck) {
-      var threads = [].filter.call(
+    getThreadForRange(rangeToCheck) {
+      const threads = [].filter.call(
           Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread'),
-          function(thread) {
+          thread => {
             return thread.locationRange === rangeToCheck;
           });
       if (threads.length === 1) {
@@ -68,13 +68,13 @@
       }
     },
 
-    _commentsChanged: function() {
+    _commentsChanged() {
       this._threads = this._getThreadGroups(this.comments);
     },
 
-    _sortByDate: function(threadGroups) {
+    _sortByDate(threadGroups) {
       if (!threadGroups.length) { return; }
-      return threadGroups.sort(function(a, b) {
+      return threadGroups.sort((a, b) => {
         // If a comment is a draft, it doesn't have a start_datetime yet.
         // Assume it is newer than the comment it is being compared to.
         if (!a.start_datetime) {
@@ -88,7 +88,7 @@
       });
     },
 
-    _calculateLocationRange: function(range, comment) {
+    _calculateLocationRange(range, comment) {
       return 'range-' + range.start_line + '-' +
           range.start_character + '-' +
           range.end_line + '-' +
@@ -102,15 +102,15 @@
      * This is needed for switching between side-by-side and unified views when
      * there are unsaved drafts.
      */
-    _getPatchNum: function(comment) {
+    _getPatchNum(comment) {
       return comment.patchNum || this.patchForNewThreads;
     },
 
-    _getThreadGroups: function(comments) {
-      var threadGroups = {};
+    _getThreadGroups(comments) {
+      const threadGroups = {};
 
-      comments.forEach(function(comment) {
-        var locationRange;
+      for (const comment of comments) {
+        let locationRange;
         if (!comment.range) {
           locationRange = 'line-' + comment.__commentSide;
         } else {
@@ -123,18 +123,18 @@
           threadGroups[locationRange] = {
             start_datetime: comment.updated,
             comments: [comment],
-            locationRange: locationRange,
+            locationRange,
             commentSide: comment.__commentSide,
             patchNum: this._getPatchNum(comment),
           };
         }
-      }.bind(this));
+      }
 
-      var threadGroupArr = [];
-      var threadGroupKeys = Object.keys(threadGroups);
-      threadGroupKeys.forEach(function(threadGroupKey) {
+      const threadGroupArr = [];
+      const threadGroupKeys = Object.keys(threadGroups);
+      for (const threadGroupKey of threadGroupKeys) {
         threadGroupArr.push(threadGroups[threadGroupKey]);
-      });
+      }
 
       return this._sortByDate(threadGroupArr);
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
index 53a8e81..c2738460 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-comment-thread-group.html">
 
 <script>void(0);</script>
@@ -34,25 +34,25 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-comment-thread-group tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-comment-thread-group tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('_getThreadGroups', function() {
+    test('_getThreadGroups', () => {
       element.patchForNewThreads = 3;
-      var comments = [
+      const comments = [
         {
           id: 'sallys_confession',
           message: 'i like you, jack',
@@ -66,23 +66,23 @@
         },
       ];
 
-      var expectedThreadGroups = [
+      let expectedThreadGroups = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           commentSide: 'left',
           comments: [{
-              id: 'sallys_confession',
-              message: 'i like you, jack',
-              updated: '2015-12-23 15:00:20.396000000',
-              __commentSide: 'left',
-            }, {
-              id: 'jacks_reply',
-              message: 'i like you, too',
-              updated: '2015-12-24 15:00:20.396000000',
-              __commentSide: 'left',
-            }],
+            id: 'sallys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-23 15:00:20.396000000',
+            __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:00:20.396000000',
+            __commentSide: 'left',
+          }],
           locationRange: 'line-left',
-          patchNum: 3
+          patchNum: 3,
         },
       ];
 
@@ -91,33 +91,33 @@
 
       // Patch num should get inherited from comment rather
       comments.push({
-          id: 'betsys_confession',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:10.396000000',
-          range: {
-            start_line: 1,
-            start_character: 1,
-            end_line: 1,
-            end_character: 2,
-          },
-          __commentSide: 'left',
-        });
+        id: 'betsys_confession',
+        message: 'i like you, jack',
+        updated: '2015-12-24 15:00:10.396000000',
+        range: {
+          start_line: 1,
+          start_character: 1,
+          end_line: 1,
+          end_character: 2,
+        },
+        __commentSide: 'left',
+      });
 
       expectedThreadGroups = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           commentSide: 'left',
           comments: [{
-              id: 'sallys_confession',
-              message: 'i like you, jack',
-              updated: '2015-12-23 15:00:20.396000000',
-              __commentSide: 'left',
-            }, {
-              id: 'jacks_reply',
-              message: 'i like you, too',
-              updated: '2015-12-24 15:00:20.396000000',
-              __commentSide: 'left',
-            }],
+            id: 'sallys_confession',
+            message: 'i like you, jack',
+            updated: '2015-12-23 15:00:20.396000000',
+            __commentSide: 'left',
+          }, {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            updated: '2015-12-24 15:00:20.396000000',
+            __commentSide: 'left',
+          }],
           patchNum: 3,
           locationRange: 'line-left',
         },
@@ -145,8 +145,8 @@
           expectedThreadGroups);
     });
 
-    test('_sortByDate', function() {
-      var threadGroups = [
+    test('_sortByDate', () => {
+      let threadGroups = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           comments: [],
@@ -159,12 +159,12 @@
         },
       ];
 
-      var expectedResult = [
+      let expectedResult = [
         {
           start_datetime: '2015-12-22 15:00:10.396000000',
           comments: [],
           locationRange: 'range-1-1-1-2',
-        },{
+        }, {
           start_datetime: '2015-12-23 15:00:20.396000000',
           comments: [],
           locationRange: 'line',
@@ -175,7 +175,7 @@
 
       // When a comment doesn't have a date, the one without the date should be
       // last.
-      var threadGroups = [
+      threadGroups = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           comments: [],
@@ -187,7 +187,7 @@
         },
       ];
 
-      var expectedResult = [
+      expectedResult = [
         {
           start_datetime: '2015-12-23 15:00:20.396000000',
           comments: [],
@@ -198,22 +198,25 @@
           locationRange: 'range-1-1-1-2',
         },
       ];
+
+      assert.deepEqual(element._sortByDate(threadGroups), expectedResult);
     });
 
-    test('_calculateLocationRange', function() {
-      var comment = {__commentSide: 'left'};
-      var range = {
+    test('_calculateLocationRange', () => {
+      const comment = {__commentSide: 'left'};
+      const range = {
         start_line: 1,
         start_character: 2,
         end_line: 3,
         end_character: 4,
       };
       assert.equal(
-        element._calculateLocationRange(range, comment), 'range-1-2-3-4-left');
+          element._calculateLocationRange(range, comment),
+          'range-1-2-3-4-left');
     });
 
-    test('thread groups are updated when comments change', function() {
-      var commentsChangedStub = sandbox.stub(element, '_commentsChanged');
+    test('thread groups are updated when comments change', () => {
+      const commentsChangedStub = sandbox.stub(element, '_commentsChanged');
       element.comments = [];
       element.comments.push({
         id: 'sallys_confession',
@@ -223,16 +226,16 @@
       assert(commentsChangedStub.called);
     });
 
-    test('addNewThread', function() {
-      var locationRange = 'range-1-2-3-4';
+    test('addNewThread', () => {
+      const locationRange = 'range-1-2-3-4';
       element._threads = [{locationRange: 'line'}];
       element.addNewThread(locationRange);
       assert(element._threads.length, 2);
     });
 
-    test('_getPatchNum', function() {
+    test('_getPatchNum', () => {
       element.patchForNewThreads = 3;
-      var comment = {
+      const comment = {
         id: 'sallys_confession',
         message: 'i like you, jack',
         updated: '2015-12-23 15:00:20.396000000',
@@ -242,11 +245,11 @@
       assert.equal(element._getPatchNum(comment), 4);
     });
 
-    test('removeThread', function() {
-      var locationRange = 'range-1-2-3-4';
+    test('removeThread', () => {
+      const locationRange = 'range-1-2-3-4';
       element._threads = [
         {locationRange: 'range-1-2-3-4', comments: []},
-        {locationRange: 'line', comments: []}
+        {locationRange: 'line', comments: []},
       ];
       flushAsynchronousOperations();
       element.removeThread(locationRange);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
index c19b643..7cc94af 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.html
@@ -18,17 +18,26 @@
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../gr-diff-comment/gr-diff-comment.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-diff-comment-thread">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         border: 1px solid #bbb;
         display: block;
         margin-bottom: 1px;
         white-space: normal;
       }
+      gr-button {
+        margin-left: .5em;
+        --gr-button: {
+          color: #212121;
+        }
+        --gr-button-hover-color: rgba(33, 33, 33, .75);
+      }
       #actions {
+        margin-left: auto;
         padding: .5em .7em;
       }
       #container {
@@ -40,11 +49,10 @@
       #commentInfoContainer {
         border-top: 1px dotted #bbb;
         display: flex;
-        justify-content: space-between;
       }
       #unresolvedLabel {
         font-family: var(--font-family);
-        margin: auto 0 auto auto;
+        margin: auto 0;
         padding: .5em .7em;
       }
     </style>
@@ -56,27 +64,39 @@
             robot-button-disabled="[[_hideActions(_showActions, _lastComment)]]"
             change-num="[[changeNum]]"
             patch-num="[[patchNum]]"
-            draft="[[comment.__draft]]"
+            draft="[[_isDraft(comment)]]"
             show-actions="[[_showActions]]"
             comment-side="[[comment.__commentSide]]"
             side="[[comment.side]]"
-            project-config="[[projectConfig]]"
+            project-config="[[_projectConfig]]"
             on-create-fix-comment="_handleCommentFix"
             on-comment-discard="_handleCommentDiscard"></gr-diff-comment>
       </template>
       <div id="commentInfoContainer"
           hidden$="[[_hideActions(_showActions, _lastComment)]]">
-        <div id="actions">
-          <gr-button id="replyBtn" class="action reply"
-              on-tap="_handleCommentReply">Reply</gr-button>
-          <gr-button id="quoteBtn" class="action quote"
-              on-tap="_handleCommentQuote">Quote</gr-button>
-          <gr-button id="ackBtn" class="action ack" on-tap="_handleCommentAck">
-            Ack</gr-button>
-          <gr-button id="doneBtn" class="action done" on-tap="_handleCommentDone">
-            Done</gr-button>
-        </div>
         <span id="unresolvedLabel" hidden$="[[!_unresolved]]">Unresolved</span>
+        <div id="actions">
+          <gr-button
+              id="replyBtn"
+              link
+              class="action reply"
+              on-tap="_handleCommentReply">Reply</gr-button>
+          <gr-button
+              id="quoteBtn"
+              link
+              class="action quote"
+              on-tap="_handleCommentQuote">Quote</gr-button>
+          <gr-button
+              id="ackBtn"
+              link
+              class="action ack"
+              on-tap="_handleCommentAck">Ack</gr-button>
+          <gr-button
+              id="doneBtn"
+              link
+              class="action done"
+              on-tap="_handleCommentDone">Done</gr-button>
+        </div>
       </div>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index be88e476..e9ab3d6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -14,8 +14,8 @@
 (function() {
   'use strict';
 
-  var UNRESOLVED_EXPAND_COUNT = 5;
-  var NEWLINE_PATTERN = /\n/g;
+  const UNRESOLVED_EXPAND_COUNT = 5;
+  const NEWLINE_PATTERN = /\n/g;
 
   Polymer({
     is: 'gr-diff-comment-thread',
@@ -30,17 +30,20 @@
       changeNum: String,
       comments: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       locationRange: String,
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
       commentSide: String,
       patchNum: String,
       path: String,
-      projectConfig: Object,
+      projectName: {
+        type: String,
+        observer: '_projectNameChanged',
+      },
       isOnParent: {
         type: Boolean,
         value: false,
@@ -53,6 +56,7 @@
         type: Boolean,
         notify: true,
       },
+      _projectConfig: Object,
     },
 
     behaviors: [
@@ -71,17 +75,17 @@
       'e shift+e': '_handleEKey',
     },
 
-    attached: function() {
-      this._getLoggedIn().then(function(loggedIn) {
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
         this._showActions = loggedIn;
-      }.bind(this));
+      });
       this._setInitialExpandedState();
     },
 
-    addOrEditDraft: function(opt_lineNum, opt_range) {
-      var lastComment = this.comments[this.comments.length - 1] || {};
+    addOrEditDraft(opt_lineNum, opt_range) {
+      const lastComment = this.comments[this.comments.length - 1] || {};
       if (lastComment.__draft) {
-        var commentEl = this._commentElWithDraftID(
+        const commentEl = this._commentElWithDraftID(
             lastComment.id || lastComment.__draftID);
         commentEl.editing = true;
 
@@ -89,25 +93,25 @@
         // actions are available.
         commentEl.collapsed = false;
       } else {
-        var range = opt_range ? opt_range :
+        const range = opt_range ? opt_range :
             lastComment ? lastComment.range : undefined;
-        var unresolved = lastComment ? lastComment.unresolved : undefined;
+        const unresolved = lastComment ? lastComment.unresolved : undefined;
         this.addDraft(opt_lineNum, range, unresolved);
       }
     },
 
-    addDraft: function(opt_lineNum, opt_range, opt_unresolved) {
-      var draft = this._newDraft(opt_lineNum, opt_range);
+    addDraft(opt_lineNum, opt_range, opt_unresolved) {
+      const draft = this._newDraft(opt_lineNum, opt_range);
       draft.__editing = true;
       draft.unresolved = opt_unresolved === false ? opt_unresolved : true;
       this.push('comments', draft);
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _commentsChanged: function(changeRecord) {
+    _commentsChanged(changeRecord) {
       this._orderedComments = this._sortedComments(this.comments);
       if (this._orderedComments.length) {
         this._lastComment = this._getLastComment();
@@ -115,15 +119,15 @@
       }
     },
 
-    _hideActions: function(_showActions, _lastComment) {
+    _hideActions(_showActions, _lastComment) {
       return !_showActions || !_lastComment || !!_lastComment.__draft;
     },
 
-    _getLastComment: function() {
+    _getLastComment() {
       return this._orderedComments[this._orderedComments.length - 1] || {};
     },
 
-    _handleEKey: function(e) {
+    _handleEKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       // Don’t preventDefault in this case because it will render the event
@@ -136,12 +140,12 @@
       }
     },
 
-    _expandCollapseComments: function(actionIsCollapse) {
-      var comments =
+    _expandCollapseComments(actionIsCollapse) {
+      const comments =
           Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
-      comments.forEach(function(comment) {
+      for (const comment of comments) {
         comment.collapsed = actionIsCollapse;
-      });
+      }
     },
 
     /**
@@ -149,33 +153,32 @@
      * {UNRESOLVED_EXPAND_COUNT} comments expanded by default if the
      * thread is unresolved.
      */
-    _setInitialExpandedState: function() {
-      var comment;
+    _setInitialExpandedState() {
+      let comment;
       if (this._orderedComments) {
-        for (var i = 0; i < this._orderedComments.length; i++) {
+        for (let i = 0; i < this._orderedComments.length; i++) {
           comment = this._orderedComments[i];
           comment.collapsed =
               this._orderedComments.length - i - 1 >= UNRESOLVED_EXPAND_COUNT ||
               !this._unresolved;
         }
       }
-
     },
 
-    _sortedComments: function(comments) {
-      return comments.slice().sort(function(c1, c2) {
-        var c1Date = c1.__date || util.parseDate(c1.updated);
-        var c2Date = c2.__date || util.parseDate(c2.updated);
-        var dateCompare = c1Date - c2Date;
+    _sortedComments(comments) {
+      return comments.slice().sort((c1, c2) => {
+        const c1Date = c1.__date || util.parseDate(c1.updated);
+        const c2Date = c2.__date || util.parseDate(c2.updated);
+        const dateCompare = c1Date - c2Date;
         if (!c1.id || !c1.id.localeCompare) { return 0; }
         // If same date, fall back to sorting by id.
         return dateCompare ? dateCompare : c1.id.localeCompare(c2.id);
       });
     },
 
-    _createReplyComment: function(parent, content, opt_isEditing,
+    _createReplyComment(parent, content, opt_isEditing,
         opt_unresolved) {
-      var reply = this._newReply(
+      const reply = this._newReply(
           this._orderedComments[this._orderedComments.length - 1].id,
           parent.line,
           content,
@@ -184,7 +187,7 @@
 
       // If there is currently a comment in an editing state, add an attribute
       // so that the gr-diff-comment knows not to populate the draft text.
-      for (var i = 0; i < this.comments.length; i++) {
+      for (let i = 0; i < this.comments.length; i++) {
         if (this.comments[i].__editing) {
           reply.__otherEditing = true;
           break;
@@ -199,62 +202,69 @@
 
       if (!opt_isEditing) {
         // Allow the reply to render in the dom-repeat.
-        this.async(function() {
-          var commentEl = this._commentElWithDraftID(reply.__draftID);
+        this.async(() => {
+          const commentEl = this._commentElWithDraftID(reply.__draftID);
           commentEl.save();
         }, 1);
       }
     },
 
-    _processCommentReply: function(opt_quote) {
-      var comment = this._lastComment;
-      var quoteStr;
+    _isDraft(comment) {
+      return !!comment.__draft;
+    },
+
+    /**
+     * @param {boolean=} opt_quote
+     */
+    _processCommentReply(opt_quote) {
+      const comment = this._lastComment;
+      let quoteStr;
       if (opt_quote) {
-        var msg = comment.message;
+        const msg = comment.message;
         quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
       }
       this._createReplyComment(comment, quoteStr, true, comment.unresolved);
     },
 
-    _handleCommentReply: function(e) {
+    _handleCommentReply(e) {
       this._processCommentReply();
     },
 
-    _handleCommentQuote: function(e) {
+    _handleCommentQuote(e) {
       this._processCommentReply(true);
     },
 
-    _handleCommentAck: function(e) {
-      var comment = this._lastComment;
+    _handleCommentAck(e) {
+      const comment = this._lastComment;
       this._createReplyComment(comment, 'Ack', false, false);
     },
 
-    _handleCommentDone: function(e) {
-      var comment = this._lastComment;
+    _handleCommentDone(e) {
+      const comment = this._lastComment;
       this._createReplyComment(comment, 'Done', false, false);
     },
 
-    _handleCommentFix: function(e) {
-      var comment = e.detail.comment;
-      var msg = comment.message;
-      var quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
-      var response = quoteStr + 'Please Fix';
+    _handleCommentFix(e) {
+      const comment = e.detail.comment;
+      const msg = comment.message;
+      const quoteStr = '> ' + msg.replace(NEWLINE_PATTERN, '\n> ') + '\n\n';
+      const response = quoteStr + 'Please Fix';
       this._createReplyComment(comment, response, false, true);
     },
 
-    _commentElWithDraftID: function(id) {
-      var els = Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
-      for (var i = 0; i < els.length; i++) {
-        if (els[i].comment.id === id || els[i].comment.__draftID === id) {
-          return els[i];
+    _commentElWithDraftID(id) {
+      const els = Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
+      for (const el of els) {
+        if (el.comment.id === id || el.comment.__draftID === id) {
+          return el;
         }
       }
       return null;
     },
 
-    _newReply: function(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
-          opt_range) {
-      var d = this._newDraft(opt_lineNum);
+    _newReply(inReplyTo, opt_lineNum, opt_message, opt_unresolved,
+        opt_range) {
+      const d = this._newDraft(opt_lineNum);
       d.in_reply_to = inReplyTo;
       d.range = opt_range;
       if (opt_message != null) {
@@ -266,8 +276,12 @@
       return d;
     },
 
-    _newDraft: function(opt_lineNum, opt_range) {
-      var d = {
+    /**
+     * @param {number=} opt_lineNum
+     * @param {!Object=} opt_range
+     */
+    _newDraft(opt_lineNum, opt_range) {
+      const d = {
         __draft: true,
         __draftID: Math.random().toString(36),
         __date: new Date(),
@@ -290,15 +304,15 @@
       return d;
     },
 
-    _getSide: function(isOnParent) {
+    _getSide(isOnParent) {
       if (isOnParent) { return 'PARENT'; }
       return 'REVISION';
     },
 
-    _handleCommentDiscard: function(e) {
-      var diffCommentEl = Polymer.dom(e).rootTarget;
-      var comment = diffCommentEl.comment;
-      var idx = this._indexOf(comment, this.comments);
+    _handleCommentDiscard(e) {
+      const diffCommentEl = Polymer.dom(e).rootTarget;
+      const comment = diffCommentEl.comment;
+      const idx = this._indexOf(comment, this.comments);
       if (idx == -1) {
         throw Error('Cannot find comment ' +
             JSON.stringify(diffCommentEl.comment));
@@ -310,23 +324,23 @@
 
       // Check to see if there are any other open comments getting edited and
       // set the local storage value to its message value.
-      for (var i = 0; i < this.comments.length; i++) {
-        if (this.comments[i].__editing) {
-          var commentLocation = {
+      for (const changeComment of this.comments) {
+        if (changeComment.__editing) {
+          const commentLocation = {
             changeNum: this.changeNum,
             patchNum: this.patchNum,
-            path: this.comments[i].path,
-            line: this.comments[i].line,
+            path: changeComment.path,
+            line: changeComment.line,
           };
           return this.$.storage.setDraftComment(commentLocation,
-              this.comments[i].message);
+              changeComment.message);
         }
       }
     },
 
-    _handleCommentUpdate: function(e) {
-      var comment = e.detail.comment;
-      var index = this._indexOf(comment, this.comments);
+    _handleCommentUpdate(e) {
+      const comment = e.detail.comment;
+      const index = this._indexOf(comment, this.comments);
       if (index === -1) {
         // This should never happen: comment belongs to another thread.
         console.warn('Comment update for another comment thread.');
@@ -335,9 +349,9 @@
       this.set(['comments', index], comment);
     },
 
-    _indexOf: function(comment, arr) {
-      for (var i = 0; i < arr.length; i++) {
-        var c = arr[i];
+    _indexOf(comment, arr) {
+      for (let i = 0; i < arr.length; i++) {
+        const c = arr[i];
         if ((c.__draftID != null && c.__draftID == comment.__draftID) ||
             (c.id != null && c.id == comment.id)) {
           return i;
@@ -346,8 +360,19 @@
       return -1;
     },
 
-    _computeHostClass: function(unresolved) {
+    _computeHostClass(unresolved) {
       return unresolved ? 'unresolved' : '';
     },
+
+    /**
+     * Load the project config when a project name has been provided.
+     * @param {string} name The project name.
+     */
+    _projectNameChanged(name) {
+      if (!name) { return; }
+      this.$.restAPI.getProjectConfig(name).then(config => {
+        this._projectConfig = config;
+      });
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index 546308e..c96c031 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-comment-thread.html">
 
 <script>void(0);</script>
@@ -40,25 +40,25 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-comment-thread tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-comment-thread tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('comments are sorted correctly', function() {
-      var comments = [
+    test('comments are sorted correctly', () => {
+      const comments = [
         {
           id: 'jacks_reply',
           message: 'i like you, too',
@@ -86,9 +86,9 @@
           id: 'sallys_mission',
           message: 'i have to find santa',
           updated: '2015-12-24 15:00:20.396000000',
-        }
+        },
       ];
-      var results = element._sortedComments(comments);
+      const results = element._sortedComments(comments);
       assert.deepEqual(results, [
         {
           id: 'sally_to_dr_finklestein',
@@ -117,11 +117,11 @@
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        }
+        },
       ]);
     });
 
-    test('addOrEditDraft w/ edit draft', function() {
+    test('addOrEditDraft w/ edit draft', () => {
       element.comments = [{
         id: 'jacks_reply',
         message: 'i like you, too',
@@ -129,9 +129,9 @@
         updated: '2015-12-25 15:00:20.396000000',
         __draft: true,
       }];
-      var commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-          function() { return {}; });
-      var addDraftStub = sandbox.stub(element, 'addDraft');
+      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
+          () => { return {}; });
+      const addDraftStub = sandbox.stub(element, 'addDraft');
 
       element.addOrEditDraft(123);
 
@@ -139,11 +139,11 @@
       assert.isFalse(addDraftStub.called);
     });
 
-    test('addOrEditDraft w/o edit draft', function() {
+    test('addOrEditDraft w/o edit draft', () => {
       element.comments = [];
-      var commentElStub = sandbox.stub(element, '_commentElWithDraftID',
-          function() { return {}; });
-      var addDraftStub = sandbox.stub(element, 'addDraft');
+      const commentElStub = sandbox.stub(element, '_commentElWithDraftID',
+          () => { return {}; });
+      const addDraftStub = sandbox.stub(element, 'addDraft');
 
       element.addOrEditDraft(123);
 
@@ -151,40 +151,52 @@
       assert.isTrue(addDraftStub.called);
     });
 
-    test('_hideActions', function() {
-      var showActions = true;
-      var lastComment = {};
+    test('_hideActions', () => {
+      let showActions = true;
+      const lastComment = {};
       assert.equal(element._hideActions(showActions, lastComment), false);
       showActions = false;
       assert.equal(element._hideActions(showActions, lastComment), true);
-      var showActions = true;
+      showActions = true;
       lastComment.__draft = true;
       assert.equal(element._hideActions(showActions, lastComment), true);
     });
+
+    test('setting project name loads the project config', done => {
+      const projectName = 'foo/bar/baz';
+      const getProjectStub = sandbox.stub(element.$.restAPI, 'getProjectConfig')
+          .returns(Promise.resolve({}));
+      element.projectName = projectName;
+      flush(() => {
+        assert.isTrue(getProjectStub.calledWithExactly(projectName));
+        done();
+      });
+    });
   });
 
-  suite('comment action tests', function() {
-    var element;
+  suite('comment action tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
-        saveDiffDraft: function() {
+        getLoggedIn() { return Promise.resolve(false); },
+        saveDiffDraft() {
           return Promise.resolve({
             ok: true,
-            text: function() { return Promise.resolve(')]}\'\n' +
-                JSON.stringify({
-                  id: '7afa4931_de3d65bd',
-                  path: '/path/to/file.txt',
-                  line: 5,
-                  in_reply_to: 'baf0414d_60047215',
-                  updated: '2015-12-21 02:01:10.850000000',
-                  message: 'Done',
-                }));
+            text() {
+              return Promise.resolve(')]}\'\n' +
+                  JSON.stringify({
+                    id: '7afa4931_de3d65bd',
+                    path: '/path/to/file.txt',
+                    line: 5,
+                    in_reply_to: 'baf0414d_60047215',
+                    updated: '2015-12-21 02:01:10.850000000',
+                    message: 'Done',
+                  }));
             },
           });
         },
-        deleteDiffDraft: function() { return Promise.resolve({ok: true}); },
+        deleteDiffDraft() { return Promise.resolve({ok: true}); },
       });
       element = fixture('withComment');
       element.comments = [{
@@ -200,15 +212,15 @@
       flushAsynchronousOperations();
     });
 
-    test('reply', function(done) {
-      var commentEl = element.$$('gr-diff-comment');
+    test('reply', done => {
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
 
-      var replyBtn = element.$.replyBtn;
+      const replyBtn = element.$.replyBtn;
       MockInteractions.tap(replyBtn);
       flushAsynchronousOperations();
 
-      var drafts = element._orderedComments.filter(function(c) {
+      const drafts = element._orderedComments.filter(c => {
         return c.__draft == true;
       });
       assert.equal(drafts.length, 1);
@@ -217,16 +229,16 @@
       done();
     });
 
-    test('quote reply', function(done) {
-      var commentEl = element.$$('gr-diff-comment');
+    test('quote reply', done => {
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
 
-      var quoteBtn = element.$.quoteBtn;
+      const quoteBtn = element.$.quoteBtn;
       MockInteractions.tap(quoteBtn);
       flushAsynchronousOperations();
 
-      var drafts = element._orderedComments.filter(function(c) {
-          return c.__draft == true;
+      const drafts = element._orderedComments.filter(c => {
+        return c.__draft == true;
       });
       assert.equal(drafts.length, 1);
       assert.equal(drafts[0].message, '> is this a crossover episode!?\n\n');
@@ -234,7 +246,7 @@
       done();
     });
 
-    test('quote reply multiline', function(done) {
+    test('quote reply multiline', done => {
       element.comments = [{
         author: {
           name: 'Mr. Peanutbutter',
@@ -247,14 +259,14 @@
       }];
       flushAsynchronousOperations();
 
-      var commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
 
-      var quoteBtn = element.$.quoteBtn;
+      const quoteBtn = element.$.quoteBtn;
       MockInteractions.tap(quoteBtn);
       flushAsynchronousOperations();
 
-      var drafts = element._orderedComments.filter(function(c) {
+      const drafts = element._orderedComments.filter(c => {
         return c.__draft == true;
       });
       assert.equal(drafts.length, 1);
@@ -264,17 +276,17 @@
       done();
     });
 
-    test('ack', function(done) {
+    test('ack', done => {
       element.changeNum = '42';
       element.patchNum = '1';
 
-      var commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
 
-      var ackBtn = element.$.ackBtn;
+      const ackBtn = element.$.ackBtn;
       MockInteractions.tap(ackBtn);
-      flush(function() {
-        var drafts = element.comments.filter(function(c) {
+      flush(() => {
+        const drafts = element.comments.filter(c => {
           return c.__draft == true;
         });
         assert.equal(drafts.length, 1);
@@ -285,16 +297,16 @@
       });
     });
 
-    test('done', function(done) {
+    test('done', done => {
       element.changeNum = '42';
       element.patchNum = '1';
-      var commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
 
-      var doneBtn = element.$.doneBtn;
+      const doneBtn = element.$.doneBtn;
       MockInteractions.tap(doneBtn);
-      flush(function() {
-        var drafts = element.comments.filter(function(c) {
+      flush(() => {
+        const drafts = element.comments.filter(c => {
           return c.__draft == true;
         });
         assert.equal(drafts.length, 1);
@@ -305,13 +317,13 @@
       });
     });
 
-    test('please fix', function(done) {
+    test('please fix', done => {
       element.changeNum = '42';
       element.patchNum = '1';
-      var commentEl = element.$$('gr-diff-comment');
+      const commentEl = element.$$('gr-diff-comment');
       assert.ok(commentEl);
-      commentEl.addEventListener('create-fix-comment', function() {
-        var drafts = element._orderedComments.filter(function(c) {
+      commentEl.addEventListener('create-fix-comment', () => {
+        const drafts = element._orderedComments.filter(c => {
           return c.__draft == true;
         });
         assert.equal(drafts.length, 1);
@@ -325,21 +337,21 @@
           {bubbles: false});
     });
 
-    test('discard', function(done) {
+    test('discard', done => {
       element.changeNum = '42';
       element.patchNum = '1';
       element.push('comments', element._newReply(
-        element.comments[0].id,
-        element.comments[0].line,
-        element.comments[0].path,
-        'it’s pronouced jiff, not giff'));
+          element.comments[0].id,
+          element.comments[0].line,
+          element.comments[0].path,
+          'it’s pronouced jiff, not giff'));
       flushAsynchronousOperations();
 
-      var draftEl =
+      const draftEl =
           Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
       assert.ok(draftEl);
-      draftEl.addEventListener('comment-discard', function() {
-        var drafts = element.comments.filter(function(c) {
+      draftEl.addEventListener('comment-discard', () => {
+        const drafts = element.comments.filter(c => {
           return c.__draft == true;
         });
         assert.equal(drafts.length, 0);
@@ -348,9 +360,7 @@
       draftEl.fire('comment-discard', null, {bubbles: false});
     });
 
-    test('first editing comment does not add __otherEditing attribute',
-        function() {
-      var commentEl = element.$$('gr-diff-comment');
+    test('first editing comment does not add __otherEditing attribute', () => {
       element.comments = [{
         author: {
           name: 'Mr. Peanutbutter',
@@ -363,19 +373,19 @@
         __draft: true,
       }];
 
-      var replyBtn = element.$.replyBtn;
+      const replyBtn = element.$.replyBtn;
       MockInteractions.tap(replyBtn);
       flushAsynchronousOperations();
 
-      var editing = element._orderedComments.filter(function(c) {
+      const editing = element._orderedComments.filter(c => {
         return c.__editing == true;
       });
       assert.equal(editing.length, 1);
       assert.equal(!!editing[0].__otherEditing, false);
     });
 
-    test('When not editing other comments, local storage not set after discard',
-        function(done) {
+    test('When not editing other comments, local storage not set' +
+        ' after discard', done => {
       element.changeNum = '42';
       element.patchNum = '1';
       element.comments = [{
@@ -413,13 +423,13 @@
         updated: '2015-12-08 19:48:33.843000000',
         __draft: true,
       }];
-      var storageStub = sinon.stub(element.$.storage, 'setDraftComment');
+      const storageStub = sinon.stub(element.$.storage, 'setDraftComment');
       flushAsynchronousOperations();
 
-      var draftEl =
-          Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
+      const draftEl =
+      Polymer.dom(element.root).querySelectorAll('gr-diff-comment')[1];
       assert.ok(draftEl);
-      draftEl.addEventListener('comment-discard', function() {
+      draftEl.addEventListener('comment-discard', () => {
         assert.isFalse(storageStub.called);
         storageStub.restore();
         done();
@@ -427,9 +437,9 @@
       draftEl.fire('comment-discard', null, {bubbles: false});
     });
 
-    test('comment-update', function() {
-      var commentEl = element.$$('gr-diff-comment');
-      var updatedComment = {
+    test('comment-update', () => {
+      const commentEl = element.$$('gr-diff-comment');
+      const updatedComment = {
         id: element.comments[0].id,
         foo: 'bar',
       };
@@ -437,88 +447,81 @@
       assert.strictEqual(element.comments[0], updatedComment);
     });
 
-    suite('jack and sally comment data test consolidation', function() {
-      var getComments = function() {
-        return Polymer.dom(element.root).querySelectorAll('gr-diff-comment');
-      };
-
-      setup(function() {
+    suite('jack and sally comment data test consolidation', () => {
+      setup(() => {
         element.comments = [
-        {
-          id: 'jacks_reply',
-          message: 'i like you, too',
-          in_reply_to: 'sallys_confession',
-          updated: '2015-12-25 15:00:20.396000000',
-          unresolved: false,
-        }, {
-          id: 'sallys_confession',
-          in_reply_to: 'nonexistent_comment',
-          message: 'i like you, jack',
-          updated: '2015-12-24 15:00:20.396000000',
-        }, {
-          id: 'sally_to_dr_finklestein',
-          in_reply_to: 'nonexistent_comment',
-          message: 'i’m running away',
-          updated: '2015-10-31 09:00:20.396000000',
-        }, {
-          id: 'sallys_defiance',
-          message: 'i will poison you so i can get away',
-          updated: '2015-10-31 15:00:20.396000000',
-        }];
+          {
+            id: 'jacks_reply',
+            message: 'i like you, too',
+            in_reply_to: 'sallys_confession',
+            updated: '2015-12-25 15:00:20.396000000',
+            unresolved: false,
+          }, {
+            id: 'sallys_confession',
+            in_reply_to: 'nonexistent_comment',
+            message: 'i like you, jack',
+            updated: '2015-12-24 15:00:20.396000000',
+          }, {
+            id: 'sally_to_dr_finklestein',
+            in_reply_to: 'nonexistent_comment',
+            message: 'i’m running away',
+            updated: '2015-10-31 09:00:20.396000000',
+          }, {
+            id: 'sallys_defiance',
+            message: 'i will poison you so i can get away',
+            updated: '2015-10-31 15:00:20.396000000',
+          }];
       });
 
-      test('orphan replies', function() {
+      test('orphan replies', () => {
         assert.equal(4, element._orderedComments.length);
       });
 
-      test('keyboard shortcuts', function() {
-        var expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
+      test('keyboard shortcuts', () => {
+        const expandCollapseStub =
+            sinon.stub(element, '_expandCollapseComments');
         MockInteractions.pressAndReleaseKeyOn(element, 69, null, 'e');
         assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
 
         MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift', 'e');
         assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
-        expandCollapseStub.restore();
       });
 
-      test('comment in_reply_to is either null or most recent comment id',
-          function() {
+      test('comment in_reply_to is either null or most recent comment', () => {
         element._createReplyComment(element.comments[3], 'dummy', true);
         flushAsynchronousOperations();
         assert.equal(element._orderedComments.length, 5);
         assert.equal(element._orderedComments[4].in_reply_to, 'jacks_reply');
       });
 
-      test('resolvable comments', function() {
+      test('resolvable comments', () => {
         assert.isFalse(element._unresolved);
         element._createReplyComment(element.comments[3], 'dummy', true, true);
         flushAsynchronousOperations();
         assert.isTrue(element._unresolved);
       });
 
-      test('_setInitialExpandedState', function() {
+      test('_setInitialExpandedState', () => {
         element._unresolved = true;
         element._setInitialExpandedState();
-        var comments = getComments();
-        for (var i = 0; i < element.comments.length; i++) {
+        for (let i = 0; i < element.comments.length; i++) {
           assert.isFalse(element.comments[i].collapsed);
         }
         element._unresolved = false;
         element._setInitialExpandedState();
-        var comments = getComments();
-        for (var i = 0; i < element.comments.length; i++) {
+        for (let i = 0; i < element.comments.length; i++) {
           assert.isTrue(element.comments[i].collapsed);
         }
       });
     });
 
-    test('_computeHostClass', function() {
+    test('_computeHostClass', () => {
       assert.equal(element._computeHostClass(true), 'unresolved');
       assert.equal(element._computeHostClass(false), '');
     });
 
-    test('addDraft sets unresolved state correctly', function() {
-      var unresolved = true;
+    test('addDraft sets unresolved state correctly', () => {
+      let unresolved = true;
       element.comments = [];
       element.addDraft(null, null, unresolved);
       assert.equal(element.comments[0].unresolved, true);
@@ -533,15 +536,15 @@
       assert.equal(element.comments[0].unresolved, true);
     });
 
-    test('_newDraft', function() {
+    test('_newDraft', () => {
       element.commentSide = 'left';
       element.patchNum = 3;
-      var draft = element._newDraft();
+      const draft = element._newDraft();
       assert.equal(draft.__commentSide, 'left');
       assert.equal(draft.patchNum, 3);
     });
 
-    test('new comment gets created', function() {
+    test('new comment gets created', () => {
       element.comments = [];
       element.addOrEditDraft(1);
       assert.equal(element.comments.length, 1);
@@ -552,7 +555,7 @@
       assert.equal(element.comments.length, 2);
     });
 
-    test('unresolved label', function() {
+    test('unresolved label', () => {
       element._unresolved = false;
       assert.isTrue(element.$.unresolvedLabel.hasAttribute('hidden'));
       element._unresolved = true;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index 4c00132..d58b6be 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -15,33 +15,41 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../shared/gr-textarea/gr-textarea.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-confirm-dialog/gr-confirm-dialog.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-formatted-text/gr-formatted-text.html">
-<link rel="import" href="../../shared/gr-storage/gr-storage.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-storage/gr-storage.html">
 <link rel="import" href="../../shared/gr-tooltip-content/gr-tooltip-content.html">
+<link rel="import" href="../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
+<script src="../../../scripts/rootElement.js"></script>
 
 <dom-module id="gr-diff-comment">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
         font-family: var(--font-family);
         padding: .7em .7em;
         --iron-autogrow-textarea: {
+          box-sizing: border-box;
           padding: 2px;
         };
       }
-      :host[disabled] {
+      :host([disabled]) {
         pointer-events: none;
       }
-      :host[disabled] .container {
+      :host([disabled]) .container {
         opacity: .5;
       }
-      :host[is-robot-comment] {
+      :host([is-robot-comment]) {
         background-color: #cfe8fc;
       }
       .header {
@@ -60,18 +68,20 @@
         overflow: hidden;
       }
       .authorName,
-      .draftLabel {
-        display: block;
-        float: left;
-        font-weight: bold;
+      .draftLabel,
+      .draftTooltip {
+        font-family: var(--font-family-bold);
       }
-      .draftLabel {
+      .draftLabel,
+      .draftTooltip {
         color: #999;
         display: none;
       }
       .date {
         justify-content: flex-end;
         margin-left: 5px;
+        min-width: 4.5em;
+        text-align: right;
         white-space: nowrap;
       }
       a.date:link,
@@ -80,10 +90,19 @@
       }
       .actions {
         display: flex;
+        justify-content: flex-end;
         padding-top: 0;
       }
       .action {
-        margin-right: .5em;
+        margin-left: 1em;
+        --gr-button: {
+          color: #212121;
+        }
+        --gr-button-hover-color: rgba(33, 33, 33, .75);
+      }
+      .rightActions {
+        display: flex;
+        justify-content: flex-end;
       }
       .editMessage {
         display: none;
@@ -99,7 +118,8 @@
       .draft .done {
         display: none;
       }
-      .draft .draftLabel {
+      .draft .draftLabel,
+      .draft .draftTooltip {
         display: inline;
       }
       .draft:not(.editing) .save,
@@ -117,7 +137,6 @@
         display: none;
       }
       .editing .editMessage {
-        background-color: #fff;
         display: block;
       }
       .show-hide {
@@ -163,7 +182,7 @@
       }
       #container.collapsed .actions,
       #container.collapsed gr-formatted-text,
-      #container.collapsed iron-autogrow-textarea {
+      #container.collapsed gr-textarea {
         display: none;
       }
       .resolve,
@@ -171,12 +190,25 @@
         align-items: center;
         display: flex;
         flex: 1;
-        justify-content: flex-end;
+        margin: 0;
       }
       .resolve label {
         color: #333;
         font-size: 12px;
       }
+      gr-confirm-dialog .main {
+        background-color: #fef;
+        display: flex;
+        flex-direction: column;
+        width: 100%;
+      }
+      #deleteBtn {
+        color: #666;
+        display: none;
+      }
+      #deleteBtn.showDeleteButtons {
+        display: block;
+      }
     </style>
     <div id="container"
         class="container"
@@ -185,17 +217,23 @@
       <div class="header" id="header" on-click="_handleToggleCollapsed">
         <div class="headerLeft">
           <span class="authorName">[[comment.author.name]]</span>
-          <gr-tooltip-content class="draftLabel"
+          <span class="draftLabel">DRAFT</span>
+          <gr-tooltip-content class="draftTooltip"
               has-tooltip
               title="This draft is only visible to you. To publish drafts, click the red 'Reply' button at the top of the change or press the 'A' key."
               max-width="20em"
-              show-icon>
-            DRAFT
-          </gr-tooltip-content>
+              show-icon></gr-tooltip-content>
         </div>
         <div class="headerMiddle">
           <span class="collapsedContent">[[comment.message]]</span>
         </div>
+        <gr-button
+            id="deleteBtn"
+            link
+            class$="action delete [[_computeDeleteButtonClass(_isAdmin, draft)]]"
+            on-tap="_handleCommentDelete">
+          (Delete)
+        </gr-button>
         <a class="date" href$="[[_computeLinkToComment(comment)]]" on-tap="_handleLinkTap">
           <gr-date-formatter
               has-tooltip
@@ -215,14 +253,14 @@
           [[comment.robot_id]]
         </div>
       </template>
-      <iron-autogrow-textarea
+      <gr-textarea
           id="editTextarea"
           class="editMessage"
           autocomplete="on"
+          monospace
           disabled="{{disabled}}"
           rows="4"
-          bind-value="{{_messageText}}"
-          on-keydown="_handleTextareaKeydown"></iron-autogrow-textarea>
+          text="{{_messageText}}"></gr-textarea>
       <gr-formatted-text class="message"
           content="[[comment.message]]"
           no-trailing-margin="[[!comment.__draft]]"
@@ -237,14 +275,6 @@
         </div>
       </div>
       <div class="actions humanActions" hidden$="[[!_showHumanActions]]">
-        <gr-button class="action edit hideOnPublished" on-tap="_handleEdit">
-            Edit</gr-button>
-        <gr-button class="action save hideOnPublished" on-tap="_handleSave"
-            disabled$="[[_computeSaveDisabled(_messageText)]]">Save</gr-button>
-        <gr-button class="action cancel hideOnPublished"
-            on-tap="_handleCancel" hidden>Cancel</gr-button>
-        <gr-button class="action discard hideOnPublished"
-            on-tap="_handleDiscard">Discard</gr-button>
         <div class="action resolve hideOnPublished">
           <label>
             <input type="checkbox"
@@ -256,15 +286,47 @@
         <div class="action unresolved hideOnPublished" hidden$="[[resolved]]">
           Unresolved
         </div>
+        <div class="rightActions">
+          <gr-button link class="action cancel hideOnPublished"
+              on-tap="_handleCancel" hidden>Cancel</gr-button>
+          <gr-button link class="action discard hideOnPublished"
+              on-tap="_handleDiscard">Discard</gr-button>
+          <gr-button link class="action edit hideOnPublished"
+              on-tap="_handleEdit">Edit</gr-button>
+          <gr-button link class="action save hideOnPublished"
+              on-tap="_handleSave"
+              disabled$="[[_computeSaveDisabled(_messageText)]]">Save
+          </gr-button>
+        </div>
       </div>
       <div class="actions robotActions" hidden$="[[!_showRobotActions]]">
-        <gr-button class="action fix"
+        <gr-button link class="action fix"
             on-tap="_handleFix"
             disabled="[[robotButtonDisabled]]">
           Please Fix
         </gr-button>
       </div>
     </div>
+    <gr-overlay id="confirmDeleteOverlay" with-backdrop>
+      <gr-confirm-delete-comment-dialog id="confirmDeleteComment"
+          on-confirm="_handleConfirmDeleteComment"
+          on-cancel="_handleCancelDeleteComment">
+      </gr-confirm-delete-comment-dialog>
+    </gr-overlay>
+    <gr-overlay id="confirmDiscardOverlay" with-backdrop>
+      <gr-confirm-dialog
+          id="confirmDiscaDialog"
+          confirm-label="Discard"
+          on-confirm="_handleConfirmDiscard"
+          on-cancel="_closeConfirmDiscardOverlay">
+        <div class="header">
+          Discard comment
+        </div>
+        <div class="main">
+          Are you sure you want to discard this draft comment?
+        </div>
+      </gr-confirm-dialog>
+    </gr-overlay>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
   </template>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index 0791193..c02aec5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -14,7 +14,13 @@
 (function() {
   'use strict';
 
-  var STORAGE_DEBOUNCE_INTERVAL = 400;
+  const STORAGE_DEBOUNCE_INTERVAL = 400;
+  const TOAST_DEBOUNCE_INTERVAL = 200;
+
+  const SAVING_MESSAGE = 'Saving';
+  const DRAFT_SINGULAR = 'draft...';
+  const DRAFT_PLURAL = 'drafts...';
+  const SAVED_MESSAGE = 'All changes saved';
 
   Polymer({
     is: 'gr-diff-comment',
@@ -53,6 +59,7 @@
 
     properties: {
       changeNum: String,
+      /** @type {?} */
       comment: {
         type: Object,
         notify: true,
@@ -88,10 +95,15 @@
         value: true,
         observer: '_toggleCollapseClass',
       },
+      /** @type {?} */
       projectConfig: Object,
       robotButtonDisabled: Boolean,
+      _isAdmin: {
+        type: Boolean,
+        value: false,
+      },
 
-      _xhrPromise: Object,  // Used for testing.
+      _xhrPromise: Object, // Used for testing.
       _messageText: {
         type: String,
         value: '',
@@ -103,6 +115,11 @@
         type: Boolean,
         observer: '_toggleResolved',
       },
+
+      _numPendingDiffRequests: {
+        type: Object,
+        value: {number: 0}, // Intentional to share the object across instances.
+      },
     },
 
     observers: [
@@ -112,48 +129,65 @@
       '_calculateActionstoShow(showActions, isRobotComment)',
     ],
 
-    attached: function() {
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    keyBindings: {
+      'ctrl+enter meta+enter ctrl+s meta+s': '_handleSaveKey',
+      'esc': '_handleEsc',
+    },
+
+    attached() {
       if (this.editing) {
         this.collapsed = false;
       } else if (this.comment) {
         this.collapsed = this.comment.collapsed;
       }
+      this._getIsAdmin().then(isAdmin => {
+        this._isAdmin = isAdmin;
+      });
     },
 
-    detached: function() {
+    detached() {
       this.cancelDebouncer('fire-update');
+      this.$.editTextarea.closeDropdown();
     },
 
-    _computeShowHideText: function(collapsed) {
+    _computeShowHideText(collapsed) {
       return collapsed ? '◀' : '▼';
     },
 
-    _calculateActionstoShow: function(showActions, isRobotComment) {
+    _calculateActionstoShow(showActions, isRobotComment) {
       this._showHumanActions = showActions && !isRobotComment;
       this._showRobotActions = showActions && isRobotComment;
     },
 
-    _isRobotComment: function(comment) {
+    _isRobotComment(comment) {
       this.isRobotComment = !!comment.robot_id;
     },
 
-    isOnParent: function() {
+    isOnParent() {
       return this.side === 'PARENT';
     },
 
-    save: function() {
+    _getIsAdmin() {
+      return this.$.restAPI.getIsAdmin();
+    },
+
+    save() {
       this.comment.message = this._messageText;
 
       this.disabled = true;
 
       this._eraseDraftComment();
 
-      this._xhrPromise = this._saveDraft(this.comment).then(function(response) {
+      this._xhrPromise = this._saveDraft(this.comment).then(response => {
         this.disabled = false;
         if (!response.ok) { return response; }
 
-        return this.$.restAPI.getResponseObject(response).then(function(obj) {
-          var comment = obj;
+        return this.$.restAPI.getResponseObject(response).then(obj => {
+          const comment = obj;
           comment.__draft = true;
           // Maintain the ephemeral draft ID for identification by other
           // elements.
@@ -165,14 +199,18 @@
           this.editing = false;
           this._fireSave();
           return obj;
-        }.bind(this));
-      }.bind(this)).catch(function(err) {
+        });
+      }).catch(err => {
         this.disabled = false;
         throw err;
-      }.bind(this));
+      });
     },
 
-    _eraseDraftComment: function() {
+    _eraseDraftComment() {
+      // Prevents a race condition in which removing the draft comment occurs
+      // prior to it being saved.
+      this.cancelDebouncer('store');
+
       this.$.storage.eraseDraftComment({
         changeNum: this.changeNum,
         patchNum: this._getPatchNum(),
@@ -182,7 +220,7 @@
       });
     },
 
-    _commentChanged: function(comment) {
+    _commentChanged(comment) {
       this.editing = !!comment.__editing;
       this.resolved = !comment.unresolved;
       if (this.editing) { // It's a new draft/reply, notify.
@@ -190,41 +228,36 @@
       }
     },
 
-    _getEventPayload: function(opt_mixin) {
-      var payload = {
+    /**
+     * @param {!Object=} opt_mixin
+     *
+     * @return {!Object}
+     */
+    _getEventPayload(opt_mixin) {
+      return Object.assign({}, opt_mixin, {
         comment: this.comment,
         patchNum: this.patchNum,
-      };
-      for (var k in opt_mixin) {
-        payload[k] = opt_mixin[k];
-      }
-      return payload;
+      });
     },
 
-    _fireSave: function() {
+    _fireSave() {
       this.fire('comment-save', this._getEventPayload());
     },
 
-    _fireUpdate: function() {
-      this.debounce('fire-update', function() {
+    _fireUpdate() {
+      this.debounce('fire-update', () => {
         this.fire('comment-update', this._getEventPayload());
       });
     },
 
-    _draftChanged: function(draft) {
+    _draftChanged(draft) {
       this.$.container.classList.toggle('draft', draft);
     },
 
-    _editingChanged: function(editing, previousValue) {
+    _editingChanged(editing, previousValue) {
       this.$.container.classList.toggle('editing', editing);
       if (editing) {
-        var textarea = this.$.editTextarea.textarea;
-        // Put the cursor at the end always.
-        textarea.selectionStart = textarea.value.length;
-        textarea.selectionEnd = textarea.selectionStart;
-        this.async(function() {
-          textarea.focus();
-        }.bind(this));
+        this.$.editTextarea.putCursorAtEnd();
       }
       if (this.comment && this.comment.id) {
         this.$$('.cancel').hidden = !editing;
@@ -238,39 +271,37 @@
       }
     },
 
-    _computeLinkToComment: function(comment) {
+    _computeLinkToComment(comment) {
       return '#' + comment.line;
     },
 
-    _computeSaveDisabled: function(draft) {
+    _computeDeleteButtonClass(isAdmin, draft) {
+      return isAdmin && !draft ? 'showDeleteButtons' : '';
+    },
+
+    _computeSaveDisabled(draft) {
       return draft == null || draft.trim() == '';
     },
 
-    _handleTextareaKeydown: function(e) {
-      switch (e.keyCode) {
-        case 13: // 'enter'
-          if (this._messageText.length !== 0 && (e.metaKey || e.ctrlKey)) {
-            this._handleSave(e);
-          }
-          break;
-        case 27: // 'esc'
-          if (this._messageText.length === 0) {
-            this._handleCancel(e);
-          }
-          break;
-        case 83: // 's'
-          if (this._messageText.length !== 0 && e.ctrlKey) {
-            this._handleSave(e);
-          }
-          break;
+    _handleSaveKey(e) {
+      if (this._messageText.length) {
+        e.preventDefault();
+        this._handleSave(e);
       }
     },
 
-    _handleToggleCollapsed: function() {
+    _handleEsc(e) {
+      if (!this._messageText.length) {
+        e.preventDefault();
+        this._handleCancel(e);
+      }
+    },
+
+    _handleToggleCollapsed() {
       this.collapsed = !this.collapsed;
     },
 
-    _toggleCollapseClass: function(collapsed) {
+    _toggleCollapseClass(collapsed) {
       if (collapsed) {
         this.$.container.classList.add('collapsed');
       } else {
@@ -278,20 +309,20 @@
       }
     },
 
-    _commentMessageChanged: function(message) {
+    _commentMessageChanged(message) {
       this._messageText = message || '';
     },
 
-    _messageTextChanged: function(newValue, oldValue) {
+    _messageTextChanged(newValue, oldValue) {
       if (!this.comment || (this.comment && this.comment.id)) { return; }
 
       // Keep comment.message in sync so that gr-diff-comment-thread is aware
       // of the current message in the case that another comment is deleted.
       this.comment.message = this._messageText || '';
-      this.debounce('store', function() {
-        var message = this._messageText;
+      this.debounce('store', () => {
+        const message = this._messageText;
 
-        var commentLocation = {
+        const commentLocation = {
           changeNum: this.changeNum,
           patchNum: this._getPatchNum(),
           path: this.comment.path,
@@ -310,9 +341,9 @@
       }, STORAGE_DEBOUNCE_INTERVAL);
     },
 
-    _handleLinkTap: function(e) {
+    _handleLinkTap(e) {
       e.preventDefault();
-      var hash = this._computeLinkToComment(this.comment);
+      const hash = this._computeLinkToComment(this.comment);
       // Don't add the hash to the window history if it's already there.
       // Otherwise you mess up expected back button behavior.
       if (window.location.hash == hash) { return; }
@@ -321,52 +352,51 @@
       page.show(window.location.pathname + hash, null, false);
     },
 
-    _handleReply: function(e) {
+    _handleReply(e) {
       e.preventDefault();
       this.fire('create-reply-comment', this._getEventPayload(),
           {bubbles: false});
     },
 
-    _handleQuote: function(e) {
+    _handleQuote(e) {
       e.preventDefault();
       this.fire('create-reply-comment', this._getEventPayload({quote: true}),
           {bubbles: false});
     },
 
-    _handleFix: function(e) {
+    _handleFix(e) {
       e.preventDefault();
       this.fire('create-fix-comment', this._getEventPayload({quote: true}),
           {bubbles: false});
     },
 
-    _handleAck: function(e) {
+    _handleAck(e) {
       e.preventDefault();
       this.fire('create-ack-comment', this._getEventPayload(),
           {bubbles: false});
     },
 
-    _handleDone: function(e) {
+    _handleDone(e) {
       e.preventDefault();
       this.fire('create-done-comment', this._getEventPayload(),
           {bubbles: false});
     },
 
-    _handleEdit: function(e) {
+    _handleEdit(e) {
       e.preventDefault();
       this._messageText = this.comment.message;
       this.editing = true;
     },
 
-    _handleSave: function(e) {
+    _handleSave(e) {
       e.preventDefault();
       this.set('comment.__editing', false);
       this.save();
     },
 
-    _handleCancel: function(e) {
+    _handleCancel(e) {
       e.preventDefault();
-      if (!this.comment.message ||
-          this.comment.message.trim().length === 0) {
+      if (!this.comment.message || this.comment.message.trim().length === 0) {
         this._fireDiscard();
         return;
       }
@@ -374,13 +404,27 @@
       this.editing = false;
     },
 
-    _fireDiscard: function() {
+    _fireDiscard() {
       this.cancelDebouncer('fire-update');
       this.fire('comment-discard', this._getEventPayload());
     },
 
-    _handleDiscard: function(e) {
+    _handleDiscard(e) {
       e.preventDefault();
+      if (this._computeSaveDisabled(this._messageText)) {
+        this._discardDraft();
+        return;
+      }
+      this._openOverlay(this.$.confirmDiscardOverlay);
+    },
+
+    _handleConfirmDiscard(e) {
+      e.preventDefault();
+      this._closeConfirmDiscardOverlay();
+      this._discardDraft();
+    },
+
+    _discardDraft() {
       if (!this.comment.__draft) {
         throw Error('Cannot discard a non-draft comment.');
       }
@@ -394,32 +438,74 @@
         return;
       }
 
-      this._xhrPromise = this._deleteDraft(this.comment).then(
-          function(response) {
-            this.disabled = false;
-            if (!response.ok) { return response; }
+      this._xhrPromise = this._deleteDraft(this.comment).then(response => {
+        this.disabled = false;
+        if (!response.ok) { return response; }
 
-            this._fireDiscard();
-          }.bind(this)).catch(function(err) {
-            this.disabled = false;
-            throw err;
-          }.bind(this));
+        this._fireDiscard();
+      }).catch(err => {
+        this.disabled = false;
+        throw err;
+      });
     },
 
-    _saveDraft: function(draft) {
-      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft);
+    _closeConfirmDiscardOverlay() {
+      this._closeOverlay(this.$.confirmDiscardOverlay);
     },
 
-    _deleteDraft: function(draft) {
+    _getSavingMessage(numPending) {
+      if (numPending === 0) { return SAVED_MESSAGE; }
+      return [
+        SAVING_MESSAGE,
+        numPending,
+        numPending === 1 ? DRAFT_SINGULAR : DRAFT_PLURAL,
+      ].join(' ');
+    },
+
+    _showStartRequest() {
+      const numPending = ++this._numPendingDiffRequests.number;
+      this._updateRequestToast(numPending);
+    },
+
+    _showEndRequest() {
+      const numPending = --this._numPendingDiffRequests.number;
+      this._updateRequestToast(numPending);
+    },
+
+    _updateRequestToast(numPending) {
+      const message = this._getSavingMessage(numPending);
+      this.debounce('draft-toast', () => {
+        // Note: the event is fired on the body rather than this element because
+        // this element may not be attached by the time this executes, in which
+        // case the event would not bubble.
+        document.body.dispatchEvent(new CustomEvent('show-alert',
+            {detail: {message}, bubbles: true}));
+      }, TOAST_DEBOUNCE_INTERVAL);
+    },
+
+    _saveDraft(draft) {
+      this._showStartRequest();
+      return this.$.restAPI.saveDiffDraft(this.changeNum, this.patchNum, draft)
+          .then(result => {
+            this._showEndRequest();
+            return result;
+          });
+    },
+
+    _deleteDraft(draft) {
+      this._showStartRequest();
       return this.$.restAPI.deleteDiffDraft(this.changeNum, this.patchNum,
-          draft);
+          draft).then(result => {
+            this._showEndRequest();
+            return result;
+          });
     },
 
-    _getPatchNum: function() {
+    _getPatchNum() {
       return this.isOnParent() ? 'PARENT' : this.patchNum;
     },
 
-    _loadLocalDraft: function(changeNum, patchNum, comment) {
+    _loadLocalDraft(changeNum, patchNum, comment) {
       // Only apply local drafts to comments that haven't been saved
       // remotely, and haven't been given a default message already.
       //
@@ -430,8 +516,8 @@
         return;
       }
 
-      var draft = this.$.storage.getDraftComment({
-        changeNum: changeNum,
+      const draft = this.$.storage.getDraftComment({
+        changeNum,
         patchNum: this._getPatchNum(),
         path: comment.path,
         line: comment.line,
@@ -443,21 +529,50 @@
       }
     },
 
-    _handleMouseEnter: function(e) {
+    _handleMouseEnter(e) {
       this.fire('comment-mouse-over', this._getEventPayload());
     },
 
-    _handleMouseLeave: function(e) {
+    _handleMouseLeave(e) {
       this.fire('comment-mouse-out', this._getEventPayload());
     },
 
-    _handleToggleResolved: function() {
+    _handleToggleResolved() {
       this.resolved = !this.resolved;
     },
 
-    _toggleResolved: function(resolved) {
+    _toggleResolved(resolved) {
       this.comment.unresolved = !resolved;
       this.fire('comment-update', this._getEventPayload());
     },
+
+    _handleCommentDelete() {
+      this._openOverlay(this.$.confirmDeleteOverlay);
+    },
+
+    _handleCancelDeleteComment() {
+      this._closeOverlay(this.$.confirmDeleteOverlay);
+    },
+
+    _openOverlay(overlay) {
+      Polymer.dom(Gerrit.getRootElement()).appendChild(overlay);
+      this.async(() => {
+        overlay.open();
+      }, 1);
+    },
+
+    _closeOverlay(overlay) {
+      Polymer.dom(Gerrit.getRootElement()).removeChild(overlay);
+      overlay.close();
+    },
+
+    _handleConfirmDeleteComment() {
+      this.$.restAPI.deleteComment(
+          this.changeNum, this.patchNum, this.comment.id,
+          this.$.confirmDeleteComment.message).then(newComment => {
+            this._handleCancelDeleteComment();
+            this.comment = newComment;
+          });
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index 919a64f..5793b05 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -20,10 +20,10 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-comment.html">
 
 <script>void(0);</script>
@@ -47,12 +47,12 @@
     return getComputedStyle(el).getPropertyValue('display') !== 'none';
   }
 
-  suite('gr-diff-comment tests', function() {
-    var element;
-    var sandbox;
-    setup(function() {
+  suite('gr-diff-comment tests', () => {
+    let element;
+    let sandbox;
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve(null); },
+        getAccount() { return Promise.resolve(null); },
       });
       element = fixture('basic');
       element.comment = {
@@ -68,18 +68,18 @@
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('collapsible comments', function() {
+    test('collapsible comments', () => {
       // When a comment (not draft) is loaded, it should be collapsed
       assert.isTrue(element.collapsed);
       assert.isFalse(isVisible(element.$$('gr-formatted-text')),
           'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
 
       // The header middle content is only visible when comments are collapsed.
@@ -94,27 +94,26 @@
           'gr-formatted-text is visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isFalse(isVisible(element.$$('.collapsedContent')),
           'header middle content is not visible');
     });
 
-    test('clicking on date link does not trigger nav', function() {
-      var showStub = sinon.stub(page, 'show');
-      var dateEl = element.$$('.date');
+    test('clicking on date link does not trigger nav', () => {
+      const showStub = sinon.stub(page, 'show');
+      const dateEl = element.$$('.date');
       assert.ok(dateEl);
       MockInteractions.tap(dateEl);
-      var dest = window.location.pathname + '#5';
+      const dest = window.location.pathname + '#5';
       assert(showStub.lastCall.calledWithExactly(dest, null, false),
           'Should navigate to ' + dest + ' without triggering nav');
       showStub.restore();
     });
 
-    test('message is not retrieved from storage when other editing is true',
-        function(done) {
-      var storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      var loadSpy = sandbox.spy(element, '_loadLocalDraft');
+    test('message is not retrieved from storage when other edits', done => {
+      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
       element.patchNum = 1;
@@ -126,17 +125,16 @@
         line: 5,
         __otherEditing: true,
       };
-      flush(function() {
+      flush(() => {
         assert.isTrue(loadSpy.called);
         assert.isFalse(storageStub.called);
         done();
       });
     });
 
-    test('message is retrieved from storage when there is no other editing',
-        function(done) {
-      var storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
-      var loadSpy = sandbox.spy(element, '_loadLocalDraft');
+    test('message is retrieved from storage when no other edits', done => {
+      const storageStub = sandbox.stub(element.$.storage, 'getDraftComment');
+      const loadSpy = sandbox.spy(element, '_loadLocalDraft');
 
       element.changeNum = 1;
       element.patchNum = 1;
@@ -147,14 +145,14 @@
         },
         line: 5,
       };
-      flush(function() {
+      flush(() => {
         assert.isTrue(loadSpy.called);
         assert.isTrue(storageStub.called);
         done();
       });
     });
 
-    test('_getPatchNum', function() {
+    test('_getPatchNum', () => {
       element.side = 'PARENT';
       element.patchNum = 1;
       assert.equal(element._getPatchNum(), 'PARENT');
@@ -162,13 +160,13 @@
       assert.equal(element._getPatchNum(), 1);
     });
 
-    test('comment expand and collapse', function() {
+    test('comment expand and collapse', () => {
       element.collapsed = true;
       assert.isFalse(isVisible(element.$$('gr-formatted-text')),
           'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isTrue(isVisible(element.$$('.collapsedContent')),
           'header middle content is visible');
@@ -179,14 +177,14 @@
           'gr-formatted-text is visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isFalse(isVisible(element.$$('.collapsedContent')),
           'header middle content is is not visible');
     });
 
-    suite('while editing', function() {
-      setup(function() {
+    suite('while editing', () => {
+      setup(() => {
         element.editing = true;
         element._messageText = 'test';
         sandbox.stub(element, '_handleCancel');
@@ -194,75 +192,108 @@
         flushAsynchronousOperations();
       });
 
-      suite('when text is empty', function() {
-        setup(function() {
+      suite('when text is empty', () => {
+        setup(() => {
           element._messageText = '';
         });
 
-        test('esc closes comment when text is empty', function() {
+        test('esc closes comment when text is empty', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.$.editTextarea, 27); // esc
           assert.isTrue(element._handleCancel.called);
         });
 
-        test('ctrl+enter does not save', function() {
+        test('ctrl+enter does not save', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.$.editTextarea, 13, 'ctrl'); // ctrl + enter
           assert.isFalse(element._handleSave.called);
         });
 
-        test('meta+enter does not save', function() {
+        test('meta+enter does not save', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.$.editTextarea, 13, 'meta'); // meta + enter
           assert.isFalse(element._handleSave.called);
         });
 
-        test('ctrl+s does not save', function() {
+        test('ctrl+s does not save', () => {
           MockInteractions.pressAndReleaseKeyOn(
               element.$.editTextarea, 83, 'ctrl'); // ctrl + s
           assert.isFalse(element._handleSave.called);
         });
       });
 
-      test('esc does not close comment that has content', function() {
+      test('esc does not close comment that has content', () => {
         MockInteractions.pressAndReleaseKeyOn(
             element.$.editTextarea, 27); // esc
         assert.isFalse(element._handleCancel.called);
       });
 
-      test('ctrl+enter saves', function() {
+      test('ctrl+enter saves', () => {
         MockInteractions.pressAndReleaseKeyOn(
             element.$.editTextarea, 13, 'ctrl'); // ctrl + enter
         assert.isTrue(element._handleSave.called);
       });
 
-      test('meta+enter saves', function() {
+      test('meta+enter saves', () => {
         MockInteractions.pressAndReleaseKeyOn(
             element.$.editTextarea, 13, 'meta'); // meta + enter
         assert.isTrue(element._handleSave.called);
       });
 
-      test('ctrl+s saves', function() {
+      test('ctrl+s saves', () => {
         MockInteractions.pressAndReleaseKeyOn(
             element.$.editTextarea, 83, 'ctrl'); // ctrl + s
         assert.isTrue(element._handleSave.called);
       });
     });
+    test('delete comment button for non-admins is hidden', () => {
+      element._isAdmin = false;
+      assert.isFalse(element.$$('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment button for admins with draft is hidden', () => {
+      element._isAdmin = false;
+      element.draft = true;
+      assert.isFalse(element.$$('.action.delete')
+          .classList.contains('showDeleteButtons'));
+    });
+
+    test('delete comment', done => {
+      sandbox.stub(
+          element.$.restAPI, 'deleteComment').returns(Promise.resolve({}));
+      sandbox.spy(element.$.confirmDeleteOverlay, 'open');
+      element.changeNum = 42;
+      element.patchNum = 0xDEADBEEF;
+      element._isAdmin = true;
+      assert.isTrue(element.$$('.action.delete')
+          .classList.contains('showDeleteButtons'));
+      MockInteractions.tap(element.$$('.action.delete'));
+      flush(() => {
+        element.$.confirmDeleteOverlay.open.lastCall.returnValue.then(() => {
+          element.$.confirmDeleteComment.message = 'removal reason';
+          element._handleConfirmDeleteComment();
+          assert.isTrue(element.$.restAPI.deleteComment.calledWith(
+              42, 0xDEADBEEF, 'baf0414d_60047215', 'removal reason'));
+          done();
+        });
+      });
+    });
   });
 
-  suite('gr-diff-comment draft tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-comment draft tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve(null); },
-        saveDiffDraft: function() {
+        getAccount() { return Promise.resolve(null); },
+        saveDiffDraft() {
           return Promise.resolve({
             ok: true,
-            text: function() {
+            text() {
               return Promise.resolve(
-                ')]}\'\n{' +
+                  ')]}\'\n{' +
                   '"id": "baf0414d_40572e03",' +
                   '"path": "/path/to/file",' +
                   '"line": 5,' +
@@ -273,12 +304,12 @@
             },
           });
         },
-        removeChangeReviewer: function() {
+        removeChangeReviewer() {
           return Promise.resolve({ok: true});
         },
       });
       stub('gr-storage', {
-        getDraftComment: function() { return null; },
+        getDraftComment() { return null; },
       });
       element = fixture('draft');
       element.changeNum = 42;
@@ -295,11 +326,11 @@
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('button visibility states', function() {
+    test('button visibility states', () => {
       element.showActions = false;
       assert.isTrue(element.$$('.humanActions').hasAttribute('hidden'));
       assert.isTrue(element.$$('.robotActions').hasAttribute('hidden'));
@@ -356,13 +387,13 @@
       assert.isFalse(element.$$('.robotActions').hasAttribute('hidden'));
     });
 
-    test('collapsible drafts', function() {
+    test('collapsible drafts', () => {
       assert.isTrue(element.collapsed);
       assert.isFalse(isVisible(element.$$('gr-formatted-text')),
           'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isTrue(isVisible(element.$$('.collapsedContent')),
           'header middle content is visible');
@@ -373,7 +404,7 @@
           'gr-formatted-text is visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isFalse(isVisible(element.$$('.collapsedContent')),
           'header middle content is is not visible');
@@ -386,7 +417,7 @@
           'gr-formatted-text is not visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
-      assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isTrue(isVisible(element.$$('gr-textarea')),
           'textarea is visible');
       assert.isFalse(isVisible(element.$$('.collapsedContent')),
           'header middle content is not visible');
@@ -399,7 +430,7 @@
           'gr-formatted-text is not visible');
       assert.isFalse(isVisible(element.$$('.actions')),
           'actions are not visible');
-      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isFalse(isVisible(element.$$('gr-textarea')),
           'textarea is not visible');
       assert.isTrue(isVisible(element.$$('.collapsedContent')),
           'header middle content is visible');
@@ -411,32 +442,32 @@
           'gr-formatted-text is not visible');
       assert.isTrue(isVisible(element.$$('.actions')),
           'actions are visible');
-      assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
+      assert.isTrue(isVisible(element.$$('gr-textarea')),
           'textarea is visible');
       assert.isFalse(isVisible(element.$$('.collapsedContent')),
           'header middle content is not visible');
     });
 
-    test('draft creation/cancelation', function(done) {
+    test('draft creation/cancelation', done => {
       assert.isFalse(element.editing);
       MockInteractions.tap(element.$$('.edit'));
       assert.isTrue(element.editing);
 
       element._messageText = '';
-      var eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
 
       // Save should be disabled on an empty message.
-      var disabled = element.$$('.save').hasAttribute('disabled');
+      let disabled = element.$$('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
       element._messageText = '     ';
       disabled = element.$$('.save').hasAttribute('disabled');
       assert.isTrue(disabled, 'save button should be disabled.');
 
-      var updateStub = sinon.stub();
+      const updateStub = sinon.stub();
       element.addEventListener('comment-update', updateStub);
 
-      var numDiscardEvents = 0;
-      element.addEventListener('comment-discard', function(e) {
+      let numDiscardEvents = 0;
+      element.addEventListener('comment-discard', e => {
         numDiscardEvents++;
         assert.isFalse(eraseMessageDraftSpy.called);
         if (numDiscardEvents === 2) {
@@ -450,32 +481,61 @@
       MockInteractions.pressAndReleaseKeyOn(element.$.editTextarea, 27); // esc
     });
 
-    test('draft discard removes message from storage', function(done) {
+    test('draft discard removes message from storage', done => {
       element._messageText = '';
-      var eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+      const eraseMessageDraftSpy = sandbox.spy(element, '_eraseDraftComment');
+      sandbox.stub(element, '_closeConfirmDiscardOverlay');
 
-      var numDiscardEvents = 0;
-      element.addEventListener('comment-discard', function(e) {
+      element.addEventListener('comment-discard', e => {
         assert.isTrue(eraseMessageDraftSpy.called);
         done();
       });
-      MockInteractions.tap(element.$$('.discard'));
+      element._handleConfirmDiscard({preventDefault: sinon.stub()});
     });
 
-    test('ctrl+s saves comment', function(done) {
-      var stub = sinon.stub(element, 'save', function() {
+    suite('confirm discard', () => {
+      let saveDisabled;
+      let discardStub;
+      let overlayStub;
+      let mockEvent;
+
+      setup(() => {
+        sandbox.stub(element, '_computeSaveDisabled', () => saveDisabled);
+        discardStub = sandbox.stub(element, '_discardDraft');
+        overlayStub = sandbox.stub(element, '_openOverlay');
+        mockEvent = {preventDefault: sinon.stub()};
+      });
+
+      test('confirms discard of comments that can be saved', () => {
+        saveDisabled = false;
+        element._handleDiscard(mockEvent);
+        assert.isTrue(overlayStub.calledWith(element.$.confirmDiscardOverlay));
+        assert.isFalse(discardStub.called);
+      });
+
+      test('no confirmation for comments that cannot be saved', () => {
+        saveDisabled = true;
+        element._handleDiscard(mockEvent);
+        assert.isFalse(overlayStub.called);
+        assert.isTrue(discardStub.calledOnce);
+      });
+    });
+
+    test('ctrl+s saves comment', done => {
+      const stub = sinon.stub(element, 'save', () => {
         assert.isTrue(stub.called);
         stub.restore();
         done();
       });
       element._messageText = 'is that the horse from horsing around??';
       MockInteractions.pressAndReleaseKeyOn(
-        element.$.editTextarea.textarea,
-        83, 'ctrl');  // 'ctrl + s'
+          element.$.editTextarea.$.textarea.textarea,
+          83, 'ctrl'); // 'ctrl + s'
     });
 
-    test('draft saving/editing', function(done) {
-      var fireStub = sinon.stub(element, 'fire');
+    test('draft saving/editing', done => {
+      const fireStub = sinon.stub(element, 'fire');
+      const cancelDebounce = sandbox.stub(element, 'cancelDebouncer');
 
       element.draft = true;
       MockInteractions.tap(element.$$('.edit'));
@@ -483,7 +543,7 @@
       element.flushDebouncer('fire-update');
       element.flushDebouncer('store');
       assert(fireStub.calledWith('comment-update'),
-             'comment-update should be sent');
+          'comment-update should be sent');
       assert.deepEqual(fireStub.lastCall.args, [
         'comment-update', {
           comment: {
@@ -504,9 +564,11 @@
       assert.isTrue(element.disabled,
           'Element should be disabled when creating draft.');
 
-      element._xhrPromise.then(function(draft) {
+      element._xhrPromise.then(draft => {
         assert(fireStub.calledWith('comment-save'),
-               'comment-save should be sent');
+            'comment-save should be sent');
+        assert(cancelDebounce.calledWith('store'));
+
         assert.deepEqual(fireStub.lastCall.args[1], {
           comment: {
             __commentSide: 'right',
@@ -522,10 +584,10 @@
           patchNum: 1,
         });
         assert.isFalse(element.disabled,
-                       'Element should be enabled when done creating draft.');
+            'Element should be enabled when done creating draft.');
         assert.equal(draft.message, 'saved!');
         assert.isFalse(element.editing);
-      }).then(function() {
+      }).then(() => {
         MockInteractions.tap(element.$$('.edit'));
         element._messageText = 'You’ll be delivering a package to Chapek 9, ' +
             'a world where humans are killed on sight.';
@@ -533,7 +595,7 @@
         assert.isTrue(element.disabled,
             'Element should be disabled when updating draft.');
 
-        element._xhrPromise.then(function(draft) {
+        element._xhrPromise.then(draft => {
           assert.isFalse(element.disabled,
               'Element should be enabled when done updating draft.');
           assert.equal(draft.message, 'saved!');
@@ -544,30 +606,59 @@
       });
     });
 
-    test('clicking on date link does not trigger nav', function() {
-      var showStub = sinon.stub(page, 'show');
-      var dateEl = element.$$('.date');
+    test('clicking on date link does not trigger nav', () => {
+      const showStub = sinon.stub(page, 'show');
+      const dateEl = element.$$('.date');
       assert.ok(dateEl);
       MockInteractions.tap(dateEl);
-      var dest = window.location.pathname + '#5';
+      const dest = window.location.pathname + '#5';
       assert(showStub.lastCall.calledWithExactly(dest, null, false),
           'Should navigate to ' + dest + ' without triggering nav');
       showStub.restore();
     });
 
-    test('proper event fires on resolve', function(done) {
-      element.addEventListener('comment-update', function(e) {
+    test('proper event fires on resolve', done => {
+      element.addEventListener('comment-update', e => {
         assert.isTrue(e.detail.comment.unresolved);
         done();
       });
       MockInteractions.tap(element.$$('.resolve input'));
     });
 
-    test('resolved comment state indicated by checkbox', function() {
+    test('resolved comment state indicated by checkbox', () => {
       element.comment = {unresolved: false};
       assert.isTrue(element.$$('.resolve input').checked);
       element.comment = {unresolved: true};
       assert.isFalse(element.$$('.resolve input').checked);
     });
+
+    suite('draft saving messages', () => {
+      test('_getSavingMessage', () => {
+        assert.equal(element._getSavingMessage(0), 'All changes saved');
+        assert.equal(element._getSavingMessage(1), 'Saving 1 draft...');
+        assert.equal(element._getSavingMessage(2), 'Saving 2 drafts...');
+        assert.equal(element._getSavingMessage(3), 'Saving 3 drafts...');
+      });
+
+      test('_show{Start,End}Request', () => {
+        const updateStub = sandbox.stub(element, '_updateRequestToast');
+        element._numPendingDiffRequests.number = 1;
+
+        element._showStartRequest();
+        assert.isTrue(updateStub.calledOnce);
+        assert.equal(updateStub.lastCall.args[0], 2);
+        assert.equal(element._numPendingDiffRequests.number, 2);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledTwice);
+        assert.equal(updateStub.lastCall.args[0], 1);
+        assert.equal(element._numPendingDiffRequests.number, 1);
+
+        element._showEndRequest();
+        assert.isTrue(updateStub.calledThrice);
+        assert.equal(updateStub.lastCall.args[0], 0);
+        assert.equal(element._numPendingDiffRequests.number, 0);
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
index 2d0786a..564312f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
@@ -23,6 +23,7 @@
         id="cursorManager"
         scroll-behavior="[[_scrollBehavior]]"
         cursor-target-class="target-row"
+        focus-on-move="[[_focusOnMove]]"
         target="{{diffRow}}"></gr-cursor-manager>
   </template>
   <script src="gr-diff-cursor.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index e40ccf3..92311b1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -14,23 +14,23 @@
 (function() {
   'use strict';
 
-  var DiffSides = {
+  const DiffSides = {
     LEFT: 'left',
     RIGHT: 'right',
   };
 
-  var DiffViewMode = {
+  const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
   };
 
-  var ScrollBehavior = {
+  const ScrollBehavior = {
     KEEP_VISIBLE: 'keep-visible',
     NEVER: 'never',
   };
 
-  var LEFT_SIDE_CLASS = 'target-side-left';
-  var RIGHT_SIDE_CLASS = 'target-side-right';
+  const LEFT_SIDE_CLASS = 'target-side-left';
+  const RIGHT_SIDE_CLASS = 'target-side-right';
 
   Polymer({
     is: 'gr-diff-cursor',
@@ -43,6 +43,7 @@
         type: String,
         value: DiffSides.RIGHT,
       },
+      /** @type {!HTMLElement|undefined} */
       diffRow: {
         type: Object,
         notify: true,
@@ -54,15 +55,15 @@
        */
       diffs: {
         type: Array,
-        value: function() {
-          return [];
-        },
+        value() { return []; },
       },
 
       /**
        * If set, the cursor will attempt to move to the line number (instead of
        * the first chunk) the next time the diff renders. It is set back to null
        * when used.
+       *
+       * @type (?number)
        */
       initialLineNumber: {
         type: Number,
@@ -79,6 +80,11 @@
         value: ScrollBehavior.KEEP_VISIBLE,
       },
 
+      _focusOnMove: {
+        type: Boolean,
+        value: true,
+      },
+
       _listeningForScroll: Boolean,
     },
 
@@ -87,30 +93,30 @@
       '_diffsChanged(diffs.splices)',
     ],
 
-    attached: function() {
+    attached() {
       // Catch when users are scrolling as the view loads.
       this.listen(window, 'scroll', '_handleWindowScroll');
     },
 
-    detached: function() {
+    detached() {
       this.unlisten(window, 'scroll', '_handleWindowScroll');
     },
 
-    moveLeft: function() {
+    moveLeft() {
       this.side = DiffSides.LEFT;
       if (this._isTargetBlank()) {
         this.moveUp();
       }
     },
 
-    moveRight: function() {
+    moveRight() {
       this.side = DiffSides.RIGHT;
       if (this._isTargetBlank()) {
         this.moveUp();
       }
     },
 
-    moveDown: function() {
+    moveDown() {
       if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
         this.$.cursorManager.next(this._rowHasSide.bind(this));
       } else {
@@ -118,7 +124,7 @@
       }
     },
 
-    moveUp: function() {
+    moveUp() {
       if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE) {
         this.$.cursorManager.previous(this._rowHasSide.bind(this));
       } else {
@@ -126,31 +132,36 @@
       }
     },
 
-    moveToNextChunk: function() {
+    moveToNextChunk() {
       this.$.cursorManager.next(this._isFirstRowOfChunk.bind(this),
-          function(target) {
+          target => {
             return target.parentNode.scrollHeight;
           });
       this._fixSide();
     },
 
-    moveToPreviousChunk: function() {
+    moveToPreviousChunk() {
       this.$.cursorManager.previous(this._isFirstRowOfChunk.bind(this));
       this._fixSide();
     },
 
-    moveToNextCommentThread: function() {
+    moveToNextCommentThread() {
       this.$.cursorManager.next(this._rowHasThread.bind(this));
       this._fixSide();
     },
 
-    moveToPreviousCommentThread: function() {
+    moveToPreviousCommentThread() {
       this.$.cursorManager.previous(this._rowHasThread.bind(this));
       this._fixSide();
     },
 
-    moveToLineNumber: function(number, side) {
-      var row = this._findRowByNumber(number, side);
+    /**
+     * @param {number} number
+     * @param {string} side
+     * @param {string=} opt_path
+     */
+    moveToLineNumber(number, side, opt_path) {
+      const row = this._findRowByNumberAndFile(number, side, opt_path);
       if (row) {
         this.side = side;
         this.$.cursorManager.setCursor(row);
@@ -159,10 +170,10 @@
 
     /**
      * Get the line number element targeted by the cursor row and side.
-     * @return {DOMElement}
+     * @return {?Element|undefined}
      */
-    getTargetLineElement: function() {
-      var lineElSelector = '.lineNum';
+    getTargetLineElement() {
+      let lineElSelector = '.lineNum';
 
       if (!this.diffRow) {
         return;
@@ -175,20 +186,20 @@
       return this.diffRow.querySelector(lineElSelector);
     },
 
-    getTargetDiffElement: function() {
+    getTargetDiffElement() {
       // Find the parent diff element of the cursor row.
-      for (var diff = this.diffRow; diff; diff = diff.parentElement) {
+      for (let diff = this.diffRow; diff; diff = diff.parentElement) {
         if (diff.tagName === 'GR-DIFF') { return diff; }
       }
       return null;
     },
 
-    moveToFirstChunk: function() {
+    moveToFirstChunk() {
       this.$.cursorManager.moveToStart();
       this.moveToNextChunk();
     },
 
-    reInitCursor: function() {
+    reInitCursor() {
       this._updateStops();
       if (this.initialLineNumber) {
         this.moveToLineNumber(this.initialLineNumber, this.side);
@@ -198,39 +209,42 @@
       }
     },
 
-    _handleWindowScroll: function() {
+    _handleWindowScroll() {
       if (this._listeningForScroll) {
         this._scrollBehavior = ScrollBehavior.NEVER;
+        this._focusOnMove = false;
         this._listeningForScroll = false;
       }
     },
 
-    handleDiffUpdate: function() {
+    handleDiffUpdate() {
       this._updateStops();
 
       if (!this.diffRow) {
         this.reInitCursor();
       }
       this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
+      this._focusOnMove = true;
       this._listeningForScroll = false;
     },
 
-    _handleDiffRenderStart: function() {
+    _handleDiffRenderStart() {
       this._listeningForScroll = true;
     },
 
     /**
-     * Get a short address for the location of the cursor. Such as '123' for
-     * line 123 of the revision, or 'b321' for line 321 of the base patch.
-     * Returns an empty string if an address is not available.
-     * @return {String}
+     * Get an object describing the location of the cursor. Such as
+     * {leftSide: false, number: 123} for line 123 of the revision, or
+     * {leftSide: true, number: 321} for line 321 of the base patch.
+     * Returns null if an address is not available.
+     * @return {?Object}
      */
-    getAddress: function() {
-      if (!this.diffRow) { return ''; }
+    getAddress() {
+      if (!this.diffRow) { return null; }
 
       // Get the line-number cell targeted by the cursor. If the mode is unified
       // then prefer the revision cell if available.
-      var cell;
+      let cell;
       if (this._getViewMode() === DiffViewMode.UNIFIED) {
         cell = this.diffRow.querySelector('.lineNum.right');
         if (!cell) {
@@ -239,15 +253,18 @@
       } else {
         cell = this.diffRow.querySelector('.lineNum.' + this.side);
       }
-      if (!cell) { return ''; }
+      if (!cell) { return null; }
 
-      var number = cell.getAttribute('data-value');
-      if (!number || number === 'FILE') { return ''; }
+      const number = cell.getAttribute('data-value');
+      if (!number || number === 'FILE') { return null; }
 
-      return (cell.matches('.left') ? 'b' : '') + number;
+      return {
+        leftSide: cell.matches('.left'),
+        number: parseInt(number, 10),
+      };
     },
 
-    _getViewMode: function() {
+    _getViewMode() {
       if (!this.diffRow) {
         return null;
       }
@@ -259,20 +276,20 @@
       }
     },
 
-    _rowHasSide: function(row) {
-      var selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
+    _rowHasSide(row) {
+      const selector = (this.side === DiffSides.LEFT ? '.left' : '.right') +
           ' + .content';
       return !!row.querySelector(selector);
     },
 
-    _isFirstRowOfChunk: function(row) {
-      var parentClassList = row.parentNode.classList;
+    _isFirstRowOfChunk(row) {
+      const parentClassList = row.parentNode.classList;
       return parentClassList.contains('section') &&
           parentClassList.contains('delta') &&
           !row.previousSibling;
     },
 
-    _rowHasThread: function(row) {
+    _rowHasThread(row) {
       return row.querySelector('gr-diff-comment-thread');
     },
 
@@ -280,7 +297,7 @@
      * If we jumped to a row where there is no content on the current side then
      * switch to the alternate side.
      */
-    _fixSide: function() {
+    _fixSide() {
       if (this._getViewMode() === DiffViewMode.SIDE_BY_SIDE &&
           this._isTargetBlank()) {
         this.side = this.side === DiffSides.LEFT ?
@@ -288,24 +305,24 @@
       }
     },
 
-    _isTargetBlank: function() {
+    _isTargetBlank() {
       if (!this.diffRow) {
         return false;
       }
 
-      var actions = this._getActionsForRow();
+      const actions = this._getActionsForRow();
       return (this.side === DiffSides.LEFT && !actions.left) ||
           (this.side === DiffSides.RIGHT && !actions.right);
     },
 
-    _rowChanged: function(newRow, oldRow) {
+    _rowChanged(newRow, oldRow) {
       if (oldRow) {
         oldRow.classList.remove(LEFT_SIDE_CLASS, RIGHT_SIDE_CLASS);
       }
       this._updateSideClass();
     },
 
-    _updateSideClass: function() {
+    _updateSideClass() {
       if (!this.diffRow) {
         return;
       }
@@ -315,12 +332,12 @@
           this.diffRow);
     },
 
-    _isActionType: function(type) {
+    _isActionType(type) {
       return type !== 'blank' && type !== 'contextControl';
     },
 
-    _getActionsForRow: function() {
-      var actions = {left: false, right: false};
+    _getActionsForRow() {
+      const actions = {left: false, right: false};
       if (this.diffRow) {
         actions.left = this._isActionType(
             this.diffRow.getAttribute('left-type'));
@@ -330,14 +347,14 @@
       return actions;
     },
 
-    _getStops: function() {
+    _getStops() {
       return this.diffs.reduce(
-          function(stops, diff) {
+          (stops, diff) => {
             return stops.concat(diff.getCursorStops());
           }, []);
     },
 
-    _updateStops: function() {
+    _updateStops() {
       this.$.cursorManager.stops = this._getStops();
     },
 
@@ -346,14 +363,14 @@
      * removed from the cursor.
      * @private
      */
-    _diffsChanged: function(changeRecord) {
+    _diffsChanged(changeRecord) {
       if (!changeRecord) { return; }
 
       this._updateStops();
 
-      var splice;
-      var i;
-      for (var spliceIdx = 0;
+      let splice;
+      let i;
+      for (let spliceIdx = 0;
         changeRecord.indexSplices &&
             spliceIdx < changeRecord.indexSplices.length;
         spliceIdx++) {
@@ -371,15 +388,22 @@
             i++) {
           this.unlisten(splice.removed[i],
               'render-start', '_handleDiffRenderStart');
-          this.unlisten(splice.removed[i], 'render', 'handleDiffUpdate');
+          this.unlisten(splice.removed[i],
+              'render-content', 'handleDiffUpdate');
         }
       }
     },
 
-    _findRowByNumber: function(targetNumber, side) {
-      var stops = this.$.cursorManager.stops;
-      var selector;
-      for (var i = 0; i < stops.length; i++) {
+    _findRowByNumberAndFile(targetNumber, side, opt_path) {
+      let stops;
+      if (opt_path) {
+        const diff = this.diffs.filter(diff => diff.path === opt_path)[0];
+        stops = diff.getCursorStops();
+      } else {
+        stops = this.$.cursorManager.stops;
+      }
+      let selector;
+      for (let i = 0; i < stops.length; i++) {
         selector = '.lineNum.' + side + '[data-value="' + targetNumber + '"]';
         if (stops[i].querySelector(selector)) {
           return stops[i];
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index a77c617..ab3302b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
 <link rel="import" href="./gr-diff-cursor.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
@@ -38,17 +38,20 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-cursor tests', function() {
-    var cursorElement;
-    var diffElement;
-    var mockDiffResponse;
+  suite('gr-diff-cursor tests', () => {
+    let sandbox;
+    let cursorElement;
+    let diffElement;
+    let mockDiffResponse;
 
-    setup(function(done) {
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
 
-      var fixtureElems = fixture('basic');
+      const fixtureElems = fixture('basic');
       mockDiffResponse = fixtureElems[0];
       diffElement = fixtureElems[1];
       cursorElement = fixtureElems[2];
@@ -56,41 +59,33 @@
       // Register the diff with the cursor.
       cursorElement.push('diffs', diffElement);
 
-      diffElement.$.restAPI.getDiffPreferences().then(function(prefs) {
+      diffElement.comments = {left: [], right: []};
+      diffElement.$.restAPI.getDiffPreferences().then(prefs => {
         diffElement.prefs = prefs;
       });
 
-      sinon.stub(diffElement, '_getDiff', function() {
+      sandbox.stub(diffElement, '_getDiff', () => {
         return Promise.resolve(mockDiffResponse.diffResponse);
       });
 
-      sinon.stub(diffElement, '_getDiffComments', function() {
-        return Promise.resolve({baseComments: [], comments: []});
-      });
-
-      sinon.stub(diffElement, '_getDiffDrafts', function() {
-        return Promise.resolve({baseComments: [], comments: []});
-      });
-
-      sinon.stub(diffElement, '_getDiffRobotComments', function() {
-        return Promise.resolve({baseComments: [], comments: []});
-      });
-
-      var setupDone = function() {
+      const setupDone = () => {
+        cursorElement._updateStops();
         cursorElement.moveToFirstChunk();
-        done();
         diffElement.removeEventListener('render', setupDone);
+        done();
       };
       diffElement.addEventListener('render', setupDone);
 
       diffElement.reload();
     });
 
-    test('diff cursor functionality (side-by-side)', function() {
+    teardown(() => sandbox.restore());
+
+    test('diff cursor functionality (side-by-side)', () => {
       // The cursor has been initialized to the first delta.
       assert.isOk(cursorElement.diffRow);
 
-      var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+      const firstDeltaRow = diffElement.$$('.section.delta .diff-row');
       assert.equal(cursorElement.diffRow, firstDeltaRow);
 
       cursorElement.moveDown();
@@ -104,22 +99,24 @@
       assert.equal(cursorElement.diffRow, firstDeltaRow);
     });
 
-    test('cursor scroll behavior', function() {
+    test('cursor scroll behavior', () => {
       cursorElement._handleDiffRenderStart();
       assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+      assert.isTrue(cursorElement._focusOnMove);
 
       cursorElement._handleWindowScroll();
       assert.equal(cursorElement._scrollBehavior, 'never');
+      assert.isFalse(cursorElement._focusOnMove);
 
       cursorElement.handleDiffUpdate();
       assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+      assert.isTrue(cursorElement._focusOnMove);
     });
 
-    suite('unified diff', function() {
-
-      setup(function(done) {
+    suite('unified diff', () => {
+      setup(done => {
         // We must allow the diff to re-render after setting the viewMode.
-        var renderHandler = function() {
+        const renderHandler = function() {
           diffElement.removeEventListener('render', renderHandler);
           cursorElement.reInitCursor();
           done();
@@ -128,11 +125,11 @@
         diffElement.viewMode = 'UNIFIED_DIFF';
       });
 
-      test('diff cursor functionality (unified)', function() {
+      test('diff cursor functionality (unified)', () => {
         // The cursor has been initialized to the first delta.
         assert.isOk(cursorElement.diffRow);
 
-        var firstDeltaRow = diffElement.$$('.section.delta .diff-row');
+        let firstDeltaRow = diffElement.$$('.section.delta .diff-row');
         assert.equal(cursorElement.diffRow, firstDeltaRow);
 
         firstDeltaRow = diffElement.$$('.section.delta .diff-row');
@@ -150,19 +147,19 @@
       });
     });
 
-    test('cursor side functionality', function() {
+    test('cursor side functionality', () => {
       // The side only applies to side-by-side mode, which should be the default
       // mode.
       assert.equal(diffElement.viewMode, 'SIDE_BY_SIDE');
 
-      var firstDeltaSection = diffElement.$$('.section.delta');
-      var firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
+      const firstDeltaSection = diffElement.$$('.section.delta');
+      const firstDeltaRow = firstDeltaSection.querySelector('.diff-row');
 
       // Because the first delta in this diff is on the right, it should be set
       // to the right side.
       assert.equal(cursorElement.side, 'right');
       assert.equal(cursorElement.diffRow, firstDeltaRow);
-      var firstIndex = cursorElement.$.cursorManager.index;
+      const firstIndex = cursorElement.$.cursorManager.index;
 
       // Move the side to the left. Because this delta only has a right side, we
       // should be moved up to the previous line where there is content on the
@@ -186,16 +183,16 @@
           firstDeltaSection.nextSibling);
     });
 
-    test('chunk skip functionality', function() {
-      var chunks = Polymer.dom(diffElement.root).querySelectorAll(
+    test('chunk skip functionality', () => {
+      const chunks = Polymer.dom(diffElement.root).querySelectorAll(
           '.section.delta');
-      var indexOfChunk = function(chunk) {
+      const indexOfChunk = function(chunk) {
         return Array.prototype.indexOf.call(chunks, chunk);
       };
 
       // We should be initialized to the first chunk. Since this chunk only has
       // content on the right side, our side should be right.
-      var currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
+      let currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
       assert.equal(currentIndex, 0);
       assert.equal(cursorElement.side, 'right');
 
@@ -204,36 +201,39 @@
 
       // Since this chunk only has content on the left side. we should have been
       // automatically mvoed over.
-      var previousIndex = currentIndex;
+      const previousIndex = currentIndex;
       currentIndex = indexOfChunk(cursorElement.diffRow.parentElement);
       assert.equal(currentIndex, previousIndex + 1);
       assert.equal(cursorElement.side, 'left');
     });
 
-    test('initialLineNumber disabled', function(done) {
-      var moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
-      var moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+    test('initialLineNumber disabled', done => {
+      const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
+      const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
 
-      diffElement.addEventListener('render', function() {
+      function renderHandler() {
+        diffElement.removeEventListener('render', renderHandler);
         assert.isFalse(moveToNumStub.called);
         assert.isTrue(moveToChunkStub.called);
         done();
-      });
-
+      }
+      diffElement.addEventListener('render', renderHandler);
       diffElement.reload();
     });
 
-    test('initialLineNumber enabled', function(done) {
-      var moveToNumStub = sinon.stub(cursorElement, 'moveToLineNumber');
-      var moveToChunkStub = sinon.stub(cursorElement, 'moveToFirstChunk');
+    test('initialLineNumber enabled', done => {
+      const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
+      const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
 
-      diffElement.addEventListener('render', function() {
+      function renderHandler() {
+        diffElement.removeEventListener('render', renderHandler);
         assert.isFalse(moveToChunkStub.called);
         assert.isTrue(moveToNumStub.called);
         assert.equal(moveToNumStub.lastCall.args[0], 10);
         assert.equal(moveToNumStub.lastCall.args[1], 'right');
         done();
-      });
+      }
+      diffElement.addEventListener('render', renderHandler);
 
       cursorElement.initialLineNumber = 10;
       cursorElement.side = 'right';
@@ -241,38 +241,51 @@
       diffElement.reload();
     });
 
-    test('getAddress', function() {
+    test('getAddress', () => {
       // It should initialize to the first chunk: line 5 of the revision.
-      assert.equal(cursorElement.getAddress(), '5');
+      assert.deepEqual(cursorElement.getAddress(),
+          {leftSide: false, number: 5});
 
       // Revision line 4 is up.
       cursorElement.moveUp();
-      assert.equal(cursorElement.getAddress(), '4');
+      assert.deepEqual(cursorElement.getAddress(),
+          {leftSide: false, number: 4});
 
       // Base line 4 is left.
       cursorElement.moveLeft();
-      assert.equal(cursorElement.getAddress(), 'b4');
+      assert.deepEqual(cursorElement.getAddress(), {leftSide: true, number: 4});
 
       // Moving to the next chunk takes it back to the start.
       cursorElement.moveToNextChunk();
-      assert.equal(cursorElement.getAddress(), '5');
+      assert.deepEqual(cursorElement.getAddress(),
+          {leftSide: false, number: 5});
 
       // The following chunk is a removal starting on line 10 of the base.
       cursorElement.moveToNextChunk();
-      assert.equal(cursorElement.getAddress(), 'b10');
+      assert.deepEqual(cursorElement.getAddress(),
+          {leftSide: true, number: 10});
 
-      // Should be an empty string if there is no selection.
+      // Should be null if there is no selection.
       cursorElement.$.cursorManager.unsetCursor();
-      assert.equal(cursorElement.getAddress(), '');
+      assert.isNotOk(cursorElement.getAddress());
     });
 
-    test('_findRowByNumber', function() {
+    test('_findRowByNumberAndFile', () => {
       // Get the first ab row after the first chunk.
-      var row = Polymer.dom(diffElement.root).querySelectorAll('tr')[8];
+      const row = Polymer.dom(diffElement.root).querySelectorAll('tr')[8];
 
       // It should be line 8 on the right, but line 5 on the left.
-      assert.equal(cursorElement._findRowByNumber(8, 'right'), row);
-      assert.equal(cursorElement._findRowByNumber(5, 'left'), row);
+      assert.equal(cursorElement._findRowByNumberAndFile(8, 'right'), row);
+      assert.equal(cursorElement._findRowByNumberAndFile(5, 'left'), row);
+    });
+
+    test('expand context updates stops', done => {
+      sandbox.spy(cursorElement, 'handleDiffUpdate');
+      MockInteractions.tap(diffElement.$$('.showContext'));
+      flush(() => {
+        assert.isTrue(cursorElement.handleDiffUpdate.called);
+        done();
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
index bb5b938..e18f6ca 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation.js
@@ -18,24 +18,24 @@
   if (window.GrAnnotation) { return; }
 
   // TODO(wyatta): refactor this to be <MARK> rather than <HL>.
-  var ANNOTATION_TAG = 'HL';
+  const ANNOTATION_TAG = 'HL';
 
   // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
-  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+  const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  var GrAnnotation = {
+  const GrAnnotation = {
 
     /**
      * The DOM API textContent.length calculation is broken when the text
      * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
-     * @param  {Text} A text node.
-     * @return {Number} The length of the text.
+     * @param  {!Text} node text node.
+     * @return {number} The length of the text.
      */
-    getLength: function(node) {
+    getLength(node) {
       return this.getStringLength(node.textContent);
     },
 
-    getStringLength: function(str) {
+    getStringLength(str) {
       return str.replace(REGEX_ASTRAL_SYMBOL, '_').length;
     },
 
@@ -44,14 +44,12 @@
      * element. If the element has child elements, the range is split and
      * applied as deeply as possible.
      */
-    annotateElement: function(parent, offset, length, cssClass) {
-      var nodes = [].slice.apply(parent.childNodes);
-      var node;
-      var nodeLength;
-      var subLength;
+    annotateElement(parent, offset, length, cssClass) {
+      const nodes = [].slice.apply(parent.childNodes);
+      let nodeLength;
+      let subLength;
 
-      for (var i = 0; i < nodes.length; i++) {
-        node = nodes[i];
+      for (const node of nodes) {
         nodeLength = this.getLength(node);
 
         // If the current node is completely before the offset.
@@ -85,8 +83,8 @@
      *
      * @return {!Element} Wrapped node.
      */
-    wrapInHighlight: function(node, cssClass) {
-      var hl;
+    wrapInHighlight(node, cssClass) {
+      let hl;
       if (node.tagName === ANNOTATION_TAG) {
         hl = node;
         hl.classList.add(cssClass);
@@ -108,7 +106,7 @@
      * @param {string} cssClass
      * @param {boolean=} opt_firstPart
      */
-    splitAndWrapInHighlight: function(node, offset, cssClass, opt_firstPart) {
+    splitAndWrapInHighlight(node, offset, cssClass, opt_firstPart) {
       if (this.getLength(node) === offset || offset === 0) {
         return this.wrapInHighlight(node, cssClass);
       } else {
@@ -130,14 +128,14 @@
      * @param {number} offset
      * @return {!Node} Trailing Node.
      */
-    splitNode: function(element, offset) {
+    splitNode(element, offset) {
       if (element instanceof Text) {
         return this.splitTextNode(element, offset);
       }
-      var tail = element.cloneNode(false);
+      const tail = element.cloneNode(false);
       element.parentElement.insertBefore(tail, element.nextSibling);
       // Skip nodes before offset.
-      var node = element.firstChild;
+      let node = element.firstChild;
       while (node &&
           this.getLength(node) <= offset ||
           this.getLength(node) === 0) {
@@ -163,17 +161,17 @@
      * @param {number} offset
      * @return {!Text} Trailing Text Node.
      */
-    splitTextNode: function(node, offset) {
+    splitTextNode(node, offset) {
       if (node.textContent.match(REGEX_ASTRAL_SYMBOL)) {
         // TODO (viktard): Polyfill Array.from for IE10.
-        var head = Array.from(node.textContent);
-        var tail = head.splice(offset);
-        var parent = node.parentNode;
+        const head = Array.from(node.textContent);
+        const tail = head.splice(offset);
+        const parent = node.parentNode;
 
         // Split the content of the original node.
         node.textContent = head.join('');
 
-        var tailNode = document.createTextNode(tail.join(''));
+        const tailNode = document.createTextNode(tail.join(''));
         if (parent) {
           parent.insertBefore(tailNode, node.nextSibling);
         }
@@ -183,8 +181,8 @@
       }
     },
 
-    _annotateText: function(node, offset, length, cssClass) {
-      var nodeLength = this.getLength(node);
+    _annotateText(node, offset, length, cssClass) {
+      const nodeLength = this.getLength(node);
 
       // There are four cases:
       //  1) Entire node is highlighted.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
index 0a03539..b237685 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-annotation_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="gr-annotation.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 
 <script>void(0);</script>
 
@@ -33,18 +33,18 @@
 </test-fixture>
 
 <script>
-  suite('annotation', function() {
-    var str;
-    var parent;
-    var textNode;
+  suite('annotation', () => {
+    let str;
+    let parent;
+    let textNode;
 
-    setup(function() {
+    setup(() => {
       parent = fixture('basic');
       textNode = parent.childNodes[0];
       str = textNode.textContent;
     });
 
-    test('_annotateText Case 1', function() {
+    test('_annotateText Case 1', () => {
       GrAnnotation._annotateText(textNode, 0, str.length, 'foobar');
 
       assert.equal(parent.childNodes.length, 1);
@@ -54,10 +54,10 @@
       assert.equal(parent.childNodes[0].childNodes[0].textContent, str);
     });
 
-    test('_annotateText Case 2', function() {
-      var length = 12;
-      var substr = str.substr(0, length);
-      var remainder = str.substr(length);
+    test('_annotateText Case 2', () => {
+      const length = 12;
+      const substr = str.substr(0, length);
+      const remainder = str.substr(length);
 
       GrAnnotation._annotateText(textNode, 0, length, 'foobar');
 
@@ -72,11 +72,11 @@
       assert.equal(parent.childNodes[1].textContent, remainder);
     });
 
-    test('_annotateText Case 3', function() {
-      var index = 12;
-      var length = str.length - index;
-      var remainder = str.substr(0, index);
-      var substr = str.substr(index);
+    test('_annotateText Case 3', () => {
+      const index = 12;
+      const length = str.length - index;
+      const remainder = str.substr(0, index);
+      const substr = str.substr(index);
 
       GrAnnotation._annotateText(textNode, index, length, 'foobar');
 
@@ -91,13 +91,13 @@
       assert.equal(parent.childNodes[1].childNodes[0].textContent, substr);
     });
 
-    test('_annotateText Case 4', function() {
-      var index = str.indexOf('dolor');
-      var length = 'dolor '.length;
+    test('_annotateText Case 4', () => {
+      const index = str.indexOf('dolor');
+      const length = 'dolor '.length;
 
-      var remainderPre = str.substr(0, index);
-      var substr = str.substr(index, length);
-      var remainderPost = str.substr(index + length);
+      const remainderPre = str.substr(0, index);
+      const substr = str.substr(index, length);
+      const remainderPost = str.substr(index + length);
 
       GrAnnotation._annotateText(textNode, index, length, 'foobar');
 
@@ -115,42 +115,42 @@
       assert.equal(parent.childNodes[2].textContent, remainderPost);
     });
 
-    test('_annotateElement design doc example', function() {
-      var layers = [
+    test('_annotateElement design doc example', () => {
+      const layers = [
         'amet, ',
         'inceptos ',
         'amet, ',
-        'et, suspendisse ince'
+        'et, suspendisse ince',
       ];
 
       // Apply the layers successively.
-      layers.forEach(function(layer, i) {
+      layers.forEach((layer, i) => {
         GrAnnotation.annotateElement(
-            parent, str.indexOf(layer), layer.length, 'layer-' + (i + 1));
+            parent, str.indexOf(layer), layer.length, `layer-${i + 1}`);
       });
 
       assert.equal(parent.textContent, str);
 
       // Layer 1:
-      var layer1 = parent.querySelectorAll('.layer-1');
+      const layer1 = parent.querySelectorAll('.layer-1');
       assert.equal(layer1.length, 1);
       assert.equal(layer1[0].textContent, layers[0]);
       assert.equal(layer1[0].parentElement, parent);
 
       // Layer 2:
-      var layer2 = parent.querySelectorAll('.layer-2');
+      const layer2 = parent.querySelectorAll('.layer-2');
       assert.equal(layer2.length, 1);
       assert.equal(layer2[0].textContent, layers[1]);
       assert.equal(layer2[0].parentElement, parent);
 
       // Layer 3:
-      var layer3 = parent.querySelectorAll('.layer-3');
+      const layer3 = parent.querySelectorAll('.layer-3');
       assert.equal(layer3.length, 1);
       assert.equal(layer3[0].textContent, layers[2]);
       assert.equal(layer3[0].parentElement, layer1[0]);
 
       // Layer 4:
-      var layer4 = parent.querySelectorAll('.layer-4');
+      const layer4 = parent.querySelectorAll('.layer-4');
       assert.equal(layer4.length, 3);
 
       assert.equal(layer4[0].textContent, 'et, ');
@@ -168,13 +168,13 @@
           layers[3]);
     });
 
-    test('splitTextNode', function() {
-      var helloString = 'hello';
-      var asciiString = 'ASCII';
-      var unicodeString = 'Unic💢de';
+    test('splitTextNode', () => {
+      const helloString = 'hello';
+      const asciiString = 'ASCII';
+      const unicodeString = 'Unic💢de';
 
-      var node;
-      var tail;
+      let node;
+      let tail;
 
       // Non-unicode path:
       node = document.createTextNode(helloString + asciiString);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
index 814a760..7b9954d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.html
@@ -16,11 +16,12 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
 <link rel="import" href="../gr-selection-action-box/gr-selection-action-box.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-diff-highlight">
   <template>
-    <style>
-      .contentWrapper ::content {
+    <style include="shared-styles">
+      :host {
         position: relative;
       }
       .contentWrapper ::content .range {
@@ -31,6 +32,13 @@
         background-color: rgba(255,255,0,0.5);
         display: inline;
       }
+      gr-selection-action-box {
+        /**
+         * Needs z-index to apear above wrapped content, since it's inseted
+         * into DOM before it.
+         */
+        z-index: 10;
+      }
     </style>
     <div class="contentWrapper">
       <content></content>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
index e5743a7..2490509 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight.js
@@ -20,6 +20,11 @@
     properties: {
       comments: Object,
       loggedIn: Boolean,
+      /**
+       * querySelector can return null, so needs to be nullable.
+       *
+       * @type {?HTMLElement}
+       * */
       _cachedDiffBuilder: Object,
       isAttached: Boolean,
     },
@@ -42,7 +47,7 @@
       return this._cachedDiffBuilder;
     },
 
-    _enableSelectionObserver: function(loggedIn, isAttached) {
+    _enableSelectionObserver(loggedIn, isAttached) {
       if (loggedIn && isAttached) {
         this.listen(document, 'selectionchange', '_handleSelectionChange');
       } else {
@@ -50,11 +55,11 @@
       }
     },
 
-    isRangeSelected: function() {
+    isRangeSelected() {
       return !!this.$$('gr-selection-action-box');
     },
 
-    _handleSelectionChange: function() {
+    _handleSelectionChange() {
       // Can't use up or down events to handle selection started and/or ended in
       // in comment threads or outside of diff.
       // Debounce removeActionBox to give it a chance to react to click/tap.
@@ -62,31 +67,31 @@
       this.debounce('selectionChange', this._handleSelection, 200);
     },
 
-    _handleCommentMouseOver: function(e) {
-      var comment = e.detail.comment;
+    _handleCommentMouseOver(e) {
+      const comment = e.detail.comment;
       if (!comment.range) { return; }
-      var lineEl = this.diffBuilder.getLineElByChild(e.target);
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var index = this._indexOfComment(side, comment);
+      const lineEl = this.diffBuilder.getLineElByChild(e.target);
+      const side = this.diffBuilder.getSideByLineEl(lineEl);
+      const index = this._indexOfComment(side, comment);
       if (index !== undefined) {
         this.set(['comments', side, index, '__hovering'], true);
       }
     },
 
-    _handleCommentMouseOut: function(e) {
-      var comment = e.detail.comment;
+    _handleCommentMouseOut(e) {
+      const comment = e.detail.comment;
       if (!comment.range) { return; }
-      var lineEl = this.diffBuilder.getLineElByChild(e.target);
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var index = this._indexOfComment(side, comment);
+      const lineEl = this.diffBuilder.getLineElByChild(e.target);
+      const side = this.diffBuilder.getSideByLineEl(lineEl);
+      const index = this._indexOfComment(side, comment);
       if (index !== undefined) {
         this.set(['comments', side, index, '__hovering'], false);
       }
     },
 
-    _indexOfComment: function(side, comment) {
-      var idProp = comment.id ? 'id' : '__draftID';
-      for (var i = 0; i < this.comments[side].length; i++) {
+    _indexOfComment(side, comment) {
+      const idProp = comment.id ? 'id' : '__draftID';
+      for (let i = 0; i < this.comments[side].length; i++) {
         if (comment[idProp] &&
             this.comments[side][i][idProp] === comment[idProp]) {
           return i;
@@ -99,7 +104,7 @@
      * Merges multiple ranges, accounts for triple click, accounts for
      * syntax highligh, convert native DOM Range objects to Gerrit concepts
      * (line, side, etc).
-     * @return {{
+     * @return {({
      *   start: {
      *     node: Node,
      *     side: string,
@@ -112,18 +117,18 @@
      *     line: Number,
      *     column: Number
      *   }
-     * }}
+     * })|null|!Object}
      */
-    _getNormalizedRange: function() {
-      var selection = window.getSelection();
-      var rangeCount = selection.rangeCount;
+    _getNormalizedRange() {
+      const selection = window.getSelection();
+      const rangeCount = selection.rangeCount;
       if (rangeCount === 0) {
         return null;
       } else if (rangeCount === 1) {
         return this._normalizeRange(selection.getRangeAt(0));
       } else {
-        var startRange = this._normalizeRange(selection.getRangeAt(0));
-        var endRange = this._normalizeRange(
+        const startRange = this._normalizeRange(selection.getRangeAt(0));
+        const endRange = this._normalizeRange(
             selection.getRangeAt(rangeCount - 1));
         return {
           start: startRange.start,
@@ -134,9 +139,10 @@
 
     /**
      * Normalize a specific DOM Range.
+     * @return {!Object} fixed normalized range
      */
-    _normalizeRange: function(domRange) {
-      var range = GrRangeNormalizer.normalize(domRange);
+    _normalizeRange(domRange) {
+      const range = GrRangeNormalizer.normalize(domRange);
       return this._fixTripleClickSelection({
         start: this._normalizeSelectionSide(
             range.startContainer, range.startOffset),
@@ -156,25 +162,25 @@
      * @param {!Range} domRange DOM Range object
      * @return {!Object} fixed normalized range
      */
-    _fixTripleClickSelection: function(range, domRange) {
+    _fixTripleClickSelection(range, domRange) {
       if (!range.start) {
         // Selection outside of current diff.
         return range;
       }
-      var start = range.start;
-      var end = range.end;
-      var endsAtOtherSideLineNum =
+      const start = range.start;
+      const end = range.end;
+      const endsAtOtherSideLineNum =
           domRange.endOffset === 0 &&
           domRange.endContainer.nodeName === 'TD' &&
           (domRange.endContainer.classList.contains('left') ||
               domRange.endContainer.classList.contains('right'));
-      var endsOnOtherSideStart = endsAtOtherSideLineNum ||
+      const endsOnOtherSideStart = endsAtOtherSideLineNum ||
           end &&
           end.column === 0 &&
           end.line === start.line &&
           end.side != start.side;
-      var content = domRange.cloneContents().querySelector('.contentText');
-      var lineLength = content && this._getLength(content) || 0;
+      const content = domRange.cloneContents().querySelector('.contentText');
+      const lineLength = content && this._getLength(content) || 0;
       if (lineLength && endsOnOtherSideStart || endsAtOtherSideLineNum) {
         // Selection ends at the beginning of the next line.
         // Move the selection to the end of the previous line.
@@ -195,40 +201,40 @@
      *
      * @param {Node} node td.content child
      * @param {number} offset offset within node
-     * @return {{
+     * @return {({
      *   node: Node,
      *   side: string,
      *   line: Number,
      *   column: Number
-     * }}
+     * }|undefined)}
      */
-    _normalizeSelectionSide: function(node, offset) {
-      var column;
+    _normalizeSelectionSide(node, offset) {
+      let column;
       if (!this.contains(node)) {
         return;
       }
-      var lineEl = this.diffBuilder.getLineElByChild(node);
+      const lineEl = this.diffBuilder.getLineElByChild(node);
       if (!lineEl) {
         return;
       }
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
+      const side = this.diffBuilder.getSideByLineEl(lineEl);
       if (!side) {
         return;
       }
-      var line = this.diffBuilder.getLineNumberByChild(lineEl);
+      const line = this.diffBuilder.getLineNumberByChild(lineEl);
       if (!line) {
         return;
       }
-      var contentText = this.diffBuilder.getContentByLineEl(lineEl);
+      const contentText = this.diffBuilder.getContentByLineEl(lineEl);
       if (!contentText) {
         return;
       }
-      var contentTd = contentText.parentElement;
+      const contentTd = contentText.parentElement;
       if (!contentTd.contains(node)) {
         node = contentText;
         column = 0;
       } else {
-        var thread = contentTd.querySelector('gr-diff-comment-thread');
+        const thread = contentTd.querySelector('gr-diff-comment-thread');
         if (thread && thread.contains(node)) {
           column = this._getLength(contentText);
           node = contentText;
@@ -238,25 +244,25 @@
       }
 
       return {
-        node: node,
-        side: side,
-        line: line,
-        column: column,
+        node,
+        side,
+        line,
+        column,
       };
     },
 
-    _handleSelection: function() {
-      var normalizedRange = this._getNormalizedRange();
+    _handleSelection() {
+      const normalizedRange = this._getNormalizedRange();
       if (!normalizedRange) {
         return;
       }
-      var domRange = window.getSelection().getRangeAt(0);
-      var start = normalizedRange.start;
-
+      const domRange = window.getSelection().getRangeAt(0);
+      /** @type {?} */
+      const start = normalizedRange.start;
       if (!start) {
         return;
       }
-      var end = normalizedRange.end;
+      const end = normalizedRange.end;
       if (!end) {
         return;
       }
@@ -268,8 +274,9 @@
 
       // TODO (viktard): Drop empty first and last lines from selection.
 
-      var actionBox = document.createElement('gr-selection-action-box');
-      Polymer.dom(this.root).appendChild(actionBox);
+      const actionBox = document.createElement('gr-selection-action-box');
+      const root = Polymer.dom(this.root);
+      root.insertBefore(actionBox, root.firstElementChild);
       actionBox.range = {
         startLine: start.line,
         startChar: start.column,
@@ -280,7 +287,9 @@
       if (start.line === end.line) {
         actionBox.placeAbove(domRange);
       } else if (start.node instanceof Text) {
-        actionBox.placeAbove(start.node.splitText(start.column));
+        if (start.column) {
+          actionBox.placeAbove(start.node.splitText(start.column));
+        }
         start.node.parentElement.normalize(); // Undo splitText from above.
       } else if (start.node.classList.contains('content') &&
                  start.node.firstChild) {
@@ -290,22 +299,22 @@
       }
     },
 
-    _createComment: function(e) {
+    _createComment(e) {
       this._removeActionBox();
     },
 
-    _removeActionBoxDebounced: function() {
+    _removeActionBoxDebounced() {
       this.debounce('removeActionBox', this._removeActionBox, 10);
     },
 
-    _removeActionBox: function() {
-      var actionBox = this.$$('gr-selection-action-box');
+    _removeActionBox() {
+      const actionBox = this.$$('gr-selection-action-box');
       if (actionBox) {
         Polymer.dom(this.root).removeChild(actionBox);
       }
     },
 
-    _convertOffsetToColumn: function(el, offset) {
+    _convertOffsetToColumn(el, offset) {
       if (el instanceof Element && el.classList.contains('content')) {
         return offset;
       }
@@ -325,20 +334,20 @@
      * Traverse Element from right to left, call callback for each node.
      * Stops if callback returns true.
      *
-     * @param {!Node} startNode
+     * @param {!Element} startNode
      * @param {function(Node):boolean} callback
      * @param {Object=} opt_flags If flags.left is true, traverse left.
      */
-    _traverseContentSiblings: function(startNode, callback, opt_flags) {
-      var travelLeft = opt_flags && opt_flags.left;
-      var node = startNode;
+    _traverseContentSiblings(startNode, callback, opt_flags) {
+      const travelLeft = opt_flags && opt_flags.left;
+      let node = startNode;
       while (node) {
         if (node instanceof Element &&
             node.tagName !== 'HL' &&
             node.tagName !== 'SPAN') {
           break;
         }
-        var nextNode = travelLeft ? node.previousSibling : node.nextSibling;
+        const nextNode = travelLeft ? node.previousSibling : node.nextSibling;
         if (callback(node)) {
           break;
         }
@@ -350,10 +359,10 @@
      * Get length of a node. If the node is a content node, then only give the
      * length of its .contentText child.
      *
-     * @param {!Node} node
+     * @param {?Element} node this is sometimes passed as null.
      * @return {number}
      */
-    _getLength: function(node) {
+    _getLength(node) {
       if (node instanceof Element && node.classList.contains('content')) {
         return this._getLength(node.querySelector('.contentText'));
       } else {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
index c4c2993..b63b9a4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-diff-highlight_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-diff-highlight.html">
 
 <script>void(0);</script>
@@ -124,39 +123,39 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-highlight', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-highlight', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic')[1];
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    suite('selectionchange event handling', function() {
-      var emulateSelection = function() {
+    suite('selectionchange event handling', () => {
+      const emulateSelection = function() {
         document.dispatchEvent(new CustomEvent('selectionchange'));
         element.flushDebouncer('selectionChange');
         element.flushDebouncer('removeActionBox');
       };
 
-      setup(function() {
+      setup(() => {
         sandbox.stub(element, '_handleSelection');
         sandbox.stub(element, '_removeActionBox');
       });
 
-      test('enabled if logged in', function() {
+      test('enabled if logged in', () => {
         element.loggedIn = true;
         emulateSelection();
         assert.isTrue(element._handleSelection.called);
         assert.isTrue(element._removeActionBox.called);
       });
 
-      test('ignored if logged out', function() {
+      test('ignored if logged out', () => {
         element.loggedIn = false;
         emulateSelection();
         assert.isFalse(element._handleSelection.called);
@@ -164,10 +163,10 @@
       });
     });
 
-    suite('comment events', function() {
-      var builder;
+    suite('comment events', () => {
+      let builder;
 
-      setup(function() {
+      setup(() => {
         builder = {
           getContentsByLineRange: sandbox.stub().returns([]),
           getLineElByChild: sandbox.stub().returns({}),
@@ -176,25 +175,25 @@
         element._cachedDiffBuilder = builder;
       });
 
-      test('comment-mouse-over from line comments is ignored', function() {
+      test('comment-mouse-over from line comments is ignored', () => {
         sandbox.stub(element, 'set');
         element.fire('comment-mouse-over', {comment: {}});
         assert.isFalse(element.set.called);
       });
 
-      test('comment-mouse-over from ranged comment causes set', function() {
+      test('comment-mouse-over from ranged comment causes set', () => {
         sandbox.stub(element, 'set');
         sandbox.stub(element, '_indexOfComment').returns(0);
         element.fire('comment-mouse-over', {comment: {range: {}}});
         assert.isTrue(element.set.called);
       });
 
-      test('comment-mouse-out from line comments is ignored', function() {
+      test('comment-mouse-out from line comments is ignored', () => {
         element.fire('comment-mouse-over', {comment: {}});
         assert.isFalse(builder.getContentsByLineRange.called);
       });
 
-      test('on create-comment action box is removed', function() {
+      test('on create-comment action box is removed', () => {
         sandbox.stub(element, '_removeActionBox');
         element.fire('create-comment', {
           comment: {
@@ -205,21 +204,21 @@
       });
     });
 
-    suite('selection', function() {
-      var diff;
-      var builder;
-      var contentStubs;
+    suite('selection', () => {
+      let diff;
+      let builder;
+      let contentStubs;
 
-      var stubContent = function(line, side, opt_child) {
-        var contentTd = diff.querySelector(
-            '.' + side + '.lineNum[data-value="' + line + '"] ~ .content');
-        var contentText = contentTd.querySelector('.contentText');
-        var lineEl = diff.querySelector(
-            '.' + side + '.lineNum[data-value="' + line + '"]');
+      const stubContent = (line, side, opt_child) => {
+        const contentTd = diff.querySelector(
+            `.${side}.lineNum[data-value="${line}"] ~ .content`);
+        const contentText = contentTd.querySelector('.contentText');
+        const lineEl = diff.querySelector(
+            `.${side}.lineNum[data-value="${line}"]`);
         contentStubs.push({
-          lineEl: lineEl,
-          contentTd: contentTd,
-          contentText: contentText,
+          lineEl,
+          contentTd,
+          contentText,
         });
         builder.getContentByLineEl.withArgs(lineEl).returns(contentText);
         builder.getLineNumberByChild.withArgs(lineEl).returns(line);
@@ -228,34 +227,29 @@
         return contentText;
       };
 
-      var emulateSelection = function(
-          startNode, startOffset, endNode, endOffset) {
-        var selection = window.getSelection();
-        var range = document.createRange();
+      const emulateSelection = (startNode, startOffset, endNode, endOffset) => {
+        const selection = window.getSelection();
+        const range = document.createRange();
         range.setStart(startNode, startOffset);
         range.setEnd(endNode, endOffset);
         selection.addRange(range);
         element._handleSelection();
       };
 
-      var getActionRange = function() {
-        return Polymer.dom(element.root).querySelector(
-            'gr-selection-action-box').range;
-      };
+      const getActionRange = () =>
+          Polymer.dom(element.root).querySelector(
+              'gr-selection-action-box').range;
 
-      var getActionSide = function() {
-        return Polymer.dom(element.root).querySelector(
-            'gr-selection-action-box').side;
-      };
+      const getActionSide = () =>
+          Polymer.dom(element.root).querySelector(
+              'gr-selection-action-box').side;
 
-      var getLineElByChild = function(node) {
-        var stubs = contentStubs.find(function(stub) {
-          return stub.contentTd.contains(node);
-        });
+      const getLineElByChild = node => {
+        const stubs = contentStubs.find(stub => stub.contentTd.contains(node));
         return stubs && stubs.lineEl;
       };
 
-      setup(function() {
+      setup(() => {
         contentStubs = [];
         stub('gr-selection-action-box', {
           placeAbove: sandbox.stub(),
@@ -264,20 +258,20 @@
         builder = {
           getContentByLine: sandbox.stub(),
           getContentByLineEl: sandbox.stub(),
-          getLineElByChild: getLineElByChild,
+          getLineElByChild,
           getLineNumberByChild: sandbox.stub(),
           getSideByLineEl: sandbox.stub(),
         };
         element._cachedDiffBuilder = builder;
       });
 
-      teardown(function() {
+      teardown(() => {
         contentStubs = null;
         window.getSelection().removeAllRanges();
       });
 
-      test('single line', function() {
-        var content = stubContent(138, 'left');
+      test('single line', () => {
+        const content = stubContent(138, 'left');
         emulateSelection(content.firstChild, 5, content.firstChild, 12);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -289,9 +283,9 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('multiline', function() {
-        var startContent = stubContent(119, 'right');
-        var endContent = stubContent(120, 'right');
+      test('multiline', () => {
+        const startContent = stubContent(119, 'right');
+        const endContent = stubContent(120, 'right');
         emulateSelection(
             startContent.firstChild, 10, endContent.lastChild, 7);
         assert.isTrue(element.isRangeSelected());
@@ -305,18 +299,18 @@
       });
 
       test('multiple ranges aka firefox implementation', () => {
-        var startContent = stubContent(119, 'right');
-        var endContent = stubContent(120, 'right');
+        const startContent = stubContent(119, 'right');
+        const endContent = stubContent(120, 'right');
 
-        var startRange = document.createRange();
+        const startRange = document.createRange();
         startRange.setStart(startContent.firstChild, 10);
         startRange.setEnd(startContent.firstChild, 11);
 
-        var endRange = document.createRange();
+        const endRange = document.createRange();
         endRange.setStart(endContent.lastChild, 6);
         endRange.setEnd(endContent.lastChild, 7);
 
-        var getRangeAtStub = sandbox.stub();
+        const getRangeAtStub = sandbox.stub();
         getRangeAtStub
             .onFirstCall().returns(startRange)
             .onSecondCall().returns(endRange);
@@ -335,9 +329,9 @@
         });
       });
 
-      test('multiline grow end highlight over tabs', function() {
-        var startContent = stubContent(119, 'right');
-        var endContent = stubContent(120, 'right');
+      test('multiline grow end highlight over tabs', () => {
+        const startContent = stubContent(119, 'right');
+        const endContent = stubContent(120, 'right');
         emulateSelection(startContent.firstChild, 10, endContent.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -349,16 +343,16 @@
         assert.equal(getActionSide(), 'right');
       });
 
-      test('collapsed', function() {
-        var content = stubContent(138, 'left');
+      test('collapsed', () => {
+        const content = stubContent(138, 'left');
         emulateSelection(content.firstChild, 5, content.firstChild, 5);
         assert.isOk(window.getSelection().getRangeAt(0).startContainer);
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('starts inside hl', function() {
-        var content = stubContent(140, 'left');
-        var hl = content.querySelector('.foo');
+      test('starts inside hl', () => {
+        const content = stubContent(140, 'left');
+        const hl = content.querySelector('.foo');
         emulateSelection(hl.firstChild, 2, hl.nextSibling, 7);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -370,9 +364,9 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('ends inside hl', function() {
-        var content = stubContent(140, 'left');
-        var hl = content.querySelector('.bar');
+      test('ends inside hl', () => {
+        const content = stubContent(140, 'left');
+        const hl = content.querySelector('.bar');
         emulateSelection(hl.previousSibling, 2, hl.firstChild, 3);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -383,9 +377,9 @@
         });
       });
 
-      test('multiple hl', function() {
-        var content = stubContent(140, 'left');
-        var hl = content.querySelectorAll('hl')[4];
+      test('multiple hl', () => {
+        const content = stubContent(140, 'left');
+        const hl = content.querySelectorAll('hl')[4];
         emulateSelection(content.firstChild, 2, hl.firstChild, 2);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -397,34 +391,34 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('starts outside of diff', function() {
-        var contentText = stubContent(140, 'left');
-        var contentTd = contentText.parentElement;
+      test('starts outside of diff', () => {
+        const contentText = stubContent(140, 'left');
+        const contentTd = contentText.parentElement;
 
         emulateSelection(contentTd.previousElementSibling, 0,
             contentText.firstChild, 2);
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('ends outside of diff', function() {
-        var content = stubContent(140, 'left');
+      test('ends outside of diff', () => {
+        const content = stubContent(140, 'left');
         emulateSelection(content.nextElementSibling.firstChild, 2,
             content.firstChild, 2);
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('starts and ends on different sides', function() {
-        var startContent = stubContent(140, 'left');
-        var endContent = stubContent(130, 'right');
+      test('starts and ends on different sides', () => {
+        const startContent = stubContent(140, 'left');
+        const endContent = stubContent(130, 'right');
         emulateSelection(startContent.firstChild, 2, endContent.firstChild, 2);
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('starts in comment thread element', function() {
-        var startContent = stubContent(140, 'left');
-        var comment = startContent.parentElement.querySelector(
+      test('starts in comment thread element', () => {
+        const startContent = stubContent(140, 'left');
+        const comment = startContent.parentElement.querySelector(
             'gr-diff-comment-thread');
-        var endContent = stubContent(141, 'left');
+        const endContent = stubContent(141, 'left');
         emulateSelection(comment.firstChild, 2, endContent.firstChild, 4);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -436,9 +430,9 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('ends in comment thread element', function() {
-        var content = stubContent(140, 'left');
-        var comment = content.parentElement.querySelector(
+      test('ends in comment thread element', () => {
+        const content = stubContent(140, 'left');
+        const comment = content.parentElement.querySelector(
             'gr-diff-comment-thread');
         emulateSelection(content.firstChild, 4, comment.firstChild, 1);
         assert.isTrue(element.isRangeSelected());
@@ -451,27 +445,27 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('starts in context element', function() {
-        var contextControl =
+      test('starts in context element', () => {
+        const contextControl =
             diff.querySelector('.contextControl').querySelector('gr-button');
-        var content = stubContent(146, 'right');
+        const content = stubContent(146, 'right');
         emulateSelection(contextControl, 0, content.firstChild, 7);
         // TODO (viktard): Select nearest line.
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('ends in context element', function() {
-        var contextControl =
+      test('ends in context element', () => {
+        const contextControl =
             diff.querySelector('.contextControl').querySelector('gr-button');
-        var content = stubContent(141, 'left');
+        const content = stubContent(141, 'left');
         emulateSelection(content.firstChild, 2, contextControl, 1);
         // TODO (viktard): Select nearest line.
         assert.isFalse(element.isRangeSelected());
       });
 
-      test('selection containing context element', function() {
-        var startContent = stubContent(130, 'right');
-        var endContent = stubContent(146, 'right');
+      test('selection containing context element', () => {
+        const startContent = stubContent(130, 'right');
+        const endContent = stubContent(146, 'right');
         emulateSelection(startContent.firstChild, 3, endContent.firstChild, 14);
         assert.isTrue(element.isRangeSelected());
         assert.deepEqual(getActionRange(), {
@@ -483,8 +477,8 @@
         assert.equal(getActionSide(), 'right');
       });
 
-      test('ends at a tab', function() {
-        var content = stubContent(140, 'left');
+      test('ends at a tab', () => {
+        const content = stubContent(140, 'left');
         emulateSelection(
             content.firstChild, 1, content.querySelector('span'), 0);
         assert.isTrue(element.isRangeSelected());
@@ -497,8 +491,8 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('starts at a tab', function() {
-        var content = stubContent(140, 'left');
+      test('starts at a tab', () => {
+        const content = stubContent(140, 'left');
         emulateSelection(
             content.querySelectorAll('hl')[3], 0,
             content.querySelectorAll('span')[1].nextSibling, 1);
@@ -512,21 +506,21 @@
         assert.equal(getActionSide(), 'left');
       });
 
-      test('properly accounts for syntax highlighting', function() {
-        var content = stubContent(140, 'left');
-        var spy = sinon.spy(element, '_normalizeRange');
+      test('properly accounts for syntax highlighting', () => {
+        const content = stubContent(140, 'left');
+        const spy = sinon.spy(element, '_normalizeRange');
         emulateSelection(
             content.querySelectorAll('hl')[3], 0,
             content.querySelectorAll('span')[1], 0);
-        var spyCall = spy.getCall(0);
-        var range = window.getSelection().getRangeAt(0);
+        const spyCall = spy.getCall(0);
+        const range = window.getSelection().getRangeAt(0);
         assert.notDeepEqual(spyCall.returnValue, range);
       });
 
-      test('GrRangeNormalizer._getTextOffset computes text offset', function() {
-        var content = stubContent(140, 'left');
-        var child = content.lastChild.lastChild;
-        var result = GrRangeNormalizer._getTextOffset(content, child);
+      test('GrRangeNormalizer._getTextOffset computes text offset', () => {
+        let content = stubContent(140, 'left');
+        let child = content.lastChild.lastChild;
+        let result = GrRangeNormalizer._getTextOffset(content, child);
         assert.equal(result, 75);
         content = stubContent(146, 'right');
         child = content.lastChild;
@@ -540,15 +534,15 @@
       // TODO (viktard): Only empty lines selected.
       // TODO (viktard): Unified mode.
 
-      suite('triple click', function() {
-        test('_fixTripleClickSelection', function() {
-          var fakeRange = {
+      suite('triple click', () => {
+        test('_fixTripleClickSelection', () => {
+          const fakeRange = {
             startContainer: '',
             startOffset: '',
             endContainer: '',
-            endOffset: ''
+            endOffset: '',
           };
-          var fixedRange = {};
+          const fixedRange = {};
           sandbox.stub(GrRangeNormalizer, 'normalize').returns(fakeRange);
           sandbox.stub(element, '_normalizeSelectionSide');
           sandbox.stub(element, '_fixTripleClickSelection').returns(fixedRange);
@@ -556,9 +550,9 @@
           assert.isTrue(element._fixTripleClickSelection.called);
         });
 
-        test('left pane', function() {
-          var startNode = stubContent(138, 'left');
-          var endNode =
+        test('left pane', () => {
+          const startNode = stubContent(138, 'left');
+          const endNode =
               stubContent(119, 'right').parentElement.previousElementSibling;
           builder.getLineNumberByChild.withArgs(endNode).returns(119);
           emulateSelection(startNode, 0, endNode, 0);
@@ -570,9 +564,9 @@
           });
         });
 
-        test('right pane', function() {
-          var startNode = stubContent(119, 'right');
-          var endNode =
+        test('right pane', () => {
+          const startNode = stubContent(119, 'right');
+          const endNode =
               stubContent(140, 'left').parentElement.previousElementSibling;
           emulateSelection(startNode, 0, endNode, 0);
           assert.deepEqual(getActionRange(), {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
index e870169..1ea5037 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-highlight/gr-range-normalizer.js
@@ -18,36 +18,36 @@
   if (window.GrRangeNormalizer) { return; }
 
   // Astral code point as per https://mathiasbynens.be/notes/javascript-unicode
-  var REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
+  const REGEX_ASTRAL_SYMBOL = /[\uD800-\uDBFF][\uDC00-\uDFFF]/;
 
-  var GrRangeNormalizer = {
+  const GrRangeNormalizer = {
     /**
      * Remap DOM range to whole lines of a diff if necessary. If the start or
      * end containers are DOM elements that are singular pieces of syntax
      * highlighting, the containers are remapped to the .contentText divs that
      * contain the entire line of code.
      *
-     * @param {Object} range - the standard DOM selector range.
-     * @return {Object} A modified version of the range that correctly accounts
+     * @param {!Object} range - the standard DOM selector range.
+     * @return {!Object} A modified version of the range that correctly accounts
      *     for syntax highlighting.
      */
-    normalize: function(range) {
-      var startContainer = this._getContentTextParent(range.startContainer);
-      var startOffset = range.startOffset + this._getTextOffset(startContainer,
-          range.startContainer);
-      var endContainer = this._getContentTextParent(range.endContainer);
-      var endOffset = range.endOffset + this._getTextOffset(endContainer,
+    normalize(range) {
+      const startContainer = this._getContentTextParent(range.startContainer);
+      const startOffset = range.startOffset +
+          this._getTextOffset(startContainer, range.startContainer);
+      const endContainer = this._getContentTextParent(range.endContainer);
+      const endOffset = range.endOffset + this._getTextOffset(endContainer,
           range.endContainer);
       return {
-        startContainer: startContainer,
-        startOffset: startOffset,
-        endContainer: endContainer,
-        endOffset: endOffset,
+        startContainer,
+        startOffset,
+        endContainer,
+        endOffset,
       };
     },
 
-    _getContentTextParent: function(target) {
-      var element = target;
+    _getContentTextParent(target) {
+      let element = target;
       if (element.nodeName === '#text') {
         element = element.parentElement;
       }
@@ -69,18 +69,18 @@
      * @param {!Element} child The child element being searched for.
      * @return {number}
      */
-    _getTextOffset: function(node, child) {
-      var count = 0;
-      var stack = [node];
+    _getTextOffset(node, child) {
+      let count = 0;
+      let stack = [node];
       while (stack.length) {
-        var n = stack.pop();
+        const n = stack.pop();
         if (n === child) {
           break;
         }
         if (n.childNodes && n.childNodes.length !== 0) {
-          var arr = [];
-          for (var i = 0; i < n.childNodes.length; i++) {
-            arr.push(n.childNodes[i]);
+          const arr = [];
+          for (const childNode of n.childNodes) {
+            arr.push(childNode);
           }
           arr.reverse();
           stack = stack.concat(arr);
@@ -94,10 +94,10 @@
     /**
      * The DOM API textContent.length calculation is broken when the text
      * contains Unicode. See https://mathiasbynens.be/notes/javascript-unicode .
-     * @param {Text} A text node.
-     * @return {Number} The length of the text.
+     * @param {text} node A text node.
+     * @return {number} The length of the text.
      */
-    _getLength: function(node) {
+    _getLength(node) {
       return node.textContent.replace(REGEX_ASTRAL_SYMBOL, '_').length;
     },
   };
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
index fe57c43..7e6d54d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.html
@@ -17,15 +17,18 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-storage/gr-storage.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-diff-preferences">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
-      :host[disabled] {
+      :host([disabled]) {
         opacity: .5;
         pointer-events: none;
       }
@@ -42,7 +45,7 @@
       }
       .header {
         border-bottom: 1px solid #ddd;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       .mainContainer {
         padding: 1em 0;
@@ -63,78 +66,91 @@
       .actions {
         border-top: 1px solid #ddd;
         display: flex;
-        justify-content: space-between;
+        justify-content: flex-end;
       }
       .beta {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         color: #888;
       }
+      gr-button {
+        margin-left: 1em;
+      }
     </style>
-    <div class="header">
-      Diff View Preferences
-    </div>
-    <div class="mainContainer">
-      <div class="pref">
-        <label for="contextSelect">Context</label>
-        <select id="contextSelect" on-change="_handleContextSelectChange">
-          <option value="3">3 lines</option>
-          <option value="10">10 lines</option>
-          <option value="25">25 lines</option>
-          <option value="50">50 lines</option>
-          <option value="75">75 lines</option>
-          <option value="100">100 lines</option>
-          <option value="-1">Whole file</option>
-        </select>
+
+    <gr-overlay id="prefsOverlay" with-backdrop>
+      <div class="header">
+        Diff View Preferences
       </div>
-      <div class="pref">
-        <label for="lineWrappingInput">Fit to screen</label>
-        <input
-            is="iron-input"
-            type="checkbox"
-            id="lineWrappingInput"
-            on-tap="_handlelineWrappingTap">
+      <div class="mainContainer">
+        <div class="pref">
+          <label for="contextSelect">Context</label>
+          <select id="contextSelect" on-change="_handleContextSelectChange">
+            <option value="3">3 lines</option>
+            <option value="10">10 lines</option>
+            <option value="25">25 lines</option>
+            <option value="50">50 lines</option>
+            <option value="75">75 lines</option>
+            <option value="100">100 lines</option>
+            <option value="-1">Whole file</option>
+          </select>
+        </div>
+        <div class="pref">
+          <label for="lineWrappingInput">Fit to screen</label>
+          <input
+              is="iron-input"
+              type="checkbox"
+              id="lineWrappingInput"
+              on-tap="_handlelineWrappingTap">
+        </div>
+        <div class="pref" id="columnsPref"
+            hidden$="[[_newPrefs.line_wrapping]]">
+          <label for="columnsInput">Diff width</label>
+          <input is="iron-input" type="number" id="columnsInput"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{_newPrefs.line_length}}">
+        </div>
+        <div class="pref">
+          <label for="tabSizeInput">Tab width</label>
+          <input is="iron-input" type="number" id="tabSizeInput"
+              prevent-invalid-input
+              allowed-pattern="[0-9]"
+              bind-value="{{_newPrefs.tab_size}}">
+        </div>
+        <div class="pref" hidden$="[[!_newPrefs.font_size]]">
+          <label for="fontSizeInput">Font size</label>
+          <input is="iron-input" type="number" id="fontSizeInput"
+                prevent-invalid-input
+                allowed-pattern="[0-9]"
+                bind-value="{{_newPrefs.font_size}}">
+        </div>
+        <div class="pref">
+          <label for="showTabsInput">Show tabs</label>
+          <input is="iron-input" type="checkbox" id="showTabsInput"
+              on-tap="_handleShowTabsTap">
+        </div>
+        <div class="pref">
+          <label for="showTrailingWhitespaceInput">
+            Show trailing whitespace</label>
+          <input is="iron-input" type="checkbox"
+              id="showTrailingWhitespaceInput"
+              on-tap="_handleShowTrailingWhitespaceTap">
+        </div>
+        <div class="pref">
+          <label for="syntaxHighlightInput">Syntax highlighting</label>
+          <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
+              on-tap="_handleSyntaxHighlightTap">
+        </div>
       </div>
-      <div class="pref" id="columnsPref" hidden$="[[_newPrefs.line_wrapping]]">
-        <label for="columnsInput">Diff width</label>
-        <input is="iron-input" type="number" id="columnsInput"
-            prevent-invalid-input
-            allowed-pattern="[0-9]"
-            bind-value="{{_newPrefs.line_length}}">
+      <div class="actions">
+        <gr-button id="cancelButton" link on-tap="_handleCancel">
+            Cancel</gr-button>
+        <gr-button id="saveButton" link primary on-tap="_handleSave">
+            Save</gr-button>
       </div>
-      <div class="pref">
-        <label for="tabSizeInput">Tab width</label>
-        <input is="iron-input" type="number" id="tabSizeInput"
-            prevent-invalid-input
-            allowed-pattern="[0-9]"
-            bind-value="{{_newPrefs.tab_size}}">
-      </div>
-      <div class="pref" hidden$="[[!_newPrefs.font_size]]">
-        <label for="fontSizeInput">Font size</label>
-        <input is="iron-input" type="number" id="fontSizeInput"
-               prevent-invalid-input
-               allowed-pattern="[0-9]"
-               bind-value="{{_newPrefs.font_size}}">
-      </div>
-      <div class="pref">
-        <label for="showTabsInput">Show tabs</label>
-        <input is="iron-input" type="checkbox" id="showTabsInput"
-            on-tap="_handleShowTabsTap">
-      </div>
-      <div class="pref">
-        <label for="showTrailingWhitespaceInput">Show trailing whitespace</label>
-        <input is="iron-input" type="checkbox" id="showTrailingWhitespaceInput"
-            on-tap="_handleShowTrailingWhitespaceTap">
-      </div>
-      <div class="pref">
-        <label for="syntaxHighlightInput">Syntax highlighting</label>
-        <input is="iron-input" type="checkbox" id="syntaxHighlightInput"
-            on-tap="_handleSyntaxHighlightTap">
-      </div>
-    </div>
-    <div class="actions">
-      <gr-button id="saveButton" primary on-tap="_handleSave">Save</gr-button>
-      <gr-button id="cancelButton" on-tap="_handleCancel">Cancel</gr-button>
-    </div>
+    </overlay>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+    <gr-storage id="storage"></gr-storage>
   </template>
   <script src="gr-diff-preferences.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
index fd2a6f5..f5944c3 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences.js
@@ -17,18 +17,6 @@
   Polymer({
     is: 'gr-diff-preferences',
 
-    /**
-     * Fired when the user presses the save button.
-     *
-     * @event save
-     */
-
-    /**
-     * Fired when the user presses the cancel button.
-     *
-     * @event cancel
-     */
-
     properties: {
       prefs: {
         type: Object,
@@ -44,6 +32,7 @@
         reflectToAttribute: true,
       },
 
+      /** @type {?} */
       _newPrefs: Object,
       _newLocalPrefs: Object,
     },
@@ -53,20 +42,19 @@
       '_localPrefsChanged(localPrefs.*)',
     ],
 
-    getFocusStops: function() {
+    getFocusStops() {
       return {
         start: this.$.contextSelect,
         end: this.$.cancelButton,
       };
     },
 
-    resetFocus: function() {
+    resetFocus() {
       this.$.contextSelect.focus();
     },
 
-    _prefsChanged: function(changeRecord) {
-      var prefs = changeRecord.base;
-      // TODO(andybons): This is not supported in IE. Implement a polyfill.
+    _prefsChanged(changeRecord) {
+      const prefs = changeRecord.base;
       // NOTE: Object.assign is NOT automatically a deep copy. If prefs adds
       // an object as a value, it must be marked enumerable.
       this._newPrefs = Object.assign({}, prefs);
@@ -77,43 +65,71 @@
       this.$.syntaxHighlightInput.checked = prefs.syntax_highlighting;
     },
 
-    _localPrefsChanged: function(changeRecord) {
-      var localPrefs = changeRecord.base || {};
-      // TODO(viktard): This is not supported in IE. Implement a polyfill.
+    _localPrefsChanged(changeRecord) {
+      const localPrefs = changeRecord.base || {};
       this._newLocalPrefs = Object.assign({}, localPrefs);
     },
 
-    _handleContextSelectChange: function(e) {
-      var selectEl = Polymer.dom(e).rootTarget;
+    _handleContextSelectChange(e) {
+      const selectEl = Polymer.dom(e).rootTarget;
       this.set('_newPrefs.context', parseInt(selectEl.value, 10));
     },
 
-    _handleShowTabsTap: function(e) {
+    _handleShowTabsTap(e) {
       this.set('_newPrefs.show_tabs', Polymer.dom(e).rootTarget.checked);
     },
 
-    _handleShowTrailingWhitespaceTap: function(e) {
+    _handleShowTrailingWhitespaceTap(e) {
       this.set('_newPrefs.show_whitespace_errors',
           Polymer.dom(e).rootTarget.checked);
     },
 
-    _handleSyntaxHighlightTap: function(e) {
+    _handleSyntaxHighlightTap(e) {
       this.set('_newPrefs.syntax_highlighting',
           Polymer.dom(e).rootTarget.checked);
     },
 
-    _handlelineWrappingTap: function(e) {
+    _handlelineWrappingTap(e) {
       this.set('_newPrefs.line_wrapping', Polymer.dom(e).rootTarget.checked);
     },
 
-    _handleSave: function() {
+    _handleSave(e) {
+      e.stopPropagation();
       this.prefs = this._newPrefs;
       this.localPrefs = this._newLocalPrefs;
-      this.fire('save', null, {bubbles: false});
+      const el = Polymer.dom(e).rootTarget;
+      el.disabled = true;
+      this.$.storage.savePreferences(this._localPrefs);
+      this._saveDiffPreferences().then(response => {
+        el.disabled = false;
+        if (!response.ok) { return response; }
+
+        this.$.prefsOverlay.close();
+      }).catch(err => {
+        el.disabled = false;
+      });
     },
 
-    _handleCancel: function() {
-      this.fire('cancel', null, {bubbles: false});
+    _handleCancel(e) {
+      e.stopPropagation();
+      this.$.prefsOverlay.close();
+    },
+
+    _handlePrefsTap(e) {
+      e.preventDefault();
+      this._openPrefs();
+    },
+
+    open() {
+      this.$.prefsOverlay.open().then(() => {
+        const focusStops = this.getFocusStops();
+        this.$.prefsOverlay.setFocusStops(focusStops);
+        this.resetFocus();
+      });
+    },
+
+    _saveDiffPreferences() {
+      return this.$.restAPI.saveDiffPreferences(this.prefs);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
index 06f617a..f06cd3a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-preferences/gr-diff-preferences_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-diff-preferences.html">
 
 <script>void(0);</script>
@@ -33,14 +32,20 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-preferences tests', function() {
-    var element;
+  suite('gr-diff-preferences tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
+      sandbox = sinon.sandbox.create();
       element = fixture('basic');
     });
 
-    test('model changes', function() {
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('model changes', () => {
       element.prefs = {
         context: 10,
         font_size: 12,
@@ -72,7 +77,7 @@
       assert.isFalse(element._newPrefs.syntax_highlighting);
     });
 
-    test('clicking fit to screen hides line length input', function() {
+    test('clicking fit to screen hides line length input', () => {
       element.prefs = {line_wrapping: false};
 
       assert.isFalse(element.$.columnsPref.hidden);
@@ -84,26 +89,33 @@
       assert.isFalse(element.$.columnsPref.hidden);
     });
 
-    test('clicking save button calls _handleSave function', function() {
-      var savePrefs = sinon.stub(element, '_handleSave');
+    test('clicking save button calls _handleSave function', () => {
+      const savePrefs = sinon.stub(element, '_handleSave');
       MockInteractions.tap(element.$.saveButton);
       flushAsynchronousOperations();
       assert(savePrefs.calledOnce);
       savePrefs.restore();
     });
 
-    test('events', function(done) {
-      var savePromise = new Promise(function(resolve) {
-        element.addEventListener('save', function() { resolve(); });
-      });
-      var cancelPromise = new Promise(function(resolve) {
-        element.addEventListener('cancel', function() { resolve(); });
-      });
-      Promise.all([savePromise, cancelPromise]).then(function() {
-        done();
-      });
+    test('save button', () => {
+      element.prefs = {
+        font_size: '11',
+      };
+      element._newPrefs = {
+        font_size: '12',
+      };
+      const saveStub = sandbox.stub(element.$.restAPI, 'saveDiffPreferences',
+          () => { return Promise.resolve(); });
+
       MockInteractions.tap(element.$$('gr-button[primary]'));
+      assert.deepEqual(element.prefs, element._newPrefs);
+      assert.deepEqual(saveStub.lastCall.args[0], element._newPrefs);
+    });
+
+    test('cancel button', () => {
+      const closeStub = sandbox.stub(element.$.prefsOverlay, 'close');
       MockInteractions.tap(element.$$('gr-button:not([primary])'));
+      assert.isTrue(closeStub.called);
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index 95ff5b7..bca6bea 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -14,20 +14,20 @@
 (function() {
   'use strict';
 
-  var WHOLE_FILE = -1;
+  const WHOLE_FILE = -1;
 
-  var DiffSide = {
+  const DiffSide = {
     LEFT: 'left',
     RIGHT: 'right',
   };
 
-  var DiffGroupType = {
+  const DiffGroupType = {
     ADDED: 'b',
     BOTH: 'ab',
     REMOVED: 'a',
   };
 
-  var DiffHighlights = {
+  const DiffHighlights = {
     ADDED: 'edit_b',
     REMOVED: 'edit_a',
   };
@@ -36,11 +36,11 @@
    * The maximum size for an addition or removal chunk before it is broken down
    * into a series of chunks that are this size at most.
    *
-   * Note: The value of 70 is chosen so that it is larger than the default
+   * Note: The value of 120 is chosen so that it is larger than the default
    * _asyncThreshold of 64, but feel free to tune this constant to your
    * performance needs.
    */
-  var MAX_GROUP_SIZE = 70;
+  const MAX_GROUP_SIZE = 120;
 
   Polymer({
     is: 'gr-diff-processor',
@@ -66,7 +66,7 @@
        */
       keyLocations: {
         type: Object,
-        value: function() { return {left: {}, right: {}}; },
+        value() { return {left: {}, right: {}}; },
       },
 
       /**
@@ -77,22 +77,23 @@
         value: 64,
       },
 
+      /** @type {number|undefined} */
       _nextStepHandle: Number,
       _isScrolling: Boolean,
     },
 
-    attached: function() {
+    attached() {
       this.listen(window, 'scroll', '_handleWindowScroll');
     },
 
-    detached: function() {
+    detached() {
       this.cancel();
       this.unlisten(window, 'scroll', '_handleWindowScroll');
     },
 
-    _handleWindowScroll: function() {
+    _handleWindowScroll() {
       this._isScrolling = true;
-      this.debounce('resetIsScrolling', function() {
+      this.debounce('resetIsScrolling', () => {
         this._isScrolling = false;
       }, 50);
     },
@@ -103,21 +104,23 @@
      * @return {Promise} A promise that resolves when the diff is completely
      *     processed.
      */
-    process: function(content) {
-      return new Promise(function(resolve) {
-        this.groups = [];
-        this.push('groups', this._makeFileComments());
+    process(content, isImageDiff) {
+      this.groups = [];
+      this.push('groups', this._makeFileComments());
 
-        var state = {
+      // If image diff, only render the file lines.
+      if (isImageDiff) { return Promise.resolve(); }
+
+      return new Promise(resolve => {
+        const state = {
           lineNums: {left: 0, right: 0},
           sectionIndex: 0,
         };
 
         content = this._splitCommonGroupsWithComments(content);
 
-        var currentBatch = 0;
-        var nextStep = function() {
-
+        let currentBatch = 0;
+        const nextStep = () => {
           if (this._isScrolling) {
             this.async(nextStep, 100);
             return;
@@ -130,11 +133,11 @@
           }
 
           // Process the next section and incorporate the result.
-          var result = this._processNext(state, content);
-          result.groups.forEach(function(group) {
+          const result = this._processNext(state, content);
+          for (const group of result.groups) {
             this.push('groups', group);
             currentBatch += group.lines.length;
-          }, this);
+          }
           state.lineNums.left += result.lineDelta.left;
           state.lineNums.right += result.lineDelta.right;
 
@@ -149,13 +152,13 @@
         };
 
         nextStep.call(this);
-      }.bind(this));
+      });
     },
 
     /**
      * Cancel any jobs that are running.
      */
-    cancel: function() {
+    cancel() {
       if (this._nextStepHandle !== undefined) {
         this.cancelAsync(this._nextStepHandle);
         this._nextStepHandle = undefined;
@@ -165,29 +168,29 @@
     /**
      * Process the next section of the diff.
      */
-    _processNext: function(state, content) {
-      var section = content[state.sectionIndex];
+    _processNext(state, content) {
+      const section = content[state.sectionIndex];
 
-      var rows = {
+      const rows = {
         both: section[DiffGroupType.BOTH] || null,
         added: section[DiffGroupType.ADDED] || null,
         removed: section[DiffGroupType.REMOVED] || null,
       };
 
-      var highlights = {
+      const highlights = {
         added: section[DiffHighlights.ADDED] || null,
         removed: section[DiffHighlights.REMOVED] || null,
       };
 
       if (rows.both) { // If it's a shared section.
-        var sectionEnd = null;
+        let sectionEnd = null;
         if (state.sectionIndex === 0) {
           sectionEnd = 'first';
         } else if (state.sectionIndex === content.length - 1) {
           sectionEnd = 'last';
         }
 
-        var sharedGroups = this._sharedGroupsFromRows(
+        const sharedGroups = this._sharedGroupsFromRows(
             rows.both,
             content.length > 1 ? this.context : WHOLE_FILE,
             state.lineNums.left,
@@ -202,13 +205,13 @@
           groups: sharedGroups,
         };
       } else { // Otherwise it's a delta section.
-
-        var deltaGroup = this._deltaGroupFromRows(
+        const deltaGroup = this._deltaGroupFromRows(
             rows.added,
             rows.removed,
             state.lineNums.left,
             state.lineNums.right,
             highlights);
+        deltaGroup.dueToRebase = section.due_to_rebase;
 
         return {
           lineDelta: {
@@ -223,23 +226,23 @@
     /**
      * Take rows of a shared diff section and produce an array of corresponding
      * (potentially collapsed) groups.
-     * @param {Array<String>} rows
-     * @param {Number} context
-     * @param {Number} startLineNumLeft
-     * @param {Number} startLineNumRight
-     * @param {String} opt_sectionEnd String representing whether this is the
+     * @param {!Array<string>} rows
+     * @param {number} context
+     * @param {number} startLineNumLeft
+     * @param {number} startLineNumRight
+     * @param {?string=} opt_sectionEnd String representing whether this is the
      *     first section or the last section or neither. Use the values 'first',
      *     'last' and null respectively.
-     * @return {Array<GrDiffGroup>}
+     * @return {!Array<!Object>} Array of GrDiffGroup
      */
-    _sharedGroupsFromRows: function(rows, context, startLineNumLeft,
+    _sharedGroupsFromRows(rows, context, startLineNumLeft,
         startLineNumRight, opt_sectionEnd) {
-      var result = [];
-      var lines = [];
-      var line;
+      const result = [];
+      const lines = [];
+      let line;
 
       // Map each row to a GrDiffLine.
-      for (var i = 0; i < rows.length; i++) {
+      for (let i = 0; i < rows.length; i++) {
         line = new GrDiffLine(GrDiffLine.Type.BOTH);
         line.text = rows[i];
         line.beforeNumber = ++startLineNumLeft;
@@ -250,7 +253,7 @@
       // Find the hidden range based on the user's context preference. If this
       // is the first or the last section of the diff, make sure the collapsed
       // part of the section extends to the edge of the file.
-      var hiddenRange = [context, rows.length - context];
+      const hiddenRange = [context, rows.length - context];
       if (opt_sectionEnd === 'first') {
         hiddenRange[0] = 0;
       } else if (opt_sectionEnd === 'last') {
@@ -259,15 +262,15 @@
 
       // If there is a range to hide.
       if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 1) {
-        var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
-        var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
-        var linesAfterCtx = lines.slice(hiddenRange[1]);
+        const linesBeforeCtx = lines.slice(0, hiddenRange[0]);
+        const hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
+        const linesAfterCtx = lines.slice(hiddenRange[1]);
 
         if (linesBeforeCtx.length > 0) {
           result.push(new GrDiffGroup(GrDiffGroup.Type.BOTH, linesBeforeCtx));
         }
 
-        var ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
+        const ctxLine = new GrDiffLine(GrDiffLine.Type.CONTEXT_CONTROL);
         ctxLine.contextGroup =
             new GrDiffGroup(GrDiffGroup.Type.BOTH, hiddenLines);
         result.push(new GrDiffGroup(GrDiffGroup.Type.CONTEXT_CONTROL,
@@ -286,15 +289,15 @@
     /**
      * Take the rows of a delta diff section and produce the corresponding
      * group.
-     * @param {Array<String>} rowsAdded
-     * @param {Array<String>} rowsRemoved
-     * @param {Number} startLineNumLeft
-     * @param {Number} startLineNumRight
-     * @return {GrDiffGroup}
+     * @param {!Array<string>} rowsAdded
+     * @param {!Array<string>} rowsRemoved
+     * @param {number} startLineNumLeft
+     * @param {number} startLineNumRight
+     * @return {!Object} (Gr-Diff-Group)
      */
-    _deltaGroupFromRows: function(rowsAdded, rowsRemoved, startLineNumLeft,
+    _deltaGroupFromRows(rowsAdded, rowsRemoved, startLineNumLeft,
         startLineNumRight, highlights) {
-      var lines = [];
+      let lines = [];
       if (rowsRemoved) {
         lines = lines.concat(this._deltaLinesFromRows(GrDiffLine.Type.REMOVE,
             rowsRemoved, startLineNumLeft, highlights.removed));
@@ -307,9 +310,9 @@
     },
 
     /**
-     * @return {Array<GrDiffLine>}
+     * @return {!Array<!Object>} Array of GrDiffLines
      */
-    _deltaLinesFromRows: function(lineType, rows, startLineNum,
+    _deltaLinesFromRows(lineType, rows, startLineNum,
         opt_highlights) {
       // Normalize highlights if they have been passed.
       if (opt_highlights) {
@@ -317,9 +320,9 @@
             opt_highlights);
       }
 
-      var lines = [];
-      var line;
-      for (var i = 0; i < rows.length; i++) {
+      const lines = [];
+      let line;
+      for (let i = 0; i < rows.length; i++) {
         line = new GrDiffLine(lineType);
         line.text = rows[i];
         if (lineType === GrDiffLine.Type.ADD) {
@@ -328,16 +331,15 @@
           line.beforeNumber = ++startLineNum;
         }
         if (opt_highlights) {
-          line.highlights = opt_highlights.filter(
-              function(hl) { return hl.contentIndex === i; });
+          line.highlights = opt_highlights.filter(hl => hl.contentIndex === i);
         }
         lines.push(line);
       }
       return lines;
     },
 
-    _makeFileComments: function() {
-      var line = new GrDiffLine(GrDiffLine.Type.BOTH);
+    _makeFileComments() {
+      const line = new GrDiffLine(GrDiffLine.Type.BOTH);
       line.beforeNumber = GrDiffLine.FILE;
       line.afterNumber = GrDiffLine.FILE;
       return new GrDiffGroup(GrDiffGroup.Type.BOTH, [line]);
@@ -347,33 +349,35 @@
      * In order to show comments out of the bounds of the selected context,
      * treat them as separate chunks within the model so that the content (and
      * context surrounding it) renders correctly.
-     * @param {Object} content The diff content object.
-     * @return {Object} A new diff content object with regions split up.
+     * @param {?} content The diff content object. (has to be iterable)
+     * @return {!Object} A new diff content object with regions split up.
      */
-    _splitCommonGroupsWithComments: function(content) {
-      var result = [];
-      var leftLineNum = 0;
-      var rightLineNum = 0;
+    _splitCommonGroupsWithComments(content) {
+      const result = [];
+      let leftLineNum = 0;
+      let rightLineNum = 0;
 
       // If the context is set to "whole file", then break down the shared
       // chunks so they can be rendered incrementally. Note: this is not enabled
       // for any other context preference because manipulating the chunks in
       // this way violates assumptions by the context grouper logic.
       if (this.context === -1) {
-        var newContent = [];
-        content.forEach(function(group) {
-          if (group.ab) {
-            newContent.push.apply(newContent, this._breakdownGroup(group));
+        const newContent = [];
+        for (const group of content) {
+          if (group.ab && group.ab.length > MAX_GROUP_SIZE * 2) {
+            // Split large shared groups in two, where the first is the maximum
+            // group size.
+            newContent.push({ab: group.ab.slice(0, MAX_GROUP_SIZE)});
+            newContent.push({ab: group.ab.slice(MAX_GROUP_SIZE)});
           } else {
             newContent.push(group);
           }
-        }.bind(this));
+        }
         content = newContent;
       }
 
       // For each section in the diff.
-      for (var i = 0; i < content.length; i++) {
-
+      for (let i = 0; i < content.length; i++) {
         // If it isn't a common group, append it as-is and update line numbers.
         if (!content[i].ab) {
           if (content[i].a) {
@@ -383,25 +387,24 @@
             rightLineNum += content[i].b.length;
           }
 
-          this._breakdownGroup(content[i]).forEach(function(group) {
+          for (const group of this._breakdownGroup(content[i])) {
             result.push(group);
-          });
+          }
 
           continue;
         }
 
-        var chunk = content[i].ab;
-        var currentChunk = {ab: []};
+        const chunk = content[i].ab;
+        let currentChunk = {ab: []};
 
         // For each line in the common group.
-        for (var j = 0; j < chunk.length; j++) {
+        for (const subChunk of chunk) {
           leftLineNum++;
           rightLineNum++;
 
           // If this line should not be collapsed.
           if (this.keyLocations[DiffSide.LEFT][leftLineNum] ||
               this.keyLocations[DiffSide.RIGHT][rightLineNum]) {
-
             // If any lines have been accumulated into the chunk leading up to
             // this non-collapse line, then add them as a chunk and start a new
             // one.
@@ -411,10 +414,10 @@
             }
 
             // Add the non-collapse line as its own chunk.
-            result.push({ab: [chunk[j]]});
+            result.push({ab: [subChunk]});
           } else {
             // Append the current line to the current chunk.
-            currentChunk.ab.push(chunk[j]);
+            currentChunk.ab.push(subChunk);
           }
         }
 
@@ -444,14 +447,13 @@
      * - endIndex: (optional) Where the highlight should end. If omitted, the
      *   highlight is meant to be a continuation onto the next line.
      */
-    _normalizeIntralineHighlights: function(content, highlights) {
-      var contentIndex = 0;
-      var idx = 0;
-      var normalized = [];
-      for (var i = 0; i < highlights.length; i++) {
-        var line = content[contentIndex] + '\n';
-        var hl = highlights[i];
-        var j = 0;
+    _normalizeIntralineHighlights(content, highlights) {
+      let contentIndex = 0;
+      let idx = 0;
+      const normalized = [];
+      for (const hl of highlights) {
+        let line = content[contentIndex] + '\n';
+        let j = 0;
         while (j < hl[0]) {
           if (idx === line.length) {
             idx = 0;
@@ -461,8 +463,8 @@
           idx++;
           j++;
         }
-        var lineHighlight = {
-          contentIndex: contentIndex,
+        let lineHighlight = {
+          contentIndex,
           startIndex: idx,
         };
 
@@ -473,7 +475,7 @@
             line = content[++contentIndex] + '\n';
             normalized.push(lineHighlight);
             lineHighlight = {
-              contentIndex: contentIndex,
+              contentIndex,
               startIndex: idx,
             };
             continue;
@@ -491,11 +493,11 @@
      * If a group is an addition or a removal, break it down into smaller groups
      * of that type using the MAX_GROUP_SIZE. If the group is a shared section
      * or a delta it is returned as the single element of the result array.
-     * @param {!Object} A raw chunk from a diff response.
+     * @param {!Object} group A raw chunk from a diff response.
      * @return {!Array<!Array<!Object>>}
      */
-    _breakdownGroup: function(group) {
-      var key = null;
+    _breakdownGroup(group) {
+      let key = null;
       if (group.a && !group.b) {
         key = 'a';
       } else if (group.b && !group.a) {
@@ -507,11 +509,14 @@
       if (!key) { return [group]; }
 
       return this._breakdown(group[key], MAX_GROUP_SIZE)
-        .map(function(subgroupLines) {
-          var subGroup = {};
-          subGroup[key] = subgroupLines;
-          return subGroup;
-        });
+          .map(subgroupLines => {
+            const subGroup = {};
+            subGroup[key] = subgroupLines;
+            if (group.due_to_rebase) {
+              subGroup.due_to_rebase = true;
+            }
+            return subGroup;
+          });
     },
 
     /**
@@ -522,12 +527,12 @@
      * @return {!Array<!Array<T>>}
      * @template T
      */
-    _breakdown: function(array, size) {
+    _breakdown(array, size) {
       if (!array.length) { return []; }
       if (array.length < size) { return [array]; }
 
-      var head = array.slice(0, array.length - size);
-      var tail = array.slice(array.length - size);
+      const head = array.slice(0, array.length - size);
+      const tail = array.slice(array.length - size);
 
       return this._breakdown(head, size).concat([tail]);
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
index 5429f52..fcb8aec 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-diff-processor.html">
 
 <script>void(0);</script>
@@ -33,40 +32,40 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-processor tests', function() {
-    var WHOLE_FILE = -1;
-    var loremIpsum = 'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
+  suite('gr-diff-processor tests', () => {
+    const WHOLE_FILE = -1;
+    const loremIpsum =
+        'Lorem ipsum dolor sit amet, ei nonumes vituperata ius. ' +
         'Duo  animal omnesque fabellas et. Id has phaedrum dignissim ' +
         'deterruisset, pro ei petentium comprehensam, ut vis solum dicta. ' +
         'Eos cu aliquam labores qualisque, usu postea inermis te, et solum ' +
         'fugit assum per.';
 
-    var element;
-    var sandbox;
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    suite('not logged in', function() {
-
-      setup(function() {
+    suite('not logged in', () => {
+      setup(() => {
         element = fixture('basic');
 
         element.context = 4;
       });
 
-      test('process loaded content', function(done) {
-        var content = [
+      test('process loaded content', done => {
+        const content = [
           {
             ab: [
               '<!DOCTYPE html>',
               '<meta charset="utf-8">',
-            ]
+            ],
           },
           {
             a: [
@@ -82,16 +81,16 @@
               'Leela: This is the only place the ship can’t hear us, so ',
               'everyone pretend to shower.',
               'Fry: Same as every day. Got it.',
-            ]
+            ],
           },
         ];
 
-        element.process(content).then(function() {
-          var groups = element.groups;
+        element.process(content).then(() => {
+          const groups = element.groups;
 
           assert.equal(groups.length, 4);
 
-          var group = groups[0];
+          let group = groups[0];
           assert.equal(group.type, GrDiffGroup.Type.BOTH);
           assert.equal(group.lines.length, 1);
           assert.equal(group.lines[0].text, '');
@@ -144,27 +143,27 @@
         });
       });
 
-      test('insert context groups', function(done) {
-        var content = [
+      test('insert context groups', done => {
+        const content = [
           {ab: []},
           {a: ['all work and no play make andybons a dull boy']},
           {ab: []},
           {b: ['elgoog elgoog elgoog']},
           {ab: []},
         ];
-        for (var i = 0; i < 100; i++) {
+        for (let i = 0; i < 100; i++) {
           content[0].ab.push('all work and no play make jack a dull boy');
           content[4].ab.push('all work and no play make jill a dull girl');
         }
-        for (var i = 0; i < 5; i++) {
+        for (let i = 0; i < 5; i++) {
           content[2].ab.push('no tv and no beer make homer go crazy');
         }
 
-        var context = 10;
+        const context = 10;
         element.context = context;
 
-        element.process(content).then(function() {
-          var groups = element.groups;
+        element.process(content).then(() => {
+          const groups = element.groups;
 
           assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[0].lines.length, 1);
@@ -175,15 +174,15 @@
           assert.equal(groups[1].type, GrDiffGroup.Type.CONTEXT_CONTROL);
           assert.instanceOf(groups[1].lines[0].contextGroup, GrDiffGroup);
           assert.equal(groups[1].lines[0].contextGroup.lines.length, 90);
-          groups[1].lines[0].contextGroup.lines.forEach(function(l) {
+          for (const l of groups[1].lines[0].contextGroup.lines) {
             assert.equal(l.text, content[0].ab[0]);
-          });
+          }
 
           assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[2].lines.length, context);
-          groups[2].lines.forEach(function(l) {
+          for (const l of groups[2].lines) {
             assert.equal(l.text, content[0].ab[0]);
-          });
+          }
 
           assert.equal(groups[3].type, GrDiffGroup.Type.DELTA);
           assert.equal(groups[3].lines.length, 1);
@@ -193,9 +192,9 @@
 
           assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[4].lines.length, 5);
-          groups[4].lines.forEach(function(l) {
+          for (const l of groups[4].lines) {
             assert.equal(l.text, content[2].ab[0]);
-          });
+          }
 
           assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
           assert.equal(groups[5].lines.length, 1);
@@ -204,36 +203,36 @@
 
           assert.equal(groups[6].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[6].lines.length, context);
-          groups[6].lines.forEach(function(l) {
+          for (const l of groups[6].lines) {
             assert.equal(l.text, content[4].ab[0]);
-          });
+          }
 
           assert.equal(groups[7].type, GrDiffGroup.Type.CONTEXT_CONTROL);
           assert.instanceOf(groups[7].lines[0].contextGroup, GrDiffGroup);
           assert.equal(groups[7].lines[0].contextGroup.lines.length, 90);
-          groups[7].lines[0].contextGroup.lines.forEach(function(l) {
+          for (const l of groups[7].lines[0].contextGroup.lines) {
             assert.equal(l.text, content[4].ab[0]);
-          });
+          }
 
           done();
         });
       });
 
-      test('insert context groups', function(done) {
-        var content = [
+      test('insert context groups', done => {
+        const content = [
           {a: ['all work and no play make andybons a dull boy']},
           {ab: []},
           {b: ['elgoog elgoog elgoog']},
         ];
-        for (var i = 0; i < 50; i++) {
+        for (let i = 0; i < 50; i++) {
           content[1].ab.push('no tv and no beer make homer go crazy');
         }
 
-        var context = 10;
+        const context = 10;
         element.context = context;
 
-        element.process(content).then(function() {
-          var groups = element.groups;
+        element.process(content).then(() => {
+          const groups = element.groups;
 
           assert.equal(groups[0].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[0].lines.length, 1);
@@ -249,22 +248,22 @@
 
           assert.equal(groups[2].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[2].lines.length, context);
-          groups[2].lines.forEach(function(l) {
+          for (const l of groups[2].lines) {
             assert.equal(l.text, content[1].ab[0]);
-          });
+          }
 
           assert.equal(groups[3].type, GrDiffGroup.Type.CONTEXT_CONTROL);
           assert.instanceOf(groups[3].lines[0].contextGroup, GrDiffGroup);
           assert.equal(groups[3].lines[0].contextGroup.lines.length, 30);
-          groups[3].lines[0].contextGroup.lines.forEach(function(l) {
+          for (const l of groups[3].lines[0].contextGroup.lines) {
             assert.equal(l.text, content[1].ab[0]);
-          });
+          }
 
           assert.equal(groups[4].type, GrDiffGroup.Type.BOTH);
           assert.equal(groups[4].lines.length, context);
-          groups[4].lines.forEach(function(l) {
+          for (const l of groups[4].lines) {
             assert.equal(l.text, content[1].ab[0]);
-          });
+          }
 
           assert.equal(groups[5].type, GrDiffGroup.Type.DELTA);
           assert.equal(groups[5].lines.length, 1);
@@ -275,14 +274,14 @@
         });
       });
 
-      test('break up common diff chunks', function() {
+      test('break up common diff chunks', () => {
         element.keyLocations = {
           left: {1: true},
           right: {10: true},
         };
-        var lineNums = {left: 0, right: 0};
+        const lineNums = {left: 0, right: 0};
 
-        var content = [
+        const content = [
           {
             ab: [
               'Copyright (C) 2015 The Android Open Source Project',
@@ -300,10 +299,11 @@
               'either express or implied. See the License for the specific ',
               'language governing permissions and limitations under the ' +
                   'License.',
-            ]
-          }
+            ],
+          },
         ];
-        var result = element._splitCommonGroupsWithComments(content, lineNums);
+        const result =
+            element._splitCommonGroupsWithComments(content, lineNums);
         assert.deepEqual(result, [
           {
             ab: ['Copyright (C) 2015 The Android Open Source Project'],
@@ -319,11 +319,11 @@
               'http://www.apache.org/licenses/LICENSE-2.0',
               '',
               'Unless required by applicable law or agreed to in writing, ',
-            ]
+            ],
           },
           {
             ab: [
-                'software distributed under the License is distributed on an '],
+              'software distributed under the License is distributed on an '],
           },
           {
             ab: [
@@ -331,47 +331,50 @@
               'either express or implied. See the License for the specific ',
               'language governing permissions and limitations under the ' +
                   'License.',
-            ]
-          }
+            ],
+          },
         ]);
       });
 
-      test('breaks-down shared chunks w/ whole-file', function() {
-        var lineNums = {left: 0, right: 0};
-        var content = [{
-          ab: _.times(75, function() { return '' + Math.random(); }),
+      test('breaks-down shared chunks w/ whole-file', () => {
+        const size = 120 * 2 + 5;
+        const lineNums = {left: 0, right: 0};
+        const content = [{
+          ab: _.times(size, () => { return `${Math.random()}`; }),
         }];
         element.context = -1;
-        var result = element._splitCommonGroupsWithComments(content, lineNums);
+        const result =
+            element._splitCommonGroupsWithComments(content, lineNums);
         assert.equal(result.length, 2);
-        assert.deepEqual(result[0].ab, content[0].ab.slice(0, 5));
-        assert.deepEqual(result[1].ab, content[0].ab.slice(5));
+        assert.deepEqual(result[0].ab, content[0].ab.slice(0, 120));
+        assert.deepEqual(result[1].ab, content[0].ab.slice(120));
       });
 
-      test('does not break-down shared chunks w/ context', function() {
-        var lineNums = {left: 0, right: 0};
-        var content = [{
-          ab: _.times(75, function() { return '' + Math.random(); }),
+      test('does not break-down shared chunks w/ context', () => {
+        const lineNums = {left: 0, right: 0};
+        const content = [{
+          ab: _.times(75, () => { return `${Math.random()}`; }),
         }];
         element.context = 4;
-        var result = element._splitCommonGroupsWithComments(content, lineNums);
+        const result =
+            element._splitCommonGroupsWithComments(content, lineNums);
         assert.deepEqual(result, content);
       });
 
-      test('intraline normalization', function() {
+      test('intraline normalization', () => {
         // The content and highlights are in the format returned by the Gerrit
         // REST API.
-        var content = [
+        let content = [
           '      <section class="summary">',
           '        <gr-linked-text content="' +
               '[[_computeCurrentRevisionMessage(change)]]"></gr-linked-text>',
           '      </section>',
         ];
-        var highlights = [
-          [31, 34], [42, 26]
+        let highlights = [
+          [31, 34], [42, 26],
         ];
 
-        var results = element._normalizeIntralineHighlights(content,
+        let results = element._normalizeIntralineHighlights(content,
             highlights);
         assert.deepEqual(results, [
           {
@@ -391,7 +394,7 @@
             contentIndex: 2,
             startIndex: 0,
             endIndex: 6,
-          }
+          },
         ]);
 
         content = [
@@ -438,18 +441,18 @@
             contentIndex: 5,
             startIndex: 12,
             endIndex: 41,
-          }
+          },
         ]);
       });
 
-      test('scrolling pauses rendering', function() {
-        var contentRow = {
+      test('scrolling pauses rendering', () => {
+        const contentRow = {
           ab: [
             '<!DOCTYPE html>',
             '<meta charset="utf-8">',
-          ]
+          ],
         };
-        var content = _.times(200, _.constant(contentRow));
+        const content = _.times(200, _.constant(contentRow));
         sandbox.stub(element, 'async');
         element._isScrolling = true;
         element.process(content);
@@ -459,17 +462,34 @@
         assert.equal(element.groups.length, 33);
       });
 
-      suite('gr-diff-processor helpers', function() {
-        var rows;
+      test('image diffs', () => {
+        const contentRow = {
+          ab: [
+            '<!DOCTYPE html>',
+            '<meta charset="utf-8">',
+          ],
+        };
+        const content = _.times(200, _.constant(contentRow));
+        sandbox.stub(element, 'async');
+        element.process(content, true);
+        assert.equal(element.groups.length, 1);
 
-        setup(function() {
+        // Image diffs don't process content, just the 'FILE' line.
+        assert.equal(element.groups[0].lines.length, 1);
+      });
+
+
+      suite('gr-diff-processor helpers', () => {
+        let rows;
+
+        setup(() => {
           rows = loremIpsum.split(' ');
         });
 
-        test('_sharedGroupsFromRows WHOLE_FILE', function() {
-          var context = WHOLE_FILE;
-          var lineNumbers = {left: 10, right: 100};
-          var result = element._sharedGroupsFromRows(
+        test('_sharedGroupsFromRows WHOLE_FILE', () => {
+          const context = WHOLE_FILE;
+          const lineNumbers = {left: 10, right: 100};
+          const result = element._sharedGroupsFromRows(
               rows, context, lineNumbers.left, lineNumbers.right, null);
 
           // Results in one, uncollapsed group with all rows.
@@ -487,11 +507,11 @@
               lineNumbers.right + rows.length);
         });
 
-        test('_sharedGroupsFromRows context', function() {
-          var context = 10;
-          var result = element._sharedGroupsFromRows(
+        test('_sharedGroupsFromRows context', () => {
+          const context = 10;
+          const result = element._sharedGroupsFromRows(
               rows, context, 10, 100, null);
-          var expectedCollapseSize = rows.length - 2 * context;
+          const expectedCollapseSize = rows.length - 2 * context;
 
           assert.equal(result.length, 3, 'Results in three groups');
 
@@ -506,11 +526,11 @@
               expectedCollapseSize);
         });
 
-        test('_sharedGroupsFromRows first', function() {
-          var context = 10;
-          var result = element._sharedGroupsFromRows(
+        test('_sharedGroupsFromRows first', () => {
+          const context = 10;
+          const result = element._sharedGroupsFromRows(
               rows, context, 10, 100, 'first');
-          var expectedCollapseSize = rows.length - context;
+          const expectedCollapseSize = rows.length - context;
 
           assert.equal(result.length, 2, 'Results in two groups');
 
@@ -523,11 +543,11 @@
               expectedCollapseSize);
         });
 
-        test('_sharedGroupsFromRows few-rows', function() {
+        test('_sharedGroupsFromRows few-rows', () => {
           // Only ten rows.
           rows = rows.slice(0, 10);
-          var context = 10;
-          var result = element._sharedGroupsFromRows(
+          const context = 10;
+          const result = element._sharedGroupsFromRows(
               rows, context, 10, 100, 'first');
 
           // Results in one uncollapsed group with all rows.
@@ -535,10 +555,10 @@
           assert.equal(result[0].lines.length, rows.length);
         });
 
-        test('_sharedGroupsFromRows no single line collapse', function() {
+        test('_sharedGroupsFromRows no single line collapse', () => {
           rows = rows.slice(0, 7);
-          var context = 3;
-          var result = element._sharedGroupsFromRows(
+          const context = 3;
+          const result = element._sharedGroupsFromRows(
               rows, context, 10, 100);
 
           // Results in one uncollapsed group with all rows.
@@ -546,9 +566,9 @@
           assert.equal(result[0].lines.length, rows.length);
         });
 
-        test('_deltaLinesFromRows', function() {
-          var startLineNum = 10;
-          var result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
+        test('_deltaLinesFromRows', () => {
+          const startLineNum = 10;
+          let result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
               startLineNum);
 
           assert.equal(result.length, rows.length);
@@ -572,54 +592,64 @@
         });
       });
 
-      suite('_breakdown*', function() {
-        test('_breakdownGroup breaks down additions', function() {
+      suite('_breakdown*', () => {
+        test('_breakdownGroup breaks down additions', () => {
           sandbox.spy(element, '_breakdown');
-          var chunk = {b: ['blah', 'blah', 'blah']};
-          var result = element._breakdownGroup(chunk);
+          const chunk = {b: ['blah', 'blah', 'blah']};
+          const result = element._breakdownGroup(chunk);
           assert.deepEqual(result, [chunk]);
           assert.isTrue(element._breakdown.called);
         });
 
-        test('_breakdown common case', function() {
-          var array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+        test('_breakdownGroup keeps due_to_rebase for broken down additions',
+            () => {
+              sandbox.spy(element, '_breakdown');
+              const chunk = {b: ['blah', 'blah', 'blah'], due_to_rebase: true};
+              const result = element._breakdownGroup(chunk);
+              for (const subResult of result) {
+                assert.isTrue(subResult.due_to_rebase);
+              }
+            });
+
+        test('_breakdown common case', () => {
+          const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
               .split(' ');
-          var size = 3;
+          const size = 3;
 
-          var result = element._breakdown(array, size);
+          const result = element._breakdown(array, size);
 
-          result.forEach(function(subResult) {
+          for (const subResult of result) {
             assert.isAtMost(subResult.length, size);
-          });
-          var flattened = result
-              .reduce(function(a, b) { return a.concat(b); }, []);
+          }
+          const flattened = result
+              .reduce((a, b) => { return a.concat(b); }, []);
           assert.deepEqual(flattened, array);
         });
 
-        test('_breakdown smaller than size', function() {
-          var array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
+        test('_breakdown smaller than size', () => {
+          const array = 'Lorem ipsum dolor sit amet, suspendisse inceptos'
               .split(' ');
-          var size = 10;
-          var expected = [array];
+          const size = 10;
+          const expected = [array];
 
-          var result = element._breakdown(array, size);
+          const result = element._breakdown(array, size);
 
           assert.deepEqual(result, expected);
         });
 
-        test('_breakdown empty', function() {
-          var array = [];
-          var size = 10;
-          var expected = [];
+        test('_breakdown empty', () => {
+          const array = [];
+          const size = 10;
+          const expected = [];
 
-          var result = element._breakdown(array, size);
+          const result = element._breakdown(array, size);
 
           assert.deepEqual(result, expected);
         });
       });
     });
 
-    test('detaching cancels', function() {
+    test('detaching cancels', () => {
       element = fixture('basic');
       sandbox.stub(element, 'cancel');
       element.detached();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
index bfddf89..6699979 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.html
@@ -14,12 +14,14 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-diff-selection">
   <template>
-    <style>
+    <style include="shared-styles">
       .contentWrapper ::content .content,
-      .contentWrapper ::content .contextControl {
+      .contentWrapper ::content .contextControl,
+      .contentWrapper ::content .blame {
         -webkit-user-select: none;
         -moz-user-select: none;
         -ms-user-select: none;
@@ -32,7 +34,8 @@
       :host-context(.selected-right:not(.selected-comment)) .contentWrapper ::content .unified .right.lineNum ~ .content .contentText,
       :host-context(.selected-left.selected-comment) .contentWrapper ::content .side-by-side .left + .content .message,
       :host-context(.selected-right.selected-comment) .contentWrapper ::content .side-by-side .right + .content .message :not(.collapsedContent),
-      :host-context(.selected-comment) .contentWrapper ::content .unified .message :not(.collapsedContent){
+      :host-context(.selected-comment) .contentWrapper ::content .unified .message :not(.collapsedContent),
+      :host-context(.selected-blame) .contentWrapper ::content .blame {
         -webkit-user-select: text;
         -moz-user-select: text;
         -ms-user-select: text;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index fa1aeb2..f00557e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -18,19 +18,21 @@
    * Possible CSS classes indicating the state of selection. Dynamically added/
    * removed based on where the user clicks within the diff.
    */
-  var SelectionClass = {
+  const SelectionClass = {
     COMMENT: 'selected-comment',
     LEFT: 'selected-left',
     RIGHT: 'selected-right',
+    BLAME: 'selected-blame',
   };
 
-  var getNewCache = function() { return {left: null, right: null}; };
+  const getNewCache = () => { return {left: null, right: null}; };
 
   Polymer({
     is: 'gr-diff-selection',
 
     properties: {
       diff: Object,
+      /** @type {?Object} */
       _cachedDiffBuilder: Object,
       _linesCache: {
         type: Object,
@@ -43,11 +45,11 @@
     ],
 
     listeners: {
-      'copy': '_handleCopy',
-      'down': '_handleDown',
+      copy: '_handleCopy',
+      down: '_handleDown',
     },
 
-    attached: function() {
+    attached() {
       this.classList.add(SelectionClass.RIGHT);
     },
 
@@ -59,44 +61,60 @@
       return this._cachedDiffBuilder;
     },
 
-    _diffChanged: function() {
+    _diffChanged() {
       this._linesCache = getNewCache();
     },
 
-    _handleDown: function(e) {
-      var lineEl = this.diffBuilder.getLineElByChild(e.target);
-      if (!lineEl) {
-        return;
-      }
-      var commentSelected =
-          this._elementDescendedFromClass(e.target, 'gr-diff-comment');
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var targetClasses = [];
-      targetClasses.push(side === 'left' ?
-          SelectionClass.LEFT :
-          SelectionClass.RIGHT);
+    _handleDown(e) {
+      const lineEl = this.diffBuilder.getLineElByChild(e.target);
+      const blameSelected = this._elementDescendedFromClass(e.target, 'blame');
+      if (!lineEl && !blameSelected) { return; }
 
-      if (commentSelected) {
-        targetClasses.push(SelectionClass.COMMENT);
+      const targetClasses = [];
+
+      if (blameSelected) {
+        targetClasses.push(SelectionClass.BLAME);
+      } else {
+        const commentSelected =
+            this._elementDescendedFromClass(e.target, 'gr-diff-comment');
+        const side = this.diffBuilder.getSideByLineEl(lineEl);
+
+        targetClasses.push(side === 'left' ?
+            SelectionClass.LEFT :
+            SelectionClass.RIGHT);
+
+        if (commentSelected) {
+          targetClasses.push(SelectionClass.COMMENT);
+        }
       }
+
+      this._setClasses(targetClasses);
+    },
+
+    /**
+     * Set the provided list of classes on the element, to the exclusion of all
+     * other SelectionClass values.
+     * @param {!Array<!string>} targetClasses
+     */
+    _setClasses(targetClasses) {
       // Remove any selection classes that do not belong.
-      for (var key in SelectionClass) {
+      for (const key in SelectionClass) {
         if (SelectionClass.hasOwnProperty(key)) {
-          var className = SelectionClass[key];
-          if (targetClasses.indexOf(className) === -1) {
+          const className = SelectionClass[key];
+          if (!targetClasses.includes(className)) {
             this.classList.remove(SelectionClass[key]);
           }
         }
       }
       // Add new selection classes iff they are not already present.
-      for (var i = 0; i < targetClasses.length; i++) {
-        if (!this.classList.contains(targetClasses[i])) {
-          this.classList.add(targetClasses[i]);
+      for (const _class of targetClasses) {
+        if (!this.classList.contains(_class)) {
+          this.classList.add(_class);
         }
       }
     },
 
-    _getCopyEventTarget: function(e) {
+    _getCopyEventTarget(e) {
       return Polymer.dom(e).rootTarget;
     },
 
@@ -108,7 +126,7 @@
      * @param {!string} className
      * @return {boolean}
      */
-    _elementDescendedFromClass: function(element, className) {
+    _elementDescendedFromClass(element, className) {
       while (!element.classList.contains(className)) {
         if (!element.parentElement ||
             element === this.diffBuilder.diffElement) {
@@ -119,20 +137,20 @@
       return true;
     },
 
-    _handleCopy: function(e) {
-      var commentSelected = false;
-      var target = this._getCopyEventTarget(e);
+    _handleCopy(e) {
+      let commentSelected = false;
+      const target = this._getCopyEventTarget(e);
       if (target.type === 'textarea') { return; }
       if (!this._elementDescendedFromClass(target, 'diff-row')) { return; }
       if (this.classList.contains(SelectionClass.COMMENT)) {
         commentSelected = true;
       }
-      var lineEl = this.diffBuilder.getLineElByChild(target);
+      const lineEl = this.diffBuilder.getLineElByChild(target);
       if (!lineEl) {
         return;
       }
-      var side = this.diffBuilder.getSideByLineEl(lineEl);
-      var text = this._getSelectedText(side, commentSelected);
+      const side = this.diffBuilder.getSideByLineEl(lineEl);
+      const text = this._getSelectedText(side, commentSelected);
       if (text) {
         e.clipboardData.setData('Text', text);
         e.preventDefault();
@@ -144,23 +162,24 @@
      * true, it returns only the text of comments within the selection.
      * Otherwise it returns the text of the selected diff region.
      *
-     * @param {!string} The side that is selected.
-     * @param {boolean} Whether or not a comment is selected.
+     * @param {!string} side The side that is selected.
+     * @param {boolean} commentSelected Whether or not a comment is selected.
      * @return {string} The selected text.
      */
-    _getSelectedText: function(side, commentSelected) {
-      var sel = window.getSelection();
+    _getSelectedText(side, commentSelected) {
+      const sel = window.getSelection();
       if (sel.rangeCount != 1) {
-        return; // No multi-select support yet.
+        return ''; // No multi-select support yet.
       }
       if (commentSelected) {
         return this._getCommentLines(sel, side);
       }
-      var range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
-      var startLineEl = this.diffBuilder.getLineElByChild(range.startContainer);
-      var endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
-      var startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
-      var endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
+      const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+      const startLineEl =
+          this.diffBuilder.getLineElByChild(range.startContainer);
+      const endLineEl = this.diffBuilder.getLineElByChild(range.endContainer);
+      const startLineNum = parseInt(startLineEl.getAttribute('data-value'), 10);
+      const endLineNum = parseInt(endLineEl.getAttribute('data-value'), 10);
 
       return this._getRangeFromDiff(startLineNum, range.startOffset, endLineNum,
           range.endOffset, side);
@@ -169,16 +188,16 @@
     /**
      * Query the diff object for the selected lines.
      *
-     * @param {int} startLineNum
-     * @param {int} startOffset
-     * @param {int} endLineNum
-     * @param {int} endOffset
+     * @param {number} startLineNum
+     * @param {number} startOffset
+     * @param {number} endLineNum
+     * @param {number} endOffset
      * @param {!string} side The side that is currently selected.
      * @return {string} The selected diff text.
      */
-    _getRangeFromDiff: function(startLineNum, startOffset, endLineNum,
-        endOffset, side) {
-      var lines = this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
+    _getRangeFromDiff(startLineNum, startOffset, endLineNum, endOffset, side) {
+      const lines =
+          this._getDiffLines(side).slice(startLineNum - 1, endLineNum);
       if (lines.length) {
         lines[lines.length - 1] = lines[lines.length - 1]
             .substring(0, endOffset);
@@ -191,19 +210,15 @@
      * Query the diff object for the lines from a particular side.
      *
      * @param {!string} side The side that is currently selected.
-     * @return {Array.string} An array of strings indexed by line number.
+     * @return {!Array<string>} An array of strings indexed by line number.
      */
-    _getDiffLines: function(side) {
+    _getDiffLines(side) {
       if (this._linesCache[side]) {
         return this._linesCache[side];
       }
-      var lines = [];
-      var chunk;
-      var key = side === 'left' ? 'a' : 'b';
-      for (var chunkIndex = 0;
-          chunkIndex < this.diff.content.length;
-          chunkIndex++) {
-        chunk = this.diff.content[chunkIndex];
+      let lines = [];
+      const key = side === 'left' ? 'a' : 'b';
+      for (const chunk of this.diff.content) {
         if (chunk.ab) {
           lines = lines.concat(chunk.ab);
         } else if (chunk[key]) {
@@ -222,16 +237,16 @@
      * @param {!string} side The side that is currently selected.
      * @return {string} The selected comment text.
      */
-    _getCommentLines: function(sel, side) {
-      var range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
-      var content = [];
+    _getCommentLines(sel, side) {
+      const range = GrRangeNormalizer.normalize(sel.getRangeAt(0));
+      const content = [];
       // Query the diffElement for comments.
-      var messages = this.diffBuilder.diffElement.querySelectorAll(
-          '.side-by-side [data-side="' + side +
-          '"] .message *, .unified .message *');
+      const messages = this.diffBuilder.diffElement.querySelectorAll(
+          `.side-by-side [data-side="${side
+          }"] .message *, .unified .message *`);
 
-      for (var i = 0; i < messages.length; i++) {
-        var el = messages[i];
+      for (let i = 0; i < messages.length; i++) {
+        const el = messages[i];
         // Check if the comment element exists inside the selection.
         if (sel.containsNode(el, true)) {
           // Padded elements require newlines for accurate spacing.
@@ -257,15 +272,15 @@
      * of the text content within that selection.
      * Using a domNode that isn't in the selection returns an empty string.
      *
-     * @param {Element} domNode The root DOM node.
-     * @param {Selection} sel The selection.
-     * @param {Range} range The normalized selection range.
+     * @param {!Node} domNode The root DOM node.
+     * @param {!Selection} sel The selection.
+     * @param {!Range} range The normalized selection range.
      * @return {string} The text within the selection.
      */
-    _getTextContentForRange: function(domNode, sel, range) {
+    _getTextContentForRange(domNode, sel, range) {
       if (!sel.containsNode(domNode, true)) { return ''; }
 
-      var text = '';
+      let text = '';
       if (domNode instanceof Text) {
         text = domNode.textContent;
         if (domNode === range.endContainer) {
@@ -275,9 +290,8 @@
           text = text.substring(range.startOffset);
         }
       } else {
-        for (var i = 0; i < domNode.childNodes.length; i++) {
-          text += this._getTextContentForRange(domNode.childNodes[i],
-              sel, range);
+        for (const childNode of domNode.childNodes) {
+          text += this._getTextContentForRange(childNode, sel, range);
         }
       }
       return text;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index 3eeba90..a14d155 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-diff-selection.html">
 
 <script>void(0);</script>
@@ -31,6 +30,7 @@
     <gr-diff-selection>
       <table id="diffTable" class="side-by-side">
         <tr class="diff-row">
+          <td class="blame" data-line-number="1"></td>
           <td class="lineNum left" data-value="1">1</td>
           <td class="content">
             <div class="contentText" data-side="left">ba ba</div>
@@ -48,6 +48,7 @@
           </td>
         </tr>
         <tr class="diff-row">
+          <td class="blame" data-line-number="2"></td>
           <td class="lineNum left" data-value="2">2</td>
           <td class="content">
             <div class="contentText" data-side="left">zin</div>
@@ -65,6 +66,7 @@
           </td>
         </tr>
         <tr class="diff-row">
+          <td class="blame" data-line-number="3"></td>
           <td class="lineNum left" data-value="3">3</td>
           <td class="content">
             <div class="contentText" data-side="left">ga ga</div>
@@ -79,6 +81,7 @@
           <td class="lineNum right" data-value="3">3</td>
         </tr>
         <tr class="diff-row">
+          <td class="blame" data-line-number="4"></td>
           <td class="lineNum left" data-value="4">4</td>
           <td class="content">
             <div class="contentText" data-side="left">ga ga</div>
@@ -101,13 +104,13 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-selection', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-selection', () => {
+    let element;
+    let sandbox;
 
-    var emulateCopyOn = function(target) {
-      var fakeEvent = {
-        target: target,
+    const emulateCopyOn = function(target) {
+      const fakeEvent = {
+        target,
         preventDefault: sandbox.stub(),
         clipboardData: {
           setData: sandbox.stub(),
@@ -118,7 +121,7 @@
       return fakeEvent;
     };
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
       sandbox.stub(element, '_getCopyEventTarget');
@@ -145,11 +148,11 @@
       };
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('applies selected-left on left side click', function() {
+    test('applies selected-left on left side click', () => {
       element.classList.add('selected-right');
       element._cachedDiffBuilder.getSideByLineEl.returns('left');
       MockInteractions.down(element);
@@ -160,7 +163,7 @@
           'removes selected-right');
     });
 
-    test('applies selected-right on right side click', function() {
+    test('applies selected-right on right side click', () => {
       element.classList.add('selected-left');
       element._cachedDiffBuilder.getSideByLineEl.returns('right');
       MockInteractions.down(element);
@@ -170,41 +173,79 @@
           element.classList.contains('selected-left'), 'removes selected-left');
     });
 
-    test('ignores copy for non-content Element', function() {
+    test('applies selected-blame on blame click', () => {
+      element.classList.add('selected-left');
+      element.diffBuilder.getLineElByChild.returns(null);
+      sandbox.stub(element, '_elementDescendedFromClass',
+          (el, className) => className === 'blame');
+      MockInteractions.down(element);
+      assert.isTrue(
+          element.classList.contains('selected-blame'), 'adds selected-right');
+      assert.isFalse(
+          element.classList.contains('selected-left'), 'removes selected-left');
+    });
+
+    test('ignores copy for non-content Element', () => {
       sandbox.stub(element, '_getSelectedText');
       emulateCopyOn(element.querySelector('.not-diff-row'));
       assert.isFalse(element._getSelectedText.called);
     });
 
-    test('asks for text for left side Elements', function() {
+    test('asks for text for left side Elements', () => {
       element._cachedDiffBuilder.getSideByLineEl.returns('left');
       sandbox.stub(element, '_getSelectedText');
       emulateCopyOn(element.querySelector('div.contentText'));
       assert.deepEqual(['left', false], element._getSelectedText.lastCall.args);
     });
 
-    test('reacts to copy for content Elements', function() {
+    test('reacts to copy for content Elements', () => {
       sandbox.stub(element, '_getSelectedText');
       emulateCopyOn(element.querySelector('div.contentText'));
       assert.isTrue(element._getSelectedText.called);
     });
 
-    test('copy event is prevented for content Elements', function() {
+    test('copy event is prevented for content Elements', () => {
       sandbox.stub(element, '_getSelectedText');
       element._cachedDiffBuilder.getSideByLineEl.returns('left');
       element._getSelectedText.returns('test');
-      var event = emulateCopyOn(element.querySelector('div.contentText'));
+      const event = emulateCopyOn(element.querySelector('div.contentText'));
       assert.isTrue(event.preventDefault.called);
     });
 
-    test('inserts text into clipboard on copy', function() {
+    test('inserts text into clipboard on copy', () => {
       sandbox.stub(element, '_getSelectedText').returns('the text');
-      var event = emulateCopyOn(element.querySelector('div.contentText'));
+      const event = emulateCopyOn(element.querySelector('div.contentText'));
       assert.deepEqual(
           ['Text', 'the text'], event.clipboardData.setData.lastCall.args);
     });
 
-    test('copies content correctly', function() {
+    test('_setClasses adds given SelectionClass values, removes others', () => {
+      element.classList.add('selected-right');
+      element._setClasses(['selected-comment', 'selected-left']);
+      assert.isTrue(element.classList.contains('selected-comment'));
+      assert.isTrue(element.classList.contains('selected-left'));
+      assert.isFalse(element.classList.contains('selected-right'));
+      assert.isFalse(element.classList.contains('selected-blame'));
+
+      element._setClasses(['selected-blame']);
+      assert.isFalse(element.classList.contains('selected-comment'));
+      assert.isFalse(element.classList.contains('selected-left'));
+      assert.isFalse(element.classList.contains('selected-right'));
+      assert.isTrue(element.classList.contains('selected-blame'));
+    });
+
+    test('_setClasses removes before it ads', () => {
+      element.classList.add('selected-right');
+      const addStub = sandbox.stub(element.classList, 'add');
+      const removeStub = sandbox.stub(element.classList, 'remove', () => {
+        assert.isFalse(addStub.called);
+      });
+      element._setClasses(['selected-comment', 'selected-left']);
+      assert.isTrue(addStub.called);
+      assert.isTrue(removeStub.called);
+    });
+
+    test('copies content correctly', () => {
       // Fetch the line number.
       element._cachedDiffBuilder.getLineElByChild = function(child) {
         while (!child.classList.contains('content') && child.parentElement) {
@@ -216,9 +257,9 @@
       element.classList.add('selected-left');
       element.classList.remove('selected-right');
 
-      var selection = window.getSelection();
+      const selection = window.getSelection();
       selection.removeAllRanges();
-      var range = document.createRange();
+      const range = document.createRange();
       range.setStart(element.querySelector('div.contentText').firstChild, 3);
       range.setEnd(
           element.querySelectorAll('div.contentText')[4].firstChild, 2);
@@ -226,13 +267,13 @@
       assert.equal(element._getSelectedText('left'), 'ba\nzin\nga');
     });
 
-    test('copies comments', function() {
+    test('copies comments', () => {
       element.classList.add('selected-left');
       element.classList.add('selected-comment');
       element.classList.remove('selected-right');
-      var selection = window.getSelection();
+      const selection = window.getSelection();
       selection.removeAllRanges();
-      var range = document.createRange();
+      const range = document.createRange();
       range.setStart(
           element.querySelector('.gr-formatted-text *').firstChild, 3);
       range.setEnd(
@@ -242,14 +283,14 @@
           element._getSelectedText('left', true));
     });
 
-    test('respects astral chars in comments', function() {
+    test('respects astral chars in comments', () => {
       element.classList.add('selected-left');
       element.classList.add('selected-comment');
       element.classList.remove('selected-right');
-      var selection = window.getSelection();
+      const selection = window.getSelection();
       selection.removeAllRanges();
-      var range = document.createRange();
-      var nodes = element.querySelectorAll('.gr-formatted-text *');
+      const range = document.createRange();
+      const nodes = element.querySelectorAll('.gr-formatted-text *');
       range.setStart(nodes[2].childNodes[2], 13);
       range.setEnd(nodes[2].childNodes[2], 23);
       selection.addRange(range);
@@ -257,15 +298,15 @@
           element._getSelectedText('left', true));
     });
 
-    test('defers to default behavior for textarea', function() {
+    test('defers to default behavior for textarea', () => {
       element.classList.add('selected-left');
       element.classList.remove('selected-right');
-      var selectedTextSpy = sandbox.spy(element, '_getSelectedText');
+      const selectedTextSpy = sandbox.spy(element, '_getSelectedText');
       emulateCopyOn(element.querySelector('textarea'));
       assert.isFalse(selectedTextSpy.called);
     });
 
-    test('regression test for 4794', function() {
+    test('regression test for 4794', () => {
       element._cachedDiffBuilder.getLineElByChild = function(child) {
         while (!child.classList.contains('content') && child.parentElement) {
           child = child.parentElement;
@@ -276,9 +317,9 @@
       element.classList.add('selected-right');
       element.classList.remove('selected-left');
 
-      var selection = window.getSelection();
+      const selection = window.getSelection();
       selection.removeAllRanges();
-      var range = document.createRange();
+      const range = document.createRange();
       range.setStart(
           element.querySelectorAll('div.contentText')[1].firstChild, 4);
       range.setEnd(
@@ -287,12 +328,12 @@
       assert.equal(element._getSelectedText('right'), ' other');
     });
 
-    suite('_getTextContentForRange', function() {
-      var selection;
-      var range;
-      var nodes;
+    suite('_getTextContentForRange', () => {
+      let selection;
+      let range;
+      let nodes;
 
-      setup(function() {
+      setup(() => {
         element.classList.add('selected-left');
         element.classList.add('selected-comment');
         element.classList.remove('selected-right');
@@ -302,7 +343,7 @@
         nodes = element.querySelectorAll('.gr-formatted-text *');
       });
 
-      test('multi level element contained in range', function() {
+      test('multi level element contained in range', () => {
         range.setStart(nodes[2].childNodes[0], 1);
         range.setEnd(nodes[2].childNodes[2], 7);
         selection.addRange(range);
@@ -311,7 +352,7 @@
       });
 
 
-      test('multi level element as startContainer of range', function() {
+      test('multi level element as startContainer of range', () => {
         range.setStart(nodes[2].childNodes[1], 0);
         range.setEnd(nodes[2].childNodes[2], 7);
         selection.addRange(range);
@@ -319,7 +360,7 @@
             'a differ');
       });
 
-      test('startContainer === endContainer', function() {
+      test('startContainer === endContainer', () => {
         range.setStart(nodes[0].firstChild, 2);
         range.setEnd(nodes[0].firstChild, 12);
         selection.addRange(range);
@@ -328,7 +369,7 @@
       });
     });
 
-    test('cache is reset when diff changes', function() {
+    test('cache is reset when diff changes', () => {
       element._linesCache = {left: 'test', right: 'test'};
       element.diff = {};
       flushAsynchronousOperations();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 40f9812..a080396 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -14,28 +14,41 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
-<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
 <link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../shared/gr-fixed-panel/gr-fixed-panel.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
-<link rel="import" href="../gr-diff/gr-diff.html">
+<link rel="import" href="../gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../gr-diff-preferences/gr-diff-preferences.html">
+<link rel="import" href="../gr-diff/gr-diff.html">
 <link rel="import" href="../gr-patch-range-select/gr-patch-range-select.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-diff-view">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         background-color: var(--view-background-color);
+      }
+      gr-patch-range-select {
         display: block;
       }
+      gr-diff {
+        border: none;
+      }
+      gr-fixed-panel {
+        background-color: #fff;
+        border-bottom: 1px #eee solid;
+        z-index: 1;
+      }
       header,
       .subHeader {
         align-items: center;
@@ -46,6 +59,7 @@
         padding: .75em var(--default-horizontal-margin);
       }
       .patchRangeLeft {
+        align-items: center;
         display: flex;
       }
       .navLink:not([href]),
@@ -66,13 +80,7 @@
       .mobile {
         display: none;
       }
-      .downArrow {
-        display: inline-block;
-        font-size: .6em;
-        vertical-align: middle;
-      }
       .dropdown-trigger {
-        color: #00e;
         cursor: pointer;
         padding: 0;
       }
@@ -82,6 +90,7 @@
       .dropdown-content {
         background-color: #fff;
         box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+        max-height: 70vh;
       }
       .dropdown-content a {
         cursor: pointer;
@@ -97,12 +106,12 @@
         width: .3em;
       }
       .dropdown-content a:hover {
-        background-color: #00e;
+        background-color: var(--color-link);
         color: #fff;
       }
       .dropdown-content a[selected] {
         color: #000;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         pointer-events: none;
         text-decoration: none;
       }
@@ -111,13 +120,15 @@
         color: #000;
       }
       gr-button {
-        font: inherit;
         padding: .3em 0;
         text-decoration: none;
       }
       .loading {
-        padding: 0 var(--default-horizontal-margin) 1em;
-        color: #666;
+        color: #777;
+        font-size: 2em;
+        height: 100%;
+        padding: 1em var(--default-horizontal-margin);
+        text-align: center;
       }
       .subHeader {
         flex-wrap: wrap;
@@ -132,6 +143,27 @@
       .separator {
         margin: 0 .25em;
       }
+      .noOverflow {
+        display: block;
+        overflow: auto;
+      }
+      #trigger {
+        --gr-button: {
+          -moz-user-select: text;
+          -ms-user-select: text;
+          -webkit-user-select: text;
+          user-select: text;
+        }
+      }
+      .editLoaded .hideOnEdit {
+        display: none;
+      }
+      .blameLoader {
+        display: none;
+      }
+      .blameLoader.show {
+        display: inline;
+      }
       @media screen and (max-width: 50em) {
         header {
           padding: .5em var(--default-horizontal-margin);
@@ -170,7 +202,7 @@
         .mobileNavLink {
           color: #000;
           font-size: 1.5em;
-          font-weight: bold;
+          font-family: var(--font-family-bold);
           text-decoration: none;
         }
         .mobileNavLink:not([href]) {
@@ -178,94 +210,111 @@
         }
       }
     </style>
-    <header>
-      <h3>
-        <a href$="[[_computeChangePath(_changeNum, _patchRange.*, _change.revisions)]]">
-          [[_changeNum]]</a><span>:</span>
-        <span>[[_change.subject]]</span>
-        <span class="dash">—</span>
-        <input id="reviewed"
-               class="reviewed"
-               type="checkbox"
-               on-change="_handleReviewedChange"
-               hidden$="[[!_loggedIn]]" hidden>
-        <div class="jumpToFileContainer desktop">
-          <gr-button link class="dropdown-trigger" id="trigger" on-tap="_showDropdownTapHandler">
-            <span>[[_computeFileDisplayName(_path)]]</span>
-            <span class="downArrow">&#9660;</span>
-          </gr-button>
-          <!-- *-align="" to disable iron-dropdown's element positioning. -->
-          <iron-dropdown id="dropdown"
-              allow-outside-scroll
-              vertical-align=""
-              horizontal-align="">
-            <div class="dropdown-content">
-              <template
-                  is="dom-repeat"
-                  items="[[_fileList]]"
-                  as="path"
-                  initial-count="75">
-                <a href$="[[_computeDiffURL(_changeNum, _patchRange.*, path)]]"
-                   selected$="[[_computeFileSelected(path, _path)]]"
-                   data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
-                   on-tap="_handleFileTap">[[_computeFileDisplayName(path)]]</a>
+    <gr-fixed-panel
+        class$="[[_computeContainerClass(_editLoaded)]]"
+        floating-disabled="[[_panelFloatingDisabled]]"
+        keep-on-scroll
+        ready-for-measure="[[!_loading]]">
+      <header>
+        <h3>
+          <a href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]">
+            [[_changeNum]]</a><span>:</span>
+          <span>[[_change.subject]]</span>
+          <span class="dash">—</span>
+          <input id="reviewed"
+              class="reviewed hideOnEdit"
+              type="checkbox"
+              on-change="_handleReviewedChange"
+              hidden$="[[!_loggedIn]]" hidden>
+          <div class="jumpToFileContainer desktop">
+            <gr-button
+                down-arrow
+                no-uppercase
+                link
+                class="dropdown-trigger"
+                id="trigger"
+                on-tap="_showDropdownTapHandler">
+              <span>[[computeDisplayPath(_path)]]</span>
+            </gr-button>
+            <!-- *-align="" to disable iron-dropdown's element positioning. -->
+            <iron-dropdown id="dropdown"
+                allow-outside-scroll
+                vertical-align=""
+                horizontal-align="">
+              <div class="dropdown-content" slot="dropdown-content">
+                <template
+                    is="dom-repeat"
+                    items="[[_fileList]]"
+                    as="path"
+                    initial-count="75">
+                  <a href$="[[_computeDiffURL(_change, _patchRange.*, path)]]"
+                    selected$="[[_computeFileSelected(path, _path)]]"
+                    data-key-nav$="[[_computeKeyNav(path, _path, _fileList)]]"
+                    on-tap="_handleFileTap">[[computeDisplayPath(path)]]</a>
+                </template>
+              </div>
+            </iron-dropdown>
+          </div>
+          <div class="mobileJumpToFileContainer mobile">
+            <select on-change="_handleMobileSelectChange">
+              <template is="dom-repeat" items="[[_fileList]]" as="path">
+                <option
+                    value$="[[path]]"
+                    selected$="[[_computeFileSelected(path, _path)]]">
+                  [[computeTruncatedPath(path)]]
+                </option>
               </template>
-            </div>
-          </iron-dropdown>
+            </select>
+          </div>
+        </h3>
+        <div class="navLinks desktop">
+          <a class="navLink"
+              href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]">
+            Prev</a>
+          /
+          <a class="navLink"
+              href$="[[_computeChangePath(_change, _patchRange.*, _change.revisions)]]">
+            Up</a>
+          /
+          <a class="navLink"
+              href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]">
+            Next</a>
         </div>
-        <div class="mobileJumpToFileContainer mobile">
-          <select on-change="_handleMobileSelectChange">
-            <template is="dom-repeat" items="[[_fileList]]" as="path">
-              <option
-                  value$="[[path]]"
-                  selected$="[[_computeFileSelected(path, _path)]]">
-                [[_computeTruncatedFileDisplayName(path)]]
-              </option>
-            </template>
-          </select>
-        </div>
-      </h3>
-      <div class="navLinks desktop">
-        <a class="navLink"
-           href$="[[_computeNavLinkURL(_path, _fileList, -1, 1)]]">Prev</a>
-        /
-        <a class="navLink"
-           href$="[[_computeUpURL(_changeNum, _patchRange, _change, _change.revisions)]]">Up</a>
-        /
-        <a class="navLink"
-           href$="[[_computeNavLinkURL(_path, _fileList, 1, 1)]]">Next</a>
-      </div>
-    </header>
-    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
-    <div hidden$="[[_loading]]" hidden>
+      </header>
       <div class="subHeader">
         <div class="patchRangeLeft">
           <gr-patch-range-select
-              path="[[_path]]"
+              id="rangeSelect"
               change-num="[[_changeNum]]"
-              patch-range="[[_patchRange]]"
+              patch-num="[[_patchRange.patchNum]]"
+              base-patch-num="[[_patchRange.basePatchNum]]"
               files-weblinks="[[_filesWeblinks]]"
-              available-patches="[[_computeAvailablePatches(_change.revisions)]]"
-              revisions="[[_change.revisions]]">
+              available-patches="[[_allPatchSets]]"
+              revisions="[[_change.revisions]]"
+              on-patch-range-change="_handlePatchChange">
           </gr-patch-range-select>
           <span class="download desktop">
             <span class="separator">/</span>
-            <a class="downloadLink"
-               href$="[[_computeDownloadLink(_changeNum, _patchRange, _path)]]">
+            <a
+              class="downloadLink"
+              download
+              href$="[[_computeDownloadLink(_changeNum, _patchRange, _path)]]">
               Download
             </a>
           </span>
         </div>
         <div>
-          <select
+          <gr-select
               id="modeSelect"
-              is="gr-select"
               bind-value="{{changeViewState.diffMode}}"
               hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">
-            <option value="SIDE_BY_SIDE">Side By Side</option>
-            <option value="UNIFIED_DIFF">Unified</option>
-          </select>
-          <span hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]">
+            <select>
+              <option value="SIDE_BY_SIDE">Side By Side</option>
+              <option value="UNIFIED_DIFF">Unified</option>
+            </select>
+          </gr-select>
+          <span id="diffPrefsContainer"
+              hidden$="[[_computePrefsButtonHidden(_prefs, _loggedIn)]]" hidden>
             <span class="preferences desktop">
               <span
                   hidden$="[[_computeModeSelectHidden(_isImageDiff)]]">/</span>
@@ -274,42 +323,52 @@
                   on-tap="_handlePrefsTap">Preferences</gr-button>
             </span>
           </span>
+          <span class$="blameLoader [[_computeBlameLoaderClass(_isImageDiff, _isBlameSupported)]]">
+            <span class="separator">/</span>
+            <gr-button
+                link
+                disabled="[[_isBlameLoading]]"
+                on-tap="_toggleBlame">[[_computeBlameToggleLabel(_isBlameLoaded, _isBlameLoading)]]</gr-button>
+          </span>
         </div>
       </div>
-      <gr-overlay id="prefsOverlay" with-backdrop>
-        <gr-diff-preferences
-            id="diffPreferences"
-            prefs="{{_prefs}}"
-            local-prefs="{{_localPrefs}}"
-            on-save="_handlePrefsSave"
-            on-cancel="_handlePrefsCancel"></gr-diff-preferences>
-      </gr-overlay>
       <div class="fileNav mobile">
         <a class="mobileNavLink"
-           href$="[[_computeNavLinkURL(_path, _fileList, -1, 1)]]">&lt;</a>
-        <div class="fullFileName mobile">[[_computeFileDisplayName(_path)]]
+          href$="[[_computeNavLinkURL(_change, _path, _fileList, -1, 1)]]">
+          &lt;</a>
+        <div class="fullFileName mobile">[[computeDisplayPath(_path)]]
         </div>
         <a class="mobileNavLink"
-            href$="[[_computeNavLinkURL(_path, _fileList, 1, 1)]]">&gt;</a>
+            href$="[[_computeNavLinkURL(_change, _path, _fileList, 1, 1)]]">
+          &gt;</a>
       </div>
-      <gr-diff
-          id="diff"
-          project="[[_change.project]]"
-          commit="[[_change.current_revision]]"
-          is-image-diff="{{_isImageDiff}}"
-          files-weblinks="{{_filesWeblinks}}"
-          change-num="[[_changeNum]]"
-          patch-range="[[_patchRange]]"
-          path="[[_path]]"
-          prefs="[[_prefs]]"
-          project-config="[[_projectConfig]]"
-          view-mode="[[_diffMode]]"
-          on-line-selected="_onLineSelected">
-      </gr-diff>
-    </div>
+    </gr-fixed-panel>
+    <div class="loading" hidden$="[[!_loading]]">Loading...</div>
+    <gr-diff
+        id="diff"
+        hidden
+        hidden$="[[_loading]]"
+        class$="[[_computeDiffClass(_panelFloatingDisabled)]]"
+        is-image-diff="{{_isImageDiff}}"
+        files-weblinks="{{_filesWeblinks}}"
+        change-num="[[_changeNum]]"
+        patch-range="[[_patchRange]]"
+        path="[[_path]]"
+        prefs="[[_prefs]]"
+        project-config="[[_projectConfig]]"
+        project-name="[[_change.project]]"
+        view-mode="[[_diffMode]]"
+        is-blame-loaded="{{_isBlameLoaded}}"
+        on-line-selected="_onLineSelected">
+    </gr-diff>
+    <gr-diff-preferences
+        id="diffPreferences"
+        prefs="{{_prefs}}"
+        local-prefs="{{_localPrefs}}"></gr-diff-preferences>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor id="cursor"></gr-diff-cursor>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
   <script src="gr-diff-view.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index d1ac842..b97d974 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -14,18 +14,17 @@
 (function() {
   'use strict';
 
-  var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
-  var MERGE_LIST_PATH = '/MERGE_LIST';
+  const ERR_REVIEW_STATUS = 'Couldn’t change file review status.';
+  const MSG_LOADING_BLAME = 'Loading blame...';
+  const MSG_LOADED_BLAME = 'Blame loaded';
 
-  var COMMENT_SAVE = 'Try again when all comments have saved.';
+  const PARENT = 'PARENT';
 
-  var DiffSides = {
+  const DiffSides = {
     LEFT: 'left',
     RIGHT: 'right',
   };
 
-  var HASH_PATTERN = /^[ab]?\d+$/;
-
   Polymer({
     is: 'gr-diff-view',
 
@@ -51,21 +50,32 @@
       },
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
+      /**
+       * @type {{ diffMode: (string|undefined) }}
+       */
       changeViewState: {
         type: Object,
         notify: true,
-        value: function() { return {}; },
+        value() { return {}; },
+        observer: '_changeViewStateChanged',
       },
-
+      /** @type {?} */
       _patchRange: Object,
+      /**
+       * @type {{
+       *  subject: string,
+       *  project: string,
+       *  revisions: string,
+       * }}
+       */
       _change: Object,
       _changeNum: String,
       _diff: Object,
       _fileList: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _path: {
         type: String,
@@ -96,6 +106,8 @@
        */
       _commentMap: Object,
 
+      _commentsForDiff: Object,
+
       /**
        * Object to contain the path of the next and previous file in the current
        * change and patch range that has comments.
@@ -104,18 +116,40 @@
         type: Object,
         computed: '_computeCommentSkips(_commentMap, _fileList, _path)',
       },
+      _panelFloatingDisabled: {
+        type: Boolean,
+        value: () => { return window.PANEL_FLOATING_DISABLED; },
+      },
+      _editLoaded: {
+        type: Boolean,
+        computed: '_computeEditLoaded(_patchRange.*)',
+      },
+      _isBlameSupported: {
+        type: Boolean,
+        value: false,
+      },
+      _isBlameLoaded: Boolean,
+      _isBlameLoading: {
+        type: Boolean,
+        value: false,
+      },
+      _allPatchSets: {
+        type: Array,
+        computed: 'computeAllPatchSets(_change, _change.revisions.*)',
+      },
     },
 
     behaviors: [
-      Gerrit.BaseUrlBehavior,
       Gerrit.KeyboardShortcutBehavior,
+      Gerrit.PatchSetBehavior,
+      Gerrit.PathListBehavior,
       Gerrit.RESTClientBehavior,
-      Gerrit.URLEncodingBehavior,
     ],
 
     observers: [
       '_getProjectConfig(_change.project)',
       '_getFiles(_changeNum, _patchRange.*)',
+      '_setReviewedObserver(_loggedIn, params.*)',
     ],
 
     keyBindings: {
@@ -134,85 +168,78 @@
       ',': '_handleCommaKey',
     },
 
-    attached: function() {
-      this._getLoggedIn().then(function(loggedIn) {
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
-        if (loggedIn) {
-          this._setReviewed(true);
-        }
-      }.bind(this));
-      if (this.changeViewState.diffMode === null) {
-        // If screen size is small, always default to unified view.
-        this.$.restAPI.getPreferences().then(function(prefs) {
-          this.set('changeViewState.diffMode', prefs.default_diff_view);
-        }.bind(this));
-      }
+      });
 
-      if (this._path) {
-        this.fire('title-change',
-            {title: this._computeFileDisplayName(this._path)});
-      }
+      this.$.restAPI.getConfig().then(config => {
+        this._isBlameSupported = config.change.allow_blame;
+      });
 
       this.$.cursor.push('diffs', this.$.diff);
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _getProjectConfig: function(project) {
+    _getProjectConfig(project) {
       return this.$.restAPI.getProjectConfig(project).then(
-          function(config) {
+          config => {
             this._projectConfig = config;
-          }.bind(this));
+          });
     },
 
-    _getChangeDetail: function(changeNum) {
-      return this.$.restAPI.getDiffChangeDetail(changeNum).then(
-          function(change) {
-            this._change = change;
-          }.bind(this));
+    _getChangeDetail(changeNum) {
+      return this.$.restAPI.getDiffChangeDetail(changeNum).then(change => {
+        this._change = change;
+      });
     },
 
-    _getFiles: function(changeNum, patchRangeRecord) {
-      var patchRange = patchRangeRecord.base;
+    _getChangeEdit(changeNum) {
+      return this.$.restAPI.getChangeEdit(this._changeNum);
+    },
+
+    _getFiles(changeNum, patchRangeRecord) {
+      const patchRange = patchRangeRecord.base;
       return this.$.restAPI.getChangeFilePathsAsSpeciallySortedArray(
-          changeNum, patchRange).then(function(files) {
+          changeNum, patchRange).then(files => {
             this._fileList = files;
-          }.bind(this));
+          });
     },
 
-    _getDiffPreferences: function() {
+    _getDiffPreferences() {
       return this.$.restAPI.getDiffPreferences();
     },
 
-    _getPreferences: function() {
+    _getPreferences() {
       return this.$.restAPI.getPreferences();
     },
 
-    _getWindowWidth: function() {
+    _getWindowWidth() {
       return window.innerWidth;
     },
 
-    _handleReviewedChange: function(e) {
+    _handleReviewedChange(e) {
       this._setReviewed(Polymer.dom(e).rootTarget.checked);
     },
 
-    _setReviewed: function(reviewed) {
+    _setReviewed(reviewed) {
+      if (this._editLoaded) { return; }
       this.$.reviewed.checked = reviewed;
-      this._saveReviewedState(reviewed).catch(function(err) {
-        alert('Couldn’t change file review status. Check the console ' +
-            'and contact the PolyGerrit team for assistance.');
+      this._saveReviewedState(reviewed).catch(err => {
+        this.fire('show-alert', {message: ERR_REVIEW_STATUS});
         throw err;
-      }.bind(this));
+      });
     },
 
-    _saveReviewedState: function(reviewed) {
+    _saveReviewedState(reviewed) {
       return this.$.restAPI.saveFileReviewed(this._changeNum,
           this._patchRange.patchNum, this._path, reviewed);
     },
 
-    _handleEscKey: function(e) {
+    _handleEscKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -220,21 +247,21 @@
       this.$.diff.displayLine = false;
     },
 
-    _handleShiftLeftKey: function(e) {
+    _handleShiftLeftKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.$.cursor.moveLeft();
     },
 
-    _handleShiftRightKey: function(e) {
+    _handleShiftRightKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
       this.$.cursor.moveRight();
     },
 
-    _handleUpKey: function(e) {
+    _handleUpKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (e.detail.keyboardEvent.shiftKey &&
           e.detail.keyboardEvent.keyCode === 75) { // 'K'
@@ -248,7 +275,7 @@
       this.$.cursor.moveUp();
     },
 
-    _handleDownKey: function(e) {
+    _handleDownKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (e.detail.keyboardEvent.shiftKey &&
           e.detail.keyboardEvent.keyCode === 74) { // 'J'
@@ -262,49 +289,51 @@
       this.$.cursor.moveDown();
     },
 
-    _moveToPreviousFileWithComment: function() {
+    _moveToPreviousFileWithComment() {
       if (this._commentSkips && this._commentSkips.previous) {
-        page.show(this._getDiffURL(this._changeNum, this._patchRange,
-            this._commentSkips.previous));
+        Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.previous,
+            this._patchRange.patchNum, this._patchRange.basePatchNum);
       }
     },
 
-    _moveToNextFileWithComment: function() {
+    _moveToNextFileWithComment() {
       if (this._commentSkips && this._commentSkips.next) {
-        page.show(this._getDiffURL(this._changeNum, this._patchRange,
-            this._commentSkips.next));
+        Gerrit.Nav.navigateToDiff(this._change, this._commentSkips.next,
+            this._patchRange.patchNum, this._patchRange.basePatchNum);
       }
     },
 
-    _handleCKey: function(e) {
+    _handleCKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       if (this.$.diff.isRangeSelected()) { return; }
       if (this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      var line = this.$.cursor.getTargetLineElement();
+      const line = this.$.cursor.getTargetLineElement();
       if (line) {
         this.$.diff.addDraftAtLine(line);
       }
     },
 
-    _handleLeftBracketKey: function(e) {
+    _handleLeftBracketKey(e) {
+      // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          this.getKeyboardEvent(e).metaKey) { return; }
 
       e.preventDefault();
       this._navToFile(this._path, this._fileList, -1);
     },
 
-    _handleRightBracketKey: function(e) {
+    _handleRightBracketKey(e) {
+      // Check for meta key to avoid overriding native chrome shortcut.
       if (this.shouldSuppressKeyboardShortcut(e) ||
-          this.modifierPressed(e)) { return; }
+          this.getKeyboardEvent(e).metaKey) { return; }
 
       e.preventDefault();
       this._navToFile(this._path, this._fileList, 1);
     },
 
-    _handleNKey: function(e) {
+    _handleNKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -316,7 +345,7 @@
       }
     },
 
-    _handlePKey: function(e) {
+    _handlePKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       e.preventDefault();
@@ -328,7 +357,7 @@
       }
     },
 
-    _handleAKey: function(e) {
+    _handleAKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
 
       if (e.detail.keyboardEvent.shiftKey) { // Hide left diff.
@@ -340,18 +369,13 @@
       if (this.modifierPressed(e)) { return; }
 
       if (!this._loggedIn) { return; }
-      if (this.$.restAPI.hasPendingDiffDrafts()) {
-        this.dispatchEvent(new CustomEvent('show-alert',
-            {detail: {message: COMMENT_SAVE}, bubbles: true}));
-        return;
-      }
 
       this.set('changeViewState.showReplyDialog', true);
       e.preventDefault();
       this._navToChangeView();
     },
 
-    _handleUKey: function(e) {
+    _handleUKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -359,49 +383,41 @@
       this._navToChangeView();
     },
 
-    _handleCommaKey: function(e) {
+    _handleCommaKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
       e.preventDefault();
-      this._openPrefs();
+      this.$.diffPreferences.open();
     },
 
-    _navToChangeView: function() {
+    _navToChangeView() {
       if (!this._changeNum || !this._patchRange.patchNum) { return; }
-
-      page.show(this._getChangePath(
-          this._changeNum,
+      this._navigateToChange(
+          this._change,
           this._patchRange,
-          this._change && this._change.revisions));
+          this._change && this._change.revisions);
     },
 
-    _computeUpURL: function(changeNum, patchRange, change, changeRevisions) {
-      return this._getChangePath(
-          changeNum,
-          patchRange,
-          change && changeRevisions);
-    },
+    _navToFile(path, fileList, direction) {
+      const newPath = this._getNavLinkPath(path, fileList, direction);
+      if (!newPath) { return; }
 
-    _navToFile: function(path, fileList, direction) {
-      var url = this._computeNavLinkURL(path, fileList, direction);
-      if (!url) { return; }
+      if (newPath.up) {
+        this._navigateToChange(
+            this._change,
+            this._patchRange,
+            this._change && this._change.revisions);
+        return;
+      }
 
-      page.show(this._computeNavLinkURL(path, fileList, direction));
-    },
-
-    _openPrefs: function() {
-      this.$.prefsOverlay.open().then(function() {
-        var diffPreferences = this.$.diffPreferences;
-        var focusStops = diffPreferences.getFocusStops();
-        this.$.prefsOverlay.setFocusStops(focusStops);
-        this.$.diffPreferences.resetFocus();
-      }.bind(this));
+      Gerrit.Nav.navigateToDiff(this._change, newPath.path,
+          this._patchRange.patchNum, this._patchRange.basePatchNum);
     },
 
     /**
      * @param {?string} path The path of the current file being shown.
-     * @param {Array.<string>} fileList The list of files in this change and
+     * @param {!Array<string>} fileList The list of files in this change and
      *     patch range.
      * @param {number} direction Either 1 (next file) or -1 (prev file).
      * @param {(number|boolean)} opt_noUp Whether to return to the change view
@@ -410,13 +426,46 @@
      * @return {?string} The next URL when proceeding in the specified
      *     direction.
      */
-    _computeNavLinkURL: function(path, fileList, direction, opt_noUp) {
+    _computeNavLinkURL(change, path, fileList, direction, opt_noUp) {
+      const newPath = this._getNavLinkPath(path, fileList, direction, opt_noUp);
+      if (!newPath) { return null; }
+
+      if (newPath.up) {
+        return this._getChangePath(
+            this._change,
+            this._patchRange,
+            this._change && this._change.revisions);
+      }
+      return this._getDiffUrl(this._change, this._patchRange, newPath.path);
+    },
+
+    /**
+     * Gives an object representing the target of navigating either left or
+     * right through the change. The resulting object will have one of the
+     * following forms:
+     *   * {path: "<target file path>"} - When another file path should be the
+     *     result of the navigation.
+     *   * {up: true} - When the result of navigating should go back to the
+     *     change view.
+     *   * null - When no navigation is possible for the given direction.
+     *
+     * @param {?string} path The path of the current file being shown.
+     * @param {!Array<string>} fileList The list of files in this change and
+     *     patch range.
+     * @param {number} direction Either 1 (next file) or -1 (prev file).
+     * @param {?number|boolean=} opt_noUp Whether to return to the change view
+     *     when advancing the file goes outside the bounds of fileList.
+     * @return {?Object}
+     */
+    _getNavLinkPath(path, fileList, direction, opt_noUp) {
       if (!path || fileList.length === 0) { return null; }
 
-      var idx = fileList.indexOf(path);
+      let idx = fileList.indexOf(path);
       if (idx === -1) {
-        var file = direction > 0 ? fileList[0] : fileList[fileList.length - 1];
-        return this._getDiffURL(this._changeNum, this._patchRange, file);
+        const file = direction > 0 ?
+            fileList[0] :
+            fileList[fileList.length - 1];
+        return {path: file};
       }
 
       idx += direction;
@@ -424,28 +473,31 @@
       // outside the bounds of [0, fileList.length).
       if (idx < 0 || idx > fileList.length - 1) {
         if (opt_noUp) { return null; }
-        return this._getChangePath(
-            this._changeNum,
-            this._patchRange,
-            this._change && this._change.revisions);
+        return {up: true};
       }
-      return this._getDiffURL(this._changeNum, this._patchRange, fileList[idx]);
+
+      return {path: fileList[idx]};
     },
 
-    _paramsChanged: function(value) {
-      if (value.view != this.tagName.toLowerCase()) { return; }
+    _paramsChanged(value) {
+      if (value.view !== Gerrit.Nav.View.DIFF) { return; }
 
-      this._loadHash(location.hash);
+      this._initCursor(this.params);
 
       this._changeNum = value.changeNum;
       this._patchRange = {
         patchNum: value.patchNum,
-        basePatchNum: value.basePatchNum || 'PARENT',
+        basePatchNum: value.basePatchNum || PARENT,
       };
       this._path = value.path;
 
-      this.fire('title-change',
-          {title: this._computeFileDisplayName(this._path)});
+      // NOTE: This may be called before attachment (e.g. while parentElement is
+      // null). Fire title-change in an async so that, if attachment to the DOM
+      // has been queued, the event can bubble up to the handler in gr-app.
+      this.async(() => {
+        this.fire('title-change',
+            {title: this.computeTruncatedPath(this._path)});
+      });
 
       // When navigating away from the page, there is a possibility that the
       // patch number is no longer a part of the URL (say when navigating to
@@ -454,128 +506,146 @@
         return;
       }
 
-      var promises = [];
+      const promises = [];
 
       this._localPrefs = this.$.storage.getPreferences();
-      promises.push(this._getDiffPreferences().then(function(prefs) {
+      promises.push(this._getDiffPreferences().then(prefs => {
         this._prefs = prefs;
-      }.bind(this)));
+      }));
 
-      promises.push(this._getPreferences().then(function(prefs) {
+      promises.push(this._getPreferences().then(prefs => {
         this._userPrefs = prefs;
-      }.bind(this)));
+      }));
 
       promises.push(this._getChangeDetail(this._changeNum));
 
-      Promise.all(promises).then(function() {
+      promises.push(this._loadComments());
+
+      promises.push(this._getChangeEdit(this._changeNum));
+
+      this._loading = true;
+      Promise.all(promises).then(r => {
+        const edit = r[4];
+        if (edit) {
+          this.set('_change.revisions.' + edit.commit.commit, {
+            _number: this.EDIT_NAME,
+            basePatchNum: edit.base_patch_set_number,
+            commit: edit.commit,
+          });
+        }
         this._loading = false;
+        this.$.diff.comments = this._commentsForDiff;
         this.$.diff.reload();
-      }.bind(this));
-
-      this._loadCommentMap().then(function(commentMap) {
-        this._commentMap = commentMap;
-      }.bind(this));
+      });
     },
 
-    /**
-     * If the URL hash is a diff address then configure the diff cursor.
-     */
-    _loadHash: function(hash) {
-      hash = hash.replace(/^#/, '');
-      if (!HASH_PATTERN.test(hash)) { return; }
-      if (hash[0] === 'a' || hash[0] === 'b') {
-        this.$.cursor.side = DiffSides.LEFT;
-        hash = hash.substring(1);
-      } else {
-        this.$.cursor.side = DiffSides.RIGHT;
+    _changeViewStateChanged(changeViewState) {
+      if (changeViewState.diffMode === null) {
+        // If screen size is small, always default to unified view.
+        this.$.restAPI.getPreferences().then(prefs => {
+          this.set('changeViewState.diffMode', prefs.default_diff_view);
+        });
       }
-      this.$.cursor.initialLineNumber = parseInt(hash, 10);
     },
 
-    _pathChanged: function(path) {
-      if (this._fileList.length == 0) { return; }
-
-      this.set('changeViewState.selectedFileIndex',
-          this._fileList.indexOf(path));
-
-      if (this._loggedIn) {
+    _setReviewedObserver(_loggedIn) {
+      if (_loggedIn) {
         this._setReviewed(true);
       }
     },
 
-    _getDiffURL: function(changeNum, patchRange, path) {
-      return this.getBaseUrl() + '/c/' + changeNum + '/' +
-          this._patchRangeStr(patchRange) + '/' + this.encodeURL(path, true);
+    /**
+     * If the params specify a diff address then configure the diff cursor.
+     */
+    _initCursor(params) {
+      if (params.lineNum === undefined) { return; }
+      if (params.leftSide) {
+        this.$.cursor.side = DiffSides.LEFT;
+      } else {
+        this.$.cursor.side = DiffSides.RIGHT;
+      }
+      this.$.cursor.initialLineNumber = params.lineNum;
     },
 
-    _computeDiffURL: function(changeNum, patchRangeRecord, path) {
-      return this._getDiffURL(changeNum, patchRangeRecord.base, path);
+    _pathChanged(path) {
+      if (path) {
+        this.fire('title-change',
+            {title: this.computeTruncatedPath(path)});
+      }
+
+      if (this._fileList.length == 0) { return; }
+
+      this.set('changeViewState.selectedFileIndex',
+          this._fileList.indexOf(path));
     },
 
-    _patchRangeStr: function(patchRange) {
-      var patchStr = patchRange.patchNum;
+    _getDiffUrl(change, patchRange, path) {
+      return Gerrit.Nav.getUrlForDiff(change, path, patchRange.patchNum,
+          patchRange.basePatchNum);
+    },
+
+    _computeDiffURL(change, patchRangeRecord, path) {
+      return this._getDiffUrl(change, patchRangeRecord.base, path);
+    },
+
+    _patchRangeStr(patchRange) {
+      let patchStr = patchRange.patchNum;
       if (patchRange.basePatchNum != null &&
-          patchRange.basePatchNum != 'PARENT') {
+          patchRange.basePatchNum != PARENT) {
         patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
       }
       return patchStr;
     },
 
-    _computeAvailablePatches: function(revisions) {
-      var patchNums = [];
-      for (var rev in revisions) {
-        patchNums.push(revisions[rev]._number);
+    /**
+     * When the latest patch of the change is selected (and there is no base
+     * patch) then the patch range need not appear in the URL. Return a patch
+     * range object with undefined values when a range is not needed.
+     *
+     * @param {!Object} patchRange
+     * @param {!Object} revisions
+     * @return {!Object}
+     */
+    _getChangeUrlRange(patchRange, revisions) {
+      let patchNum = undefined;
+      let basePatchNum = undefined;
+      let latestPatchNum = -1;
+      for (const rev of Object.values(revisions || {})) {
+        latestPatchNum = Math.max(latestPatchNum, rev._number);
       }
-      return patchNums.sort(function(a, b) { return a - b; });
-    },
-
-    _getChangePath: function(changeNum, patchRange, revisions) {
-      var base = this.getBaseUrl() + '/c/' + changeNum + '/';
-
-      // The change may not have loaded yet, making revisions unavailable.
-      if (!revisions) {
-        return base + this._patchRangeStr(patchRange);
-      }
-
-      var latestPatchNum = -1;
-      for (var rev in revisions) {
-        latestPatchNum = Math.max(latestPatchNum, revisions[rev]._number);
-      }
-      if (patchRange.basePatchNum !== 'PARENT' ||
+      if (patchRange.basePatchNum !== PARENT ||
           parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
-        return base + this._patchRangeStr(patchRange);
+        patchNum = patchRange.patchNum;
+        basePatchNum = patchRange.basePatchNum;
       }
-
-      return base;
+      return {patchNum, basePatchNum};
     },
 
-    _computeChangePath: function(changeNum, patchRangeRecord, revisions) {
-      return this._getChangePath(changeNum, patchRangeRecord.base, revisions);
+    _getChangePath(change, patchRange, revisions) {
+      const range = this._getChangeUrlRange(patchRange, revisions);
+      return Gerrit.Nav.getUrlForChange(change, range.patchNum,
+          range.basePatchNum);
     },
 
-    _computeFileDisplayName: function(path) {
-      if (path === COMMIT_MESSAGE_PATH) {
-        return 'Commit message';
-      } else if (path === MERGE_LIST_PATH) {
-        return 'Merge list';
-      }
-      return path;
+    _navigateToChange(change, patchRange, revisions) {
+      const range = this._getChangeUrlRange(patchRange, revisions);
+      Gerrit.Nav.navigateToChange(change, range.patchNum, range.basePatchNum);
     },
 
-    _computeTruncatedFileDisplayName: function(path) {
-      return util.truncatePath(this._computeFileDisplayName(path));
+    _computeChangePath(change, patchRangeRecord, revisions) {
+      return this._getChangePath(change, patchRangeRecord.base, revisions);
     },
 
-    _computeFileSelected: function(path, currentPath) {
+    _computeFileSelected(path, currentPath) {
       return path == currentPath;
     },
 
-    _computePrefsButtonHidden: function(prefs, loggedIn) {
+    _computePrefsButtonHidden(prefs, loggedIn) {
       return !loggedIn || !prefs;
     },
 
-    _computeKeyNav: function(path, selectedPath, fileList) {
-      var selectedIndex = fileList.indexOf(selectedPath);
+    _computeKeyNav(path, selectedPath, fileList) {
+      const selectedIndex = fileList.indexOf(selectedPath);
       if (fileList.indexOf(path) == selectedIndex - 1) {
         return '[';
       }
@@ -585,46 +655,50 @@
       return '';
     },
 
-    _handleFileTap: function(e) {
-      this.$.dropdown.close();
+    _handleFileTap(e) {
+      // async is needed so that that the click event is fired before the
+      // dropdown closes (This was a bug for touch devices).
+      this.async(() => {
+        this.$.dropdown.close();
+      }, 1);
     },
 
-    _handleMobileSelectChange: function(e) {
-      var path = Polymer.dom(e).rootTarget.value;
-      page.show(this._getDiffURL(this._changeNum, this._patchRange, path));
+    _handleMobileSelectChange(e) {
+      const path = Polymer.dom(e).rootTarget.value;
+      Gerrit.Nav.navigateToDiff(this._change, path, this._patchRange.patchNum,
+          this._patchRange.basePatchNum);
     },
 
-    _showDropdownTapHandler: function(e) {
+    _showDropdownTapHandler(e) {
       this.$.dropdown.open();
     },
 
-    _handlePrefsTap: function(e) {
-      e.preventDefault();
-      this._openPrefs();
+    _handlePatchChange(e) {
+      const {basePatchNum, patchNum} = e.detail;
+      if (this.patchNumEquals(basePatchNum, this._patchRange.basePatchNum) &&
+          this.patchNumEquals(patchNum, this._patchRange.patchNum)) { return; }
+      Gerrit.Nav.navigateToDiff(
+          this._change, this._path, patchNum, basePatchNum);
     },
 
-    _handlePrefsSave: function(e) {
+    _handlePrefsTap(e) {
+      e.preventDefault();
+      this.$.diffPreferences.open();
+    },
+
+    _handlePrefsSave(e) {
       e.stopPropagation();
-      var el = Polymer.dom(e).rootTarget;
+      const el = Polymer.dom(e).rootTarget;
       el.disabled = true;
       this.$.storage.savePreferences(this._localPrefs);
-      this._saveDiffPreferences().then(function(response) {
+      this._saveDiffPreferences().then(response => {
         el.disabled = false;
         if (!response.ok) { return response; }
 
         this.$.prefsOverlay.close();
-      }.bind(this)).catch(function(err) {
+      }).catch(err => {
         el.disabled = false;
-      }.bind(this));
-    },
-
-    _saveDiffPreferences: function() {
-      return this.$.restAPI.saveDiffPreferences(this._prefs);
-    },
-
-    _handlePrefsCancel: function(e) {
-      e.stopPropagation();
-      this.$.prefsOverlay.close();
+      });
     },
 
     /**
@@ -639,9 +713,9 @@
      *
      * Use side-by-side if the user is not logged in.
      *
-     * @return {String}
+     * @return {string}
      */
-    _getDiffViewMode: function() {
+    _getDiffViewMode() {
       if (this.changeViewState.diffMode) {
         return this.changeViewState.diffMode;
       } else if (this._userPrefs) {
@@ -652,66 +726,48 @@
       }
     },
 
-    _computeModeSelectHidden: function() {
+    _computeModeSelectHidden() {
       return this._isImageDiff;
     },
 
-    _onLineSelected: function(e, detail) {
+    _onLineSelected(e, detail) {
       this.$.cursor.moveToLineNumber(detail.number, detail.side);
-      history.replaceState(null, null, '#' + this.$.cursor.getAddress());
+      if (!this._change) { return; }
+      const cursorAddress = this.$.cursor.getAddress();
+      const number = cursorAddress ? cursorAddress.number : undefined;
+      const leftSide = cursorAddress ? cursorAddress.leftSide : undefined;
+      const url = Gerrit.Nav.getUrlForDiffById(this._changeNum,
+          this._change.project, this._path, this._patchRange.patchNum,
+          this._patchRange.basePatchNum, number, leftSide);
+      history.replaceState(null, '', url);
     },
 
-    _computeDownloadLink: function(changeNum, patchRange, path) {
-      var url = this.changeBaseURL(changeNum, patchRange.patchNum);
+    _computeDownloadLink(changeNum, patchRange, path) {
+      let url = this.changeBaseURL(changeNum, patchRange.patchNum);
       url += '/patch?zip&path=' + encodeURIComponent(path);
       return url;
     },
 
-    /**
-     * Request all comments (and drafts and robot comments) for the current
-     * change and construct the map of file paths that have comments for the
-     * current patch range.
-     * @return {Promise} A promise that yields a comment map object.
-     */
-    _loadCommentMap: function() {
-      function filterByRange(comment) {
-        var patchNum = comment.patch_set + '';
-        return patchNum === this._patchRange.patchNum ||
-            patchNum === this._patchRange.basePatchNum;
-      };
+    _loadComments() {
+      return this.$.commentAPI.loadAll(this._changeNum).then(() => {
+        this._commentMap = this.$.commentAPI.getPaths(this._patchRange);
 
-      return Promise.all([
-        this.$.restAPI.getDiffComments(this._changeNum),
-        this._getDiffDrafts(),
-        this.$.restAPI.getDiffRobotComments(this._changeNum),
-      ]).then(function(results) {
-        var commentMap = {};
-        results.forEach(function(response) {
-          for (var path in response) {
-            if (response.hasOwnProperty(path) &&
-                response[path].filter(filterByRange.bind(this)).length) {
-              commentMap[path] = true;
-            }
-          }
-        }.bind(this));
-        return commentMap;
-      }.bind(this));
+        this._commentsForDiff = this.$.commentAPI.getCommentsForPath(this._path,
+            this._patchRange, this._projectConfig);
+      });
     },
 
-    _getDiffDrafts: function() {
-      return this._getLoggedIn().then(function(loggedIn) {
-        if (!loggedIn) { return Promise.resolve({}); }
-        return this.$.restAPI.getDiffDrafts(this._changeNum);
-      }.bind(this));
+    _getDiffDrafts() {
+      return this.$.restAPI.getDiffDrafts(this._changeNum);
     },
 
-    _computeCommentSkips: function(commentMap, fileList, path) {
-      var skips = {previous: null, next: null};
+    _computeCommentSkips(commentMap, fileList, path) {
+      const skips = {previous: null, next: null};
       if (!fileList.length) { return skips; }
-      var pathIndex = fileList.indexOf(path);
+      const pathIndex = fileList.indexOf(path);
 
       // Scan backward for the previous file.
-      for (var i = pathIndex - 1; i >= 0; i--) {
+      for (let i = pathIndex - 1; i >= 0; i--) {
         if (commentMap[fileList[i]]) {
           skips.previous = fileList[i];
           break;
@@ -719,7 +775,7 @@
       }
 
       // Scan forward for the next file.
-      for (i = pathIndex + 1; i < fileList.length; i++) {
+      for (let i = pathIndex + 1; i < fileList.length; i++) {
         if (commentMap[fileList[i]]) {
           skips.next = fileList[i];
           break;
@@ -728,5 +784,57 @@
 
       return skips;
     },
+
+    _computeDiffClass(panelFloatingDisabled) {
+      if (panelFloatingDisabled) {
+        return 'noOverflow';
+      }
+    },
+
+    /**
+     * @param {!Object} patchRangeRecord
+     */
+    _computeEditLoaded(patchRangeRecord) {
+      const patchRange = patchRangeRecord.base || {};
+      return this.patchNumEquals(patchRange.patchNum, this.EDIT_NAME);
+    },
+
+    /**
+     * @param {boolean} editLoaded
+     */
+    _computeContainerClass(editLoaded) {
+      return editLoaded ? 'editLoaded' : '';
+    },
+
+    _computeBlameToggleLabel(loaded, loading) {
+      if (loaded) { return 'Hide blame'; }
+      return 'Show blame';
+    },
+
+    /**
+     * Load and display blame information if it has not already been loaded.
+     * Otherwise hide it.
+     */
+    _toggleBlame() {
+      if (this._isBlameLoaded) {
+        this.$.diff.clearBlame();
+        return;
+      }
+
+      this._isBlameLoading = true;
+      this.fire('show-alert', {message: MSG_LOADING_BLAME});
+      this.$.diff.loadBlame()
+          .then(() => {
+            this._isBlameLoading = false;
+            this.fire('show-alert', {message: MSG_LOADED_BLAME});
+          })
+          .catch(() => {
+            this._isBlameLoading = false;
+          });
+    },
+
+    _computeBlameLoaderClass(isImageDiff, supported) {
+      return !isImageDiff && supported ? 'show' : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index d1bcd60..fa37eb9 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -20,10 +20,10 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-diff-view.html">
 
 <script>void(0);</script>
@@ -41,40 +41,45 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff-view tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff-view tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    const PARENT = 'PARENT';
+
+    setup(() => {
       sandbox = sinon.sandbox.create();
 
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(false); },
-        getProjectConfig: function() { return Promise.resolve({}); },
-        getDiffChangeDetail: function() { return Promise.resolve(null); },
-        getChangeFiles: function() { return Promise.resolve({}); },
-        saveFileReviewed: function() { return Promise.resolve(); },
+        getLoggedIn() { return Promise.resolve(false); },
+        getProjectConfig() { return Promise.resolve({}); },
+        getDiffChangeDetail() { return Promise.resolve(null); },
+        getChangeFiles() { return Promise.resolve({}); },
+        saveFileReviewed() { return Promise.resolve(); },
+        getDiffRobotComments() { return Promise.resolve({}); },
+        getDiffDrafts() { return Promise.resolve(); },
       });
       element = fixture('basic');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('toggle left diff with a hotkey', function() {
-      var toggleLeftDiffStub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
+    test('toggle left diff with a hotkey', () => {
+      const toggleLeftDiffStub = sandbox.stub(element.$.diff, 'toggleLeftDiff');
       MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift', 'a');
       assert.isTrue(toggleLeftDiffStub.calledOnce);
     });
 
-    test('keyboard shortcuts', function() {
+    test('keyboard shortcuts', () => {
       element._changeNum = '42';
       element._patchRange = {
-        basePatchNum: 'PARENT',
+        basePatchNum: PARENT,
         patchNum: '10',
       };
       element._change = {
+        _number: 42,
         revisions: {
           a: {_number: 10},
         },
@@ -83,41 +88,48 @@
       element._path = 'glados.txt';
       element.changeViewState.selectedFileIndex = 1;
 
-      var showStub = sandbox.stub(page, 'show');
+      const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
+
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+      assert(changeNavStub.lastCall.calledWith(element._change),
           'Should navigate to /c/42/');
 
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert(showStub.lastCall.calledWithExactly('/c/42/10/wheatley.md'),
-          'Should navigate to /c/42/10/wheatley.md');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'wheatley.md',
+          '10', PARENT), 'Should navigate to /c/42/10/wheatley.md');
       element._path = 'wheatley.md';
       assert.equal(element.changeViewState.selectedFileIndex, 2);
+      assert.isTrue(element._loading);
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/10/glados.txt'),
-          'Should navigate to /c/42/10/glados.txt');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'glados.txt',
+          '10', PARENT), 'Should navigate to /c/42/10/glados.txt');
       element._path = 'glados.txt';
       assert.equal(element.changeViewState.selectedFileIndex, 1);
+      assert.isTrue(element._loading);
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/10/chell.go'),
-          'Should navigate to /c/42/10/chell.go');
+      assert(diffNavStub.lastCall.calledWith(element._change, 'chell.go', '10',
+          PARENT), 'Should navigate to /c/42/10/chell.go');
       element._path = 'chell.go';
       assert.equal(element.changeViewState.selectedFileIndex, 0);
+      assert.isTrue(element._loading);
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/'),
+      assert(changeNavStub.lastCall.calledWith(element._change),
           'Should navigate to /c/42/');
       assert.equal(element.changeViewState.selectedFileIndex, 0);
+      assert.isTrue(element._loading);
 
-      var showPrefsStub = sandbox.stub(element.$.prefsOverlay, 'open',
-          function() { return Promise.resolve({}); });
+      const showPrefsStub =
+          sandbox.stub(element.$.diffPreferences.$.prefsOverlay, 'open',
+              () => Promise.resolve());
 
       MockInteractions.pressAndReleaseKeyOn(element, 188, null, ',');
       assert(showPrefsStub.calledOnce);
 
-      var scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
+      let scrollStub = sandbox.stub(element.$.cursor, 'moveToNextChunk');
       MockInteractions.pressAndReleaseKeyOn(element, 78, null, 'n');
       assert(scrollStub.calledOnce);
 
@@ -134,7 +146,7 @@
       MockInteractions.pressAndReleaseKeyOn(element, 80, 'shift', 'p');
       assert(scrollStub.calledOnce);
 
-      var computeContainerClassStub = sandbox.stub(element.$.diff,
+      const computeContainerClassStub = sandbox.stub(element.$.diff,
           '_computeContainerClass');
       MockInteractions.pressAndReleaseKeyOn(element, 74, null, 'j');
       assert(computeContainerClassStub.lastCall.calledWithExactly(
@@ -145,41 +157,28 @@
           false, 'SIDE_BY_SIDE', false));
     });
 
-    test('saving diff preferences', function() {
-      var savePrefs = sandbox.stub(element, '_handlePrefsSave');
-      var cancelPrefs = sandbox.stub(element, '_handlePrefsCancel');
-      element.$.diffPreferences._handleSave();
-      assert(savePrefs.calledOnce);
-      assert(cancelPrefs.notCalled);
-    });
-
-    test('cancelling diff preferences', function() {
-      var savePrefs = sandbox.stub(element, '_handlePrefsSave');
-      var cancelPrefs = sandbox.stub(element, '_handlePrefsCancel');
-      element.$.diffPreferences._handleCancel();
-      assert(cancelPrefs.calledOnce);
-      assert(savePrefs.notCalled);
-    });
-
-    test('keyboard shortcuts with patch range', function() {
+    test('keyboard shortcuts with patch range', () => {
       element._changeNum = '42';
       element._patchRange = {
         basePatchNum: '5',
         patchNum: '10',
       };
       element._change = {
+        _number: 42,
         revisions: {
           a: {_number: 10},
+          b: {_number: 5},
         },
       };
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sandbox.stub(page, 'show');
+      const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
 
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
-          'only work when the user is logged in.');
+      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
+          'should only work when the user is logged in.');
       assert.isNull(window.sessionStorage.getItem(
           'changeView.showReplyDialog'));
 
@@ -187,40 +186,49 @@
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(element.changeViewState.showReplyDialog);
 
-      assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
-          'Should navigate to /c/42/5..10');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
+          '5'), 'Should navigate to /c/42/5..10');
 
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
-          'Should navigate to /c/42/5..10');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
+          '5'), 'Should navigate to /c/42/5..10');
 
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/wheatley.md'),
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'wheatley.md', '10', '5'),
           'Should navigate to /c/42/5..10/wheatley.md');
       element._path = 'wheatley.md';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/glados.txt'),
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'glados.txt', '10', '5'),
           'Should navigate to /c/42/5..10/glados.txt');
       element._path = 'glados.txt';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/5..10/chell.go'),
+      assert.isTrue(element._loading);
+      assert(diffNavStub.lastCall.calledWithExactly(element._change, 'chell.go',
+          '10', '5'),
           'Should navigate to /c/42/5..10/chell.go');
       element._path = 'chell.go';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/5..10'),
+      assert.isTrue(element._loading);
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '10',
+          '5'),
           'Should navigate to /c/42/5..10');
     });
 
-    test('keyboard shortcuts with old patch number', function() {
+    test('keyboard shortcuts with old patch number', () => {
       element._changeNum = '42';
       element._patchRange = {
-        basePatchNum: 'PARENT',
+        basePatchNum: PARENT,
         patchNum: '1',
       };
       element._change = {
+        _number: 42,
         revisions: {
           a: {_number: 1},
           b: {_number: 2},
@@ -229,11 +237,12 @@
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
 
-      var showStub = sandbox.stub(page, 'show');
+      const diffNavStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      const changeNavStub = sandbox.stub(Gerrit.Nav, 'navigateToChange');
 
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
-          'only work when the user is logged in.');
+      assert.isTrue(changeNavStub.notCalled, 'The `a` keyboard shortcut ' +
+          'should only work when the user is logged in.');
       assert.isNull(window.sessionStorage.getItem(
           'changeView.showReplyDialog'));
 
@@ -241,230 +250,258 @@
       MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
       assert.isTrue(element.changeViewState.showReplyDialog);
 
-      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
-          'Should navigate to /c/42/1');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+          PARENT), 'Should navigate to /c/42/1');
 
       MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
-          'Should navigate to /c/42/1');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+          PARENT), 'Should navigate to /c/42/1');
 
       MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'wheatley.md', '1', PARENT),
           'Should navigate to /c/42/1/wheatley.md');
       element._path = 'wheatley.md';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
+      assert(diffNavStub.lastCall.calledWithExactly(element._change,
+          'glados.txt', '1', PARENT),
           'Should navigate to /c/42/1/glados.txt');
       element._path = 'glados.txt';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
-          'Should navigate to /c/42/1/chell.go');
+      assert(diffNavStub.lastCall.calledWithExactly(element._change, 'chell.go',
+          '1', PARENT), 'Should navigate to /c/42/1/chell.go');
       element._path = 'chell.go';
 
       MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
-          'Should navigate to /c/42/1');
+      assert(changeNavStub.lastCall.calledWithExactly(element._change, '1',
+          PARENT), 'Should navigate to /c/42/1');
     });
 
-    test('go up to change via kb without change loaded', function() {
-      element._changeNum = '42';
+    test('Diff preferences hidden when no prefs or logged out', () => {
+      element._loggedIn = false;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element._loggedIn = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element._loggedIn = false;
+      element._prefs = {font_size: '12'};
+      flushAsynchronousOperations();
+      assert.isTrue(element.$.diffPrefsContainer.hidden);
+
+      element._loggedIn = true;
+      flushAsynchronousOperations();
+      assert.isFalse(element.$.diffPrefsContainer.hidden);
+    });
+
+    test('prefsButton opens gr-diff-preferences', () => {
+      const handlePrefsTapSpy = sandbox.spy(element, '_handlePrefsTap');
+      const overlayOpenStub = sandbox.stub(element.$.diffPreferences,
+          'open');
+      const prefsButton =
+          Polymer.dom(element.root).querySelector('.prefsButton');
+
+      MockInteractions.tap(prefsButton);
+
+      assert.isTrue(handlePrefsTapSpy.called);
+      assert.isTrue(overlayOpenStub.called);
+    });
+
+    suite('url params', () => {
+      setup(() => {
+        sandbox.stub(Gerrit.Nav, 'getUrlForDiff', (c, p, pn, bpn) => {
+          return `${c._number}-${p}-${pn}-${bpn}`;
+        });
+        sandbox.stub(Gerrit.Nav, 'getUrlForChange', (c, pn, bpn) => {
+          return `${c._number}-${pn}-${bpn}`;
+        });
+      });
+
+      test('jump to file dropdown', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: PARENT,
+          patchNum: '10',
+        };
+        element._change = {_number: 42};
+        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+        element._path = 'glados.txt';
+        flushAsynchronousOperations();
+        const linkEls =
+            Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
+        assert.equal(linkEls.length, 3);
+        assert.isFalse(linkEls[0].hasAttribute('selected'));
+        assert.isTrue(linkEls[1].hasAttribute('selected'));
+        assert.isFalse(linkEls[2].hasAttribute('selected'));
+        assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
+        assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
+        assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
+        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'),
+            '42-glados.txt-10-PARENT');
+        assert.equal(linkEls[2].getAttribute('href'),
+            '42-wheatley.md-10-PARENT');
+      });
+
+      test('jump to file dropdown with patch range', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: '5',
+          patchNum: '10',
+        };
+        element._change = {_number: 42};
+        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+        element._path = 'glados.txt';
+        flushAsynchronousOperations();
+        const linkEls =
+            Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
+        assert.equal(linkEls.length, 3);
+        assert.isFalse(linkEls[0].hasAttribute('selected'));
+        assert.isTrue(linkEls[1].hasAttribute('selected'));
+        assert.isFalse(linkEls[2].hasAttribute('selected'));
+        assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
+        assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
+        assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
+        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
+        assert.equal(linkEls[1].getAttribute('href'), '42-glados.txt-10-5');
+        assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
+      });
+
+      test('prev/up/next links', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: PARENT,
+          patchNum: '10',
+        };
+        element._change = {
+          _number: 42,
+          revisions: {
+            a: {_number: 10},
+          },
+        };
+        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+        element._path = 'glados.txt';
+        flushAsynchronousOperations();
+        const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
+        assert.equal(linkEls.length, 3);
+        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'),
+            '42-wheatley.md-10-PARENT');
+        element._path = 'wheatley.md';
+        flushAsynchronousOperations();
+        assert.equal(linkEls[0].getAttribute('href'),
+            '42-glados.txt-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.isFalse(linkEls[2].hasAttribute('href'));
+        element._path = 'chell.go';
+        flushAsynchronousOperations();
+        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'),
+            '42-glados.txt-10-PARENT');
+        element._path = 'not_a_real_file';
+        flushAsynchronousOperations();
+        assert.equal(linkEls[0].getAttribute('href'),
+            '42-wheatley.md-10-PARENT');
+        assert.equal(linkEls[1].getAttribute('href'), '42-undefined-undefined');
+        assert.equal(linkEls[2].getAttribute('href'), '42-chell.go-10-PARENT');
+      });
+
+      test('prev/up/next links with patch range', () => {
+        element._changeNum = '42';
+        element._patchRange = {
+          basePatchNum: '5',
+          patchNum: '10',
+        };
+        element._change = {
+          _number: 42,
+          revisions: {
+            a: {_number: 5},
+            b: {_number: 10},
+          },
+        };
+        element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
+        element._path = 'glados.txt';
+        flushAsynchronousOperations();
+        const linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
+        assert.equal(linkEls.length, 3);
+        assert.equal(linkEls[0].getAttribute('href'), '42-chell.go-10-5');
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.equal(linkEls[2].getAttribute('href'), '42-wheatley.md-10-5');
+        element._path = 'wheatley.md';
+        flushAsynchronousOperations();
+        assert.equal(linkEls[0].getAttribute('href'), '42-glados.txt-10-5');
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.isFalse(linkEls[2].hasAttribute('href'));
+        element._path = 'chell.go';
+        flushAsynchronousOperations();
+        assert.isFalse(linkEls[0].hasAttribute('href'));
+        assert.equal(linkEls[1].getAttribute('href'), '42-10-5');
+        assert.equal(linkEls[2].getAttribute('href'), '42-glados.txt-10-5');
+      });
+    });
+
+    test('_handlePatchChange calls navigateToDiff correctly', () => {
+      const navigateStub = sandbox.stub(Gerrit.Nav, 'navigateToDiff');
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._path = 'path/to/file.txt';
+
       element._patchRange = {
         basePatchNum: 'PARENT',
+        patchNum: '3',
+      };
+
+      const detail = {
+        basePatchNum: 'PARENT',
         patchNum: '1',
       };
 
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
-      element._path = 'glados.txt';
+      element.$.rangeSelect.dispatchEvent(
+          new CustomEvent('patch-range-change', {detail, bubbles: false}));
 
-      var showStub = sandbox.stub(page, 'show');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(showStub.notCalled, 'The `a` keyboard shortcut should ' +
-          'only work when the user is logged in.');
-      assert.isNull(window.sessionStorage.getItem(
-          'changeView.showReplyDialog'));
-
-      element._loggedIn = true;
-      MockInteractions.pressAndReleaseKeyOn(element, 65, null, 'a');
-      assert.isTrue(element.changeViewState.showReplyDialog);
-
-      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
-          'Should navigate to /c/42/1');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 85, null, 'u');
-      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
-          'Should navigate to /c/42/1');
-
-      MockInteractions.pressAndReleaseKeyOn(element, 221, null, ']');
-      assert(showStub.lastCall.calledWithExactly('/c/42/1/wheatley.md'),
-          'Should navigate to /c/42/1/wheatley.md');
-      element._path = 'wheatley.md';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/1/glados.txt'),
-          'Should navigate to /c/42/1/glados.txt');
-      element._path = 'glados.txt';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/1/chell.go'),
-          'Should navigate to /c/42/1/chell.go');
-      element._path = 'chell.go';
-
-      MockInteractions.pressAndReleaseKeyOn(element, 219, null, '[');
-      assert(showStub.lastCall.calledWithExactly('/c/42/1'),
-          'Should navigate to /c/42/1');
+      assert(navigateStub.lastCall.calledWithExactly(element._change,
+          element._path, '1', 'PARENT'));
     });
 
-    test('jump to file dropdown', function() {
+    test('download link', () => {
       element._changeNum = '42';
       element._patchRange = {
-        basePatchNum: 'PARENT',
+        basePatchNum: PARENT,
         patchNum: '10',
       };
       element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
       element._path = 'glados.txt';
       flushAsynchronousOperations();
-      var linkEls =
-          Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
-      assert.equal(linkEls.length, 3);
-      assert.isFalse(linkEls[0].hasAttribute('selected'));
-      assert.isTrue(linkEls[1].hasAttribute('selected'));
-      assert.isFalse(linkEls[2].hasAttribute('selected'));
-      assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
-      assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
-      assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
-      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/chell.go');
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/10/glados.txt');
-      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/wheatley.md');
-
-      assert.equal(element._computeFileDisplayName('/foo/bar/baz'),
-          '/foo/bar/baz');
-      assert.equal(element._computeFileDisplayName('/COMMIT_MSG'),
-          'Commit message');
-      assert.equal(element._computeFileDisplayName('/MERGE_LIST'),
-          'Merge list');
-    });
-
-    test('jump to file dropdown with patch range', function() {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: '5',
-        patchNum: '10',
-      };
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
-      element._path = 'glados.txt';
-      flushAsynchronousOperations();
-      var linkEls =
-          Polymer.dom(element.root).querySelectorAll('.dropdown-content > a');
-      assert.equal(linkEls.length, 3);
-      assert.isFalse(linkEls[0].hasAttribute('selected'));
-      assert.isTrue(linkEls[1].hasAttribute('selected'));
-      assert.isFalse(linkEls[2].hasAttribute('selected'));
-      assert.equal(linkEls[0].getAttribute('data-key-nav'), '[');
-      assert.equal(linkEls[1].getAttribute('data-key-nav'), '');
-      assert.equal(linkEls[2].getAttribute('data-key-nav'), ']');
-      assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/chell.go');
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10/glados.txt');
-      assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
-    });
-
-    test('prev/up/next links', function() {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '10',
-      };
-      element._change = {
-        revisions: {
-          a: {_number: 10},
-        },
-      };
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
-      element._path = 'glados.txt';
-      flushAsynchronousOperations();
-      var linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
-      assert.equal(linkEls.length, 3);
-      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/chell.go');
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
-      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/wheatley.md');
-      element._path = 'wheatley.md';
-      flushAsynchronousOperations();
-      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/glados.txt');
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
-      assert.isFalse(linkEls[2].hasAttribute('href'));
-      element._path = 'chell.go';
-      flushAsynchronousOperations();
-      assert.isFalse(linkEls[0].hasAttribute('href'));
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
-      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/glados.txt');
-      element._path = 'not_a_real_file';
-      flushAsynchronousOperations();
-      assert.equal(linkEls[0].getAttribute('href'), '/c/42/10/wheatley.md');
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/');
-      assert.equal(linkEls[2].getAttribute('href'), '/c/42/10/chell.go');
-    });
-
-    test('download link', function() {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '10',
-      };
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
-      element._path = 'glados.txt';
-      flushAsynchronousOperations();
-      assert.equal(element.$$('.downloadLink').getAttribute('href'),
+      const link = element.$$('.downloadLink');
+      assert.equal(link.getAttribute('href'),
           '/changes/42/revisions/10/patch?zip&path=glados.txt');
+      assert.isTrue(link.hasAttribute('download'));
     });
 
-    test('prev/up/next links with patch range', function() {
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: '5',
-        patchNum: '10',
-      };
-      element._change = {
-        revisions: {
-          a: {_number: 5},
-          b: {_number: 10},
-        },
-      };
-      element._fileList = ['chell.go', 'glados.txt', 'wheatley.md'];
-      element._path = 'glados.txt';
-      flushAsynchronousOperations();
-      var linkEls = Polymer.dom(element.root).querySelectorAll('.navLink');
-      assert.equal(linkEls.length, 3);
-      assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/chell.go');
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10');
-      assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/wheatley.md');
-      element._path = 'wheatley.md';
-      flushAsynchronousOperations();
-      assert.equal(linkEls[0].getAttribute('href'), '/c/42/5..10/glados.txt');
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10');
-      assert.isFalse(linkEls[2].hasAttribute('href'));
-      element._path = 'chell.go';
-      flushAsynchronousOperations();
-      assert.isFalse(linkEls[0].hasAttribute('href'));
-      assert.equal(linkEls[1].getAttribute('href'), '/c/42/5..10');
-      assert.equal(linkEls[2].getAttribute('href'), '/c/42/5..10/glados.txt');
-    });
+    test('file review status', done => {
+      stub('gr-rest-api-interface', {
+        getDiffComments() { return Promise.resolve({}); },
+      });
+      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
+          () => Promise.resolve());
+      sandbox.stub(element.$.diff, 'reload');
 
-    test('file review status', function(done) {
       element._loggedIn = true;
-      element._changeNum = '42';
-      element._patchRange = {
-        basePatchNum: '1',
+      element.params = {
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: '42',
         patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
       };
-      element._fileList = ['/COMMIT_MSG'];
-      element._path = '/COMMIT_MSG';
-      var saveReviewedStub = sandbox.stub(element, '_saveReviewedState',
-          function() { return Promise.resolve(); });
 
-      flush(function() {
-        var commitMsg = Polymer.dom(element.root).querySelector(
+      flush(() => {
+        const commitMsg = Polymer.dom(element.root).querySelector(
             'input[type="checkbox"]');
 
         assert.isTrue(commitMsg.checked);
@@ -480,102 +517,128 @@
       });
     });
 
-    test('diff mode selector correctly toggles the diff', function() {
-      var select = element.$.modeSelect;
-      var diffDisplay = element.$.diff;
+    test('file review status with edit loaded', () => {
+      const saveReviewedStub = sandbox.stub(element, '_saveReviewedState');
+
+      element._patchRange = {patchNum: element.EDIT_NAME};
+      flushAsynchronousOperations();
+
+      assert.isTrue(element._editLoaded);
+      element._setReviewed();
+      assert.isFalse(saveReviewedStub.called);
+    });
+
+    test('hash is determined from params', done => {
+      stub('gr-rest-api-interface', {
+        getDiffComments() { return Promise.resolve({}); },
+      });
+      sandbox.stub(element.$.diff, 'reload');
+      sandbox.stub(element, '_initCursor');
+
+      element._loggedIn = true;
+      element.params = {
+        view: Gerrit.Nav.View.DIFF,
+        changeNum: '42',
+        patchNum: '2',
+        basePatchNum: '1',
+        path: '/COMMIT_MSG',
+        hash: 10,
+      };
+
+      flush(() => {
+        assert.isTrue(element._initCursor.calledOnce);
+        done();
+      });
+    });
+
+    test('diff mode selector correctly toggles the diff', () => {
+      const select = element.$.modeSelect;
+      const diffDisplay = element.$.diff;
       element._userPrefs = {default_diff_view: 'SIDE_BY_SIDE'};
 
       // The mode selected in the view state reflects the selected option.
-      assert.equal(element._getDiffViewMode(), select.value);
+      assert.equal(element._getDiffViewMode(), select.nativeSelect.value);
 
       // The mode selected in the view state reflects the view rednered in the
       // diff.
-      assert.equal(select.value, diffDisplay.viewMode);
+      assert.equal(select.nativeSelect.value, diffDisplay.viewMode);
 
       // We will simulate a user change of the selected mode.
-      var newMode = 'UNIFIED_DIFF';
+      const newMode = 'UNIFIED_DIFF';
       // Set the actual value of the select, and simulate the change event.
-      select.value = newMode;
-      element.fire('change', {}, {node: select});
+      select.nativeSelect.value = newMode;
+      element.fire('change', {}, {node: select.nativeSelect});
 
       // Make sure the handler was called and the state is still coherent.
       assert.equal(element._getDiffViewMode(), newMode);
-      assert.equal(element._getDiffViewMode(), select.value);
+      assert.equal(element._getDiffViewMode(), select.nativeSelect.value);
       assert.equal(element._getDiffViewMode(), diffDisplay.viewMode);
     });
 
-    test('diff mode selector initializes from preferences', function() {
-      var resolvePrefs;
-      var prefsPromise = new Promise(function(resolve) {
+    test('diff mode selector initializes from preferences', () => {
+      let resolvePrefs;
+      const prefsPromise = new Promise(resolve => {
         resolvePrefs = resolve;
       });
-      var getPreferencesStub = sandbox.stub(element.$.restAPI, 'getPreferences',
-          function() { return prefsPromise; });
+      sandbox.stub(element.$.restAPI, 'getPreferences', () => prefsPromise);
 
       // Attach a new gr-diff-view so we can intercept the preferences fetch.
-      var view = document.createElement('gr-diff-view');
-      var select = view.$.modeSelect;
+      const view = document.createElement('gr-diff-view');
+      const select = view.$.modeSelect;
       fixture('blank').appendChild(view);
       flushAsynchronousOperations();
 
       // At this point the diff mode doesn't yet have the user's preference.
-      assert.equal(select.value, 'SIDE_BY_SIDE');
+      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
 
       // Receive the overriding preference.
       resolvePrefs({default_diff_view: 'UNIFIED'});
       flushAsynchronousOperations();
-      assert.equal(select.value, 'SIDE_BY_SIDE');
+      assert.equal(select.nativeSelect.value, 'SIDE_BY_SIDE');
     });
 
-    test('_loadHash', function() {
+    test('_initCursor', () => {
       assert.isNotOk(element.$.cursor.initialLineNumber);
 
-      // Ignores invalid hashes:
-      element._loadHash('not valid');
+      // Does nothing when params specify no cursor address:
+      element._initCursor({});
       assert.isNotOk(element.$.cursor.initialLineNumber);
 
-      // Revision hash:
-      element._loadHash('234');
+      // Does nothing when params specify side but no number:
+      element._initCursor({leftSide: true});
+      assert.isNotOk(element.$.cursor.initialLineNumber);
+
+      // Revision hash: specifies lineNum but not side.
+      element._initCursor({lineNum: 234});
       assert.equal(element.$.cursor.initialLineNumber, 234);
       assert.equal(element.$.cursor.side, 'right');
 
-      // Base hash:
-      element._loadHash('b345');
+      // Base hash: specifies lineNum and side.
+      element._initCursor({leftSide: true, lineNum: 345});
       assert.equal(element.$.cursor.initialLineNumber, 345);
       assert.equal(element.$.cursor.side, 'left');
 
-      // GWT-style base hash:
-      element._loadHash('a123');
+      // Specifies right side:
+      element._initCursor({leftSide: false, lineNum: 123});
       assert.equal(element.$.cursor.initialLineNumber, 123);
-      assert.equal(element.$.cursor.side, 'left');
+      assert.equal(element.$.cursor.side, 'right');
     });
 
-    test('_shortenPath with long path should add ellipsis', function() {
-      var path =
-          'level1/level2/level3/level4/file.js';
-      var shortenedPath = util.truncatePath(path);
-      // The expected path is truncated with an ellipsis.
-      var expectedPath = '\u2026/file.js';
-      assert.equal(shortenedPath, expectedPath);
+    test('_onLineSelected', () => {
+      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+      const replaceStateStub = sandbox.stub(history, 'replaceState');
+      const moveStub = sandbox.stub(element.$.cursor, 'moveToLineNumber');
+      sandbox.stub(element.$.cursor, 'getAddress')
+          .returns({number: 123, isLeftSide: false});
 
-      var path = 'level2/file.js';
-      var shortenedPath = util.truncatePath(path);
-      assert.equal(shortenedPath, expectedPath);
-    });
-
-    test('_shortenPath with short path should not add ellipsis', function() {
-      var path = 'file.js';
-      var expectedPath = 'file.js';
-      var shortenedPath = util.truncatePath(path);
-      assert.equal(shortenedPath, expectedPath);
-    });
-
-    test('_onLineSelected', function() {
-      var replaceStateStub = sandbox.stub(history, 'replaceState');
-      var moveStub = sandbox.stub(element.$.cursor, 'moveToLineNumber');
-
-      var e = {};
-      var detail = {number: 123, side: 'right'};
+      element._changeNum = 321;
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._patchRange = {
+        basePatchNum: '3',
+        patchNum: '5',
+      };
+      const e = {};
+      const detail = {number: 123, side: 'right'};
 
       element._onLineSelected(e, detail);
 
@@ -584,17 +647,24 @@
       assert.equal(moveStub.lastCall.args[1], detail.side);
 
       assert.isTrue(replaceStateStub.called);
+      assert.isTrue(getUrlStub.called);
     });
 
-    test('_getDiffURL encodes special characters', function() {
-      var changeNum = 123;
-      var patchRange = {basePatchNum: 123, patchNum: 456};
-      var path = 'c++/cpp.cpp';
-      assert.equal(element._getDiffURL(changeNum, patchRange, path),
-          '/c/123/123..456/c%252B%252B/cpp.cpp');
+    test('_onLineSelected w/o line address', () => {
+      const getUrlStub = sandbox.stub(Gerrit.Nav, 'getUrlForDiffById');
+      sandbox.stub(history, 'replaceState');
+      sandbox.stub(element.$.cursor, 'moveToLineNumber');
+      sandbox.stub(element.$.cursor, 'getAddress').returns(null);
+      element._changeNum = 321;
+      element._change = {_number: 321, project: 'foo/bar'};
+      element._patchRange = {basePatchNum: '3', patchNum: '5'};
+      element._onLineSelected({}, {number: 123, side: 'right'});
+      assert.isTrue(getUrlStub.calledOnce);
+      assert.isUndefined(getUrlStub.lastCall.args[5]);
+      assert.isUndefined(getUrlStub.lastCall.args[6]);
     });
 
-    test('_getDiffViewMode', function() {
+    test('_getDiffViewMode', () => {
       // No user prefs or change view state set.
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
 
@@ -607,84 +677,65 @@
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
     });
 
-    suite('_loadCommentMap', function() {
-      test('empty', function(done) {
-        stub('gr-rest-api-interface', {
-          getDiffRobotComments: function() { return Promise.resolve({}); },
-          getDiffComments: function() { return Promise.resolve({}); },
+    suite('_loadComments', () => {
+      test('empty', done => {
+        stub('gr-comment-api', {
+          loadAll() { return Promise.resolve(); },
+          getPaths() { return {}; },
+          getCommentsForPath() { return {meta: {}}; },
         });
-        element._loadCommentMap().then(function(map) {
-          assert.equal(Object.keys(map).length, 0);
+        element._loadComments().then(() => {
+          assert.equal(Object.keys(element._commentMap).length, 0);
           done();
         });
       });
 
-      test('paths in patch range', function(done) {
-        stub('gr-rest-api-interface', {
-          getDiffRobotComments: function() { return Promise.resolve({}); },
-          getDiffComments: function() {
-            return Promise.resolve({
+      test('has paths', done => {
+        stub('gr-comment-api', {
+          loadAll() { return Promise.resolve(); },
+          getPaths() {
+            return {
               'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
               'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
-            });
+            };
           },
+          getCommentsForPath() { return {meta: {}}; },
         });
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: '3',
           patchNum: '5',
         };
-        element._loadCommentMap().then(function(map) {
-          assert.deepEqual(Object.keys(map),
+        element._loadComments().then(() => {
+          assert.deepEqual(Object.keys(element._commentMap),
               ['path/to/file/one.cpp', 'path-to/file/two.py']);
           done();
         });
       });
-
-      test('empty for paths outside patch range', function(done) {
-        stub('gr-rest-api-interface', {
-          getDiffRobotComments: function() { return Promise.resolve({}); },
-          getDiffComments: function() {
-            return Promise.resolve({
-              'path/to/file/one.cpp': [{patch_set: 'PARENT', message: 'lorem'}],
-              'path-to/file/two.py': [{patch_set: 2, message: 'ipsum'}],
-            });
-          },
-        });
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: '3',
-          patchNum: '5',
-        };
-        element._loadCommentMap().then(function(map) {
-          assert.equal(Object.keys(map).length, 0);
-          done();
-        });
-      });
     });
 
-    suite('_computeCommentSkips', function() {
-      test('empty file list', function() {
-        var commentMap = {
+    suite('_computeCommentSkips', () => {
+      test('empty file list', () => {
+        const commentMap = {
           'path/one.jpg': true,
           'path/three.wav': true,
         };
-        var path = 'path/two.m4v';
-        var fileList = [];
-        var result = element._computeCommentSkips(commentMap, fileList, path);
+        const path = 'path/two.m4v';
+        const fileList = [];
+        const result = element._computeCommentSkips(commentMap, fileList, path);
         assert.isNull(result.previous);
         assert.isNull(result.next);
       });
 
-      test('finds skips', function() {
-        var fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
-        var path = fileList[1];
-        var commentMap = {};
+      test('finds skips', () => {
+        const fileList = ['path/one.jpg', 'path/two.m4v', 'path/three.wav'];
+        let path = fileList[1];
+        const commentMap = {};
         commentMap[fileList[0]] = true;
         commentMap[fileList[1]] = false;
         commentMap[fileList[2]] = true;
 
-        var result = element._computeCommentSkips(commentMap, fileList, path);
+        let result = element._computeCommentSkips(commentMap, fileList, path);
         assert.equal(result.previous, fileList[0]);
         assert.equal(result.next, fileList[2]);
 
@@ -707,5 +758,35 @@
         assert.isNull(result.next);
       });
     });
+
+    test('_computeEditLoaded', () => {
+      const callCompute = range => element._computeEditLoaded({base: range});
+      assert.isFalse(callCompute({}));
+      assert.isFalse(callCompute({basePatchNum: 'PARENT', patchNum: 1}));
+      assert.isFalse(callCompute({basePatchNum: 'edit', patchNum: 1}));
+      assert.isTrue(callCompute({basePatchNum: 1, patchNum: 'edit'}));
+    });
+
+    suite('editLoaded behavior', () => {
+      setup(() => {
+        element._loggedIn = true;
+      });
+
+      const isVisible = el => {
+        assert.ok(el);
+        return getComputedStyle(el).getPropertyValue('display') !== 'none';
+      };
+
+      test('reviewed checkbox', () => {
+        sandbox.stub(element, '_handlePatchChange');
+        element._patchRange = {patchNum: '1'};
+        // Reviewed checkbox should be shown.
+        assert.isTrue(isVisible(element.$.reviewed));
+        element.set('_patchRange.patchNum', element.EDIT_NAME);
+        flushAsynchronousOperations();
+
+        assert.isFalse(isVisible(element.$.reviewed));
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
index 2dc495a..8d1cef6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group.js
@@ -22,6 +22,7 @@
     this.lines = [];
     this.adds = [];
     this.removes = [];
+    this.dueToRebase = undefined;
 
     this.lineRange = {
       left: {start: null, end: null},
@@ -44,7 +45,7 @@
   GrDiffGroup.prototype.addLine = function(line) {
     this.lines.push(line);
 
-    var notDelta = (this.type === GrDiffGroup.Type.BOTH ||
+    const notDelta = (this.type === GrDiffGroup.Type.BOTH ||
         this.type === GrDiffGroup.Type.CONTEXT_CONTROL);
     if (notDelta && (line.type === GrDiffLine.Type.ADD ||
         line.type === GrDiffLine.Type.REMOVE)) {
@@ -62,7 +63,7 @@
   GrDiffGroup.prototype.getSideBySidePairs = function() {
     if (this.type === GrDiffGroup.Type.BOTH ||
         this.type === GrDiffGroup.Type.CONTEXT_CONTROL) {
-      return this.lines.map(function(line) {
+      return this.lines.map(line => {
         return {
           left: line,
           right: line,
@@ -70,9 +71,9 @@
       });
     }
 
-    var pairs = [];
-    var i = 0;
-    var j = 0;
+    const pairs = [];
+    let i = 0;
+    let j = 0;
     while (i < this.removes.length || j < this.adds.length) {
       pairs.push({
         left: this.removes[i] || GrDiffLine.BLANK_LINE,
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
index 563825e..32405cf 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-group_test.html
@@ -19,17 +19,17 @@
 <title>gr-diff-group</title>
 
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="gr-diff-line.js"></script>
 <script src="gr-diff-group.js"></script>
 
 <script>
-  suite('gr-diff-group tests', function() {
-
-    test('delta line pairs', function() {
-      var group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
-      var l1 = new GrDiffLine(GrDiffLine.Type.ADD);
-      var l2 = new GrDiffLine(GrDiffLine.Type.ADD);
-      var l3 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+  suite('gr-diff-group tests', () => {
+    test('delta line pairs', () => {
+      let group = new GrDiffGroup(GrDiffGroup.Type.DELTA);
+      const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+      const l2 = new GrDiffLine(GrDiffLine.Type.ADD);
+      const l3 = new GrDiffLine(GrDiffLine.Type.REMOVE);
       l1.afterNumber = 128;
       l2.afterNumber = 129;
       l3.beforeNumber = 64;
@@ -44,7 +44,7 @@
         right: {start: 128, end: 129},
       });
 
-      var pairs = group.getSideBySidePairs();
+      let pairs = group.getSideBySidePairs();
       assert.deepEqual(pairs, [
         {left: l3, right: l1},
         {left: GrDiffLine.BLANK_LINE, right: l2},
@@ -62,20 +62,20 @@
       ]);
     });
 
-    test('group/header line pairs', function() {
-      var l1 = new GrDiffLine(GrDiffLine.Type.BOTH);
+    test('group/header line pairs', () => {
+      const l1 = new GrDiffLine(GrDiffLine.Type.BOTH);
       l1.beforeNumber = 64;
       l1.afterNumber = 128;
 
-      var l2 = new GrDiffLine(GrDiffLine.Type.BOTH);
+      const l2 = new GrDiffLine(GrDiffLine.Type.BOTH);
       l2.beforeNumber = 65;
       l2.afterNumber = 129;
 
-      var l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
       l3.beforeNumber = 66;
       l3.afterNumber = 130;
 
-      var group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
+      let group = new GrDiffGroup(GrDiffGroup.Type.BOTH, [l1, l2, l3]);
 
       assert.deepEqual(group.lines, [l1, l2, l3]);
       assert.deepEqual(group.adds, []);
@@ -86,7 +86,7 @@
         right: {start: 128, end: 130},
       });
 
-      var pairs = group.getSideBySidePairs();
+      let pairs = group.getSideBySidePairs();
       assert.deepEqual(pairs, [
         {left: l1, right: l1},
         {left: l2, right: l2},
@@ -106,12 +106,12 @@
       ]);
     });
 
-    test('adding delta lines to non-delta group', function() {
-      var l1 = new GrDiffLine(GrDiffLine.Type.ADD);
-      var l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
-      var l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
+    test('adding delta lines to non-delta group', () => {
+      const l1 = new GrDiffLine(GrDiffLine.Type.ADD);
+      const l2 = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      const l3 = new GrDiffLine(GrDiffLine.Type.BOTH);
 
-      var group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
+      let group = new GrDiffGroup(GrDiffGroup.Type.BOTH);
       assert.throws(group.addLine.bind(group, l1));
       assert.throws(group.addLine.bind(group, l2));
       assert.doesNotThrow(group.addLine.bind(group, l3));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
index 2a5913c..8db0c4f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff-line.js
@@ -43,5 +43,4 @@
   GrDiffLine.BLANK_LINE = new GrDiffLine(GrDiffLine.Type.BLANK);
 
   window.GrDiffLine = GrDiffLine;
-
 })(window);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 54e9c6e..b56835f 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -15,6 +15,8 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-diff-builder/gr-diff-builder.html">
@@ -22,21 +24,27 @@
 <link rel="import" href="../gr-diff-highlight/gr-diff-highlight.html">
 <link rel="import" href="../gr-diff-selection/gr-diff-selection.html">
 <link rel="import" href="../gr-syntax-themes/gr-theme-default.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<script src="../../../scripts/hiddenscroll.js"></script>
 
 <dom-module id="gr-diff">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         --light-remove-highlight-color: #fee;
         --dark-remove-highlight-color: rgba(255, 0, 0, 0.15);
         --light-add-highlight-color: #efe;
         --dark-add-highlight-color: rgba(0, 255, 0, 0.15);
-
+        --light-rebased-remove-highlight-color: #fff6ea;
+        --dark-rebased-remove-highlight-color: rgba(255, 139, 6, 0.15);
+        --light-rebased-add-highlight-color: #edfffa;
+        --dark-rebased-add-highlight-color: rgba(11, 255, 155, 0.15);
       }
-      :host.no-left .sideBySide ::content .left,
-      :host.no-left .sideBySide ::content .left + td,
-      :host.no-left .sideBySide ::content .right:not([data-value]),
-      :host.no-left .sideBySide ::content .right:not([data-value]) + td {
+      :host(.no-left) .sideBySide ::content .left,
+      :host(.no-left) .sideBySide ::content .left + td,
+      :host(.no-left) .sideBySide ::content .right:not([data-value]),
+      :host(.no-left) .sideBySide ::content .right:not([data-value]) + td {
         display: none;
       }
       .diffContainer {
@@ -44,20 +52,14 @@
         border-top: 1px solid #eee;
         display: flex;
         font: 12px var(--monospace-font-family);
-        overflow-x: auto;
-        will-change: transform;
+      }
+      .diffContainer.hiddenscroll {
+        padding-bottom: .8em;
       }
       table {
         border-collapse: collapse;
         border-right: 1px solid #ddd;
         table-layout: fixed;
-
-        /* Hint GPU acceleration */
-        -webkit-transform: translateZ(0);
-        -moz-transform: translateZ(0);
-        -ms-transform: translateZ(0);
-        -o-transform: translateZ(0);
-        transform: translateZ(0);
       }
       .lineNum {
         background-color: #eee;
@@ -73,6 +75,9 @@
         font-family: var(--font-family);
         font-style: italic;
       }
+      .diff-row {
+        outline: none;
+      }
       .diff-row.target-row.target-side-left .lineNum.left,
       .diff-row.target-row.target-side-right .lineNum.right,
       .diff-row.target-row.unified .lineNum {
@@ -103,10 +108,11 @@
       }
       .contextLineNum:before,
       .lineNum:before {
+        box-sizing: border-box;
         display: inline-block;
         color: #666;
         content: attr(data-value);
-        padding: 0 .75em;
+        padding: 0 .5em;
         text-align: right;
         width: 100%;
       }
@@ -138,8 +144,22 @@
       .content.remove {
         background-color: var(--light-remove-highlight-color);
       }
-      .content .contentText:after {
-        /* Newline, to ensure all lines are one line-height tall. */
+      .dueToRebase .content.add .intraline,
+      .delta.total.dueToRebase .content.add {
+        background-color: var(--dark-rebased-add-highlight-color);
+      }
+      .dueToRebase .content.add {
+        background-color: var(--light-rebased-add-highlight-color);
+      }
+      .dueToRebase .content.remove .intraline,
+      .delta.total.dueToRebase .content.remove {
+        background-color: var(--dark-rebased-remove-highlight-color);
+      }
+      .dueToRebase .content.remove {
+        background-color: var(--light-rebased-remove-highlight-color);
+      }
+      .content .contentText:empty:after {
+        /* Newline, to ensure empty lines are one line-height tall. */
         content: '\A';
       }
       .contextControl {
@@ -154,8 +174,8 @@
       .contextControl td:not(.lineNum) {
         text-align: center;
       }
-      .displayLine .diff-row.target-row {
-        border-bottom: 1px solid #bbb;
+      .displayLine .diff-row.target-row td {
+        box-shadow: inset 0 -1px #bbb;
       }
       .br:after {
         /* Line feed */
@@ -173,29 +193,104 @@
         border-radius: .4em;
         background-color: #FF9AD2;
       }
+      #diffHeader {
+        background-color: #F9F9F9;
+        color: #2A00FF;
+        font-family: var(--monospace-font-family);
+        font-size: var(--font-size, 12px);
+        padding: 0.5em 0 0.5em 4em;
+      }
+      #sizeWarning {
+        display: none;
+        margin: 1em auto;
+        max-width: 60em;
+        text-align: center;
+      }
+      #sizeWarning gr-button {
+        margin: 1em;
+      }
+      #sizeWarning.warn {
+        display: block;
+      }
+      .target-row td.blame {
+        background: #eee;
+      }
+      col.blame {
+        display: none;
+      }
+      td.blame {
+        display: none;
+        font-family: var(--font-family);
+        font-size: var(--font-size, 12px);
+        padding: 0 .5em;
+        white-space: pre;
+      }
+      :host(.showBlame) col.blame {
+        display: table-column;
+      }
+      :host(.showBlame) td.blame {
+        display: table-cell;
+      }
+      td.blame > span {
+        opacity: 0.6;
+      }
+      td.blame > span.startOfRange {
+        opacity: 1;
+      }
+      td.blame .sha {
+        font-family: var(--monospace-font-family);
+      }
+      .full-width td.blame {
+        overflow: hidden;
+        width: 200px;
+      }
     </style>
     <style include="gr-theme-default"></style>
+    <div id="diffHeader" hidden$="[[_computeDiffHeaderHidden(_diffHeaderItems)]]">
+      <template
+          is="dom-repeat"
+          items="[[_diffHeaderItems]]">
+        <div>[[item]]</div>
+      </template>
+    </div>
     <div class$="[[_computeContainerClass(_loggedIn, viewMode, displayLine)]]"
         on-tap="_handleTap">
       <gr-diff-selection diff="[[_diff]]">
         <gr-diff-highlight
             id="highlights"
             logged-in="[[_loggedIn]]"
-            comments="{{_comments}}">
+            comments="{{comments}}">
           <gr-diff-builder
               id="diffBuilder"
-              comments="[[_comments]]"
+              comments="[[comments]]"
+              project-name="[[projectName]]"
               diff="[[_diff]]"
+              diff-path="[[path]]"
               view-mode="[[viewMode]]"
               line-wrapping="[[lineWrapping]]"
               is-image-diff="[[isImageDiff]]"
               base-image="[[_baseImage]]"
               revision-image="[[_revisionImage]]">
-            <table id="diffTable" class$="[[_diffTableClass]]"></table>
+            <table
+                id="diffTable"
+                class$="[[_diffTableClass]]"
+                role="presentation"></table>
           </gr-diff-builder>
         </gr-diff-highlight>
       </gr-diff-selection>
     </div>
+    <div id="sizeWarning" class$="[[_computeWarningClass(_showWarning)]]">
+      <p>
+        Prevented render because "Whole file" is enabled and this diff is very
+        large (about [[_diffLength(_diff)]] lines).
+      </p>
+      <gr-button on-tap="_handleLimitedBypass">
+        Render with limited context
+      </gr-button>
+      <gr-button on-tap="_handleFullBypass">
+        Render anyway (may be slow)
+      </gr-button>
+    </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-diff-line.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index 05a7f72..c3add28 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -14,16 +14,24 @@
 (function() {
   'use strict';
 
-  var DiffViewMode = {
+  const ERR_COMMENT_ON_EDIT = 'You cannot comment on an edit.';
+  const ERR_INVALID_LINE = 'Invalid line number: ';
+  const MSG_EMPTY_BLAME = 'No blame information for this diff.';
+
+  const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
   };
 
-  var DiffSide = {
+  const DiffSide = {
     LEFT: 'left',
     RIGHT: 'right',
   };
 
+  const LARGE_DIFF_THRESHOLD_LINES = 10000;
+  const FULL_CONTEXT = -1;
+  const LIMITED_CONTEXT = 10;
+
   Polymer({
     is: 'gr-diff',
 
@@ -32,6 +40,12 @@
      * @event line-selected
      */
 
+    /**
+     * Fired if being logged in is required.
+     *
+     * @event show-auth-required
+     */
+
     properties: {
       changeNum: String,
       noAutoRender: {
@@ -48,8 +62,7 @@
         type: Object,
         observer: '_projectConfigChanged',
       },
-      project: String,
-      commit: String,
+      projectName: String,
       displayLine: {
         type: Boolean,
         value: false,
@@ -61,17 +74,15 @@
       },
       filesWeblinks: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
         notify: true,
       },
       hidden: {
         type: Boolean,
         reflectToAttribute: true,
       },
-      _loggedIn: {
-        type: Boolean,
-        value: false,
-      },
+      noRenderOnPrefsChange: Boolean,
+      comments: Object,
       lineWrapping: {
         type: Boolean,
         value: false,
@@ -82,16 +93,56 @@
         value: DiffViewMode.SIDE_BY_SIDE,
         observer: '_viewModeObserver',
       },
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+      },
       _diff: Object,
+      _diffHeaderItems: {
+        type: Array,
+        value: [],
+        computed: '_computeDiffHeaderItems(_diff.*)',
+      },
       _diffTableClass: {
         type: String,
         value: '',
       },
-      _comments: Object,
+      /** @type {?Object} */
       _baseImage: Object,
+      /** @type {?Object} */
       _revisionImage: Object,
+
+      /**
+       * Whether the safety check for large diffs when whole-file is set has
+       * been bypassed. If the value is null, then the safety has not been
+       * bypassed. If the value is a number, then that number represents the
+       * context preference to use when rendering the bypassed diff.
+       *
+       * @type (number|null)
+       */
+      _safetyBypass: {
+        type: Number,
+        value: null,
+      },
+
+      _showWarning: Boolean,
+
+      /** @type {?Object} */
+      _blame: {
+        type: Object,
+        value: null,
+      },
+      isBlameLoaded: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeIsBlameLoaded(_blame)',
+      },
     },
 
+    behaviors: [
+      Gerrit.PatchSetBehavior,
+    ],
+
     listeners: {
       'thread-discard': '_handleThreadDiscard',
       'comment-discard': '_handleCommentDiscard',
@@ -100,42 +151,43 @@
       'create-comment': '_handleCreateComment',
     },
 
-    attached: function() {
-      this._getLoggedIn().then(function(loggedIn) {
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
         this._loggedIn = loggedIn;
-      }.bind(this));
-
+      });
     },
 
-    ready: function() {
+    ready() {
       if (this._canRender()) {
         this.reload();
       }
     },
 
-    reload: function() {
+    /** @return {!Promise} */
+    reload() {
+      this.$.diffBuilder.cancel();
+      this.clearBlame();
+      this._safetyBypass = null;
+      this._showWarning = false;
       this._clearDiffContent();
 
-      var promises = [];
+      const promises = [];
 
-      promises.push(this._getDiff().then(function(diff) {
+      promises.push(this._getDiff().then(diff => {
         this._diff = diff;
         return this._loadDiffAssets();
-      }.bind(this)));
+      }));
 
-      promises.push(this._getDiffCommentsAndDrafts().then(function(comments) {
-        this._comments = comments;
-      }.bind(this)));
-
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         if (this.prefs) {
           return this._renderDiffTable();
         }
         return Promise.resolve();
-      }.bind(this));
+      });
     },
 
-    getCursorStops: function() {
+    /** @return {!Array<!HTMLElement>} */
+    getCursorStops() {
       if (this.hidden && this.noAutoRender) {
         return [];
       }
@@ -143,43 +195,62 @@
       return Polymer.dom(this.root).querySelectorAll('.diff-row');
     },
 
-    addDraftAtLine: function(el) {
-      this._selectLine(el);
-      this._getLoggedIn().then(function(loggedIn) {
-        if (!loggedIn) { return; }
-
-        var value = el.getAttribute('data-value');
-        if (value === GrDiffLine.FILE) {
-          this._addDraft(el);
-          return;
-        }
-        var lineNum = parseInt(value, 10);
-        if (isNaN(lineNum)) {
-          throw Error('Invalid line number: ' + value);
-        }
-        this._addDraft(el, lineNum);
-      }.bind(this));
-    },
-
-    isRangeSelected: function() {
+    /** @return {boolean} */
+    isRangeSelected() {
       return this.$.highlights.isRangeSelected();
     },
 
-    toggleLeftDiff: function() {
+    toggleLeftDiff() {
       this.toggleClass('no-left');
     },
 
-    _canRender: function() {
-      return this.changeNum && this.patchRange && this.path &&
+    /**
+     * Load and display blame information for the base of the diff.
+     * @return {Promise} A promise that resolves when blame finishes rendering.
+     */
+    loadBlame() {
+      return this.$.restAPI.getBlame(this.changeNum, this.patchRange.patchNum,
+          this.path, true)
+          .then(blame => {
+            if (!blame.length) {
+              this.fire('show-alert', {message: MSG_EMPTY_BLAME});
+              return Promise.reject(MSG_EMPTY_BLAME);
+            }
+
+            this._blame = blame;
+
+            this.$.diffBuilder.setBlame(blame);
+            this.classList.add('showBlame');
+          });
+    },
+
+    _computeIsBlameLoaded(blame) {
+      return !!blame;
+    },
+
+    /**
+     * Unload blame information for the diff.
+     */
+    clearBlame() {
+      this._blame = null;
+      this.$.diffBuilder.setBlame(null);
+      this.classList.remove('showBlame');
+    },
+
+    /** @return {boolean}} */
+    _canRender() {
+      return !!this.changeNum && !!this.patchRange && !!this.path &&
           !this.noAutoRender;
     },
 
-    _getCommentThreads: function() {
+    /** @return {!Array<!HTMLElement>} */
+    _getCommentThreads() {
       return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
     },
 
-    _computeContainerClass: function(loggedIn, viewMode, displayLine) {
-      var classes = ['diffContainer'];
+    /** @return {string} */
+    _computeContainerClass(loggedIn, viewMode, displayLine) {
+      const classes = ['diffContainer'];
       switch (viewMode) {
         case DiffViewMode.UNIFIED:
           classes.push('unified');
@@ -190,6 +261,9 @@
         default:
           throw Error('Invalid view mode: ', viewMode);
       }
+      if (Gerrit.hiddenscroll) {
+        classes.push('hiddenscroll');
+      }
       if (loggedIn) {
         classes.push('canComment');
       }
@@ -199,8 +273,8 @@
       return classes.join(' ');
     },
 
-    _handleTap: function(e) {
-      var el = Polymer.dom(e).rootTarget;
+    _handleTap(e) {
+      const el = Polymer.dom(e).rootTarget;
 
       if (el.classList.contains('showContext')) {
         this.$.diffBuilder.showContext(e.detail.groups, e.detail.section);
@@ -209,75 +283,128 @@
       } else if (el.tagName === 'HL' ||
           el.classList.contains('content') ||
           el.classList.contains('contentText')) {
-        var target = this.$.diffBuilder.getLineElByChild(el);
+        const target = this.$.diffBuilder.getLineElByChild(el);
         if (target) { this._selectLine(target); }
       }
     },
 
-    _selectLine: function(el) {
+    _selectLine(el) {
       this.fire('line-selected', {
         side: el.classList.contains('left') ? DiffSide.LEFT : DiffSide.RIGHT,
         number: el.getAttribute('data-value'),
+        path: this.path,
       });
     },
 
-    _handleCreateComment: function(e) {
-      var range = e.detail.range;
-      var diffSide = e.detail.side;
-      var line = range.endLine;
-      var lineEl = this.$.diffBuilder.getLineElByNumber(line, diffSide);
-      var contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-      var contentEl = contentText.parentElement;
-      var patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
-      var isOnParent =
-          this._getIsParentCommentByLineAndContent(lineEl, contentEl);
-      var threadEl = this._getOrCreateThreadAtLineRange(contentEl, patchNum,
-          diffSide, isOnParent, range);
+    addDraftAtLine(el) {
+      this._selectLine(el);
+      this._isValidElForComment(el).then(valid => {
+        if (!valid) { return; }
 
-      threadEl.addOrEditDraft(line, range);
+        const value = el.getAttribute('data-value');
+        let lineNum;
+        if (value !== GrDiffLine.FILE) {
+          lineNum = parseInt(value, 10);
+          if (isNaN(lineNum)) {
+            this.fire('show-alert', {message: ERR_INVALID_LINE + value});
+            return;
+          }
+        }
+        this._createComment(el, lineNum);
+      });
     },
 
-    _addDraft: function(lineEl, opt_lineNum) {
-      var contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
-      var contentEl = contentText.parentElement;
-      var patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
-      var commentSide = this._getCommentSideByLineAndContent(lineEl, contentEl);
-      var isOnParent =
-          this._getIsParentCommentByLineAndContent(lineEl, contentEl);
-      var threadEl = this._getOrCreateThreadAtLineRange(contentEl, patchNum,
-          commentSide, isOnParent);
+    _handleCreateComment(e) {
+      const range = e.detail.range;
+      const side = e.detail.side;
+      const lineNum = range.endLine;
+      const lineEl = this.$.diffBuilder.getLineElByNumber(lineNum, side);
+      this._isValidElForComment(lineEl).then(valid => {
+        if (!valid) { return; }
 
-      threadEl.addOrEditDraft(opt_lineNum);
+        this._createComment(lineEl, lineNum, side, range);
+      });
     },
 
-    _getThreadForRange: function(threadGroupEl, rangeToCheck) {
+    _isValidElForComment(el) {
+      return this._getLoggedIn().then(loggedIn => {
+        if (!loggedIn) {
+          this.fire('show-auth-required');
+          return false;
+        }
+        const patchNum = el.classList.contains(DiffSide.LEFT) ?
+            this.patchRange.basePatchNum :
+            this.patchRange.patchNum;
+
+        if (this.patchNumEquals(patchNum, this.EDIT_NAME)) {
+          this.fire('show-alert', {message: ERR_COMMENT_ON_EDIT});
+          return false;
+        }
+        return true;
+      });
+    },
+
+    /**
+     * @param {!Object} lineEl
+     * @param {number=} opt_lineNum
+     * @param {string=} opt_side
+     * @param {!Object=} opt_range
+     */
+    _createComment(lineEl, opt_lineNum, opt_side, opt_range) {
+      const contentText = this.$.diffBuilder.getContentByLineEl(lineEl);
+      const contentEl = contentText.parentElement;
+      const side = opt_side ||
+          this._getCommentSideByLineAndContent(lineEl, contentEl);
+      const patchNum = this._getPatchNumByLineAndContent(lineEl, contentEl);
+      const isOnParent =
+        this._getIsParentCommentByLineAndContent(lineEl, contentEl);
+      const threadEl = this._getOrCreateThreadAtLineRange(contentEl, patchNum,
+          side, isOnParent, opt_range);
+      threadEl.addOrEditDraft(opt_lineNum, opt_range);
+    },
+
+    _getThreadForRange(threadGroupEl, rangeToCheck) {
       return threadGroupEl.getThreadForRange(rangeToCheck);
     },
 
-    _getThreadGroupForLine: function(contentEl) {
+    _getThreadGroupForLine(contentEl) {
       return contentEl.querySelector('gr-diff-comment-thread-group');
     },
 
-    _getOrCreateThreadAtLineRange:
-        function(contentEl, patchNum, commentSide, isOnParent, range) {
-      var rangeToCheck = range ?
-          'range-' +
-          range.startLine + '-' +
-          range.startChar + '-' +
-          range.endLine + '-' +
-          range.endChar + '-' +
-          commentSide : 'line-' + commentSide;
+    /**
+     * @param {string} commentSide
+     * @param {!Object=} opt_range
+     */
+    _getRangeString(commentSide, opt_range) {
+      return opt_range ?
+        'range-' +
+        opt_range.startLine + '-' +
+        opt_range.startChar + '-' +
+        opt_range.endLine + '-' +
+        opt_range.endChar + '-' +
+        commentSide : 'line-' + commentSide;
+    },
+
+    /**
+     * @param {!Object} contentEl
+     * @param {number} patchNum
+     * @param {string} commentSide
+     * @param {boolean} isOnParent
+     * @param {!Object=} opt_range
+     */
+    _getOrCreateThreadAtLineRange(contentEl, patchNum, commentSide,
+        isOnParent, opt_range) {
+      const rangeToCheck = this._getRangeString(commentSide, opt_range);
 
       // Check if thread group exists.
-      var threadGroupEl = this._getThreadGroupForLine(contentEl);
+      let threadGroupEl = this._getThreadGroupForLine(contentEl);
       if (!threadGroupEl) {
         threadGroupEl = this.$.diffBuilder.createCommentThreadGroup(
-          this.changeNum, patchNum, this.path, isOnParent,
-          this.projectConfig);
+            this.changeNum, patchNum, this.path, isOnParent);
         contentEl.appendChild(threadGroupEl);
       }
 
-      var threadEl = this._getThreadForRange(threadGroupEl, rangeToCheck);
+      let threadEl = this._getThreadForRange(threadGroupEl, rangeToCheck);
 
       if (!threadEl) {
         threadGroupEl.addNewThread(rangeToCheck, commentSide);
@@ -288,8 +415,9 @@
       return threadEl;
     },
 
-    _getPatchNumByLineAndContent: function(lineEl, contentEl) {
-      var patchNum = this.patchRange.patchNum;
+    /** @return {number} */
+    _getPatchNumByLineAndContent(lineEl, contentEl) {
+      let patchNum = this.patchRange.patchNum;
       if ((lineEl.classList.contains(DiffSide.LEFT) ||
           contentEl.classList.contains('remove')) &&
           this.patchRange.basePatchNum !== 'PARENT') {
@@ -298,8 +426,9 @@
       return patchNum;
     },
 
-    _getIsParentCommentByLineAndContent: function(lineEl, contentEl) {
-      var isOnParent = false;
+    /** @return {boolean} */
+    _getIsParentCommentByLineAndContent(lineEl, contentEl) {
+      let isOnParent = false;
       if ((lineEl.classList.contains(DiffSide.LEFT) ||
           contentEl.classList.contains('remove')) &&
           this.patchRange.basePatchNum === 'PARENT') {
@@ -308,8 +437,9 @@
       return isOnParent;
     },
 
-    _getCommentSideByLineAndContent: function(lineEl, contentEl) {
-      var side = 'right';
+    /** @return {string} */
+    _getCommentSideByLineAndContent(lineEl, contentEl) {
+      let side = 'right';
       if (lineEl.classList.contains(DiffSide.LEFT) ||
           contentEl.classList.contains('remove')) {
         side = 'left';
@@ -317,75 +447,83 @@
       return side;
     },
 
-    _handleThreadDiscard: function(e) {
-      var el = Polymer.dom(e).rootTarget;
+    _handleThreadDiscard(e) {
+      const el = Polymer.dom(e).rootTarget;
       el.parentNode.removeThread(el.locationRange);
     },
 
-    _handleCommentDiscard: function(e) {
-      var comment = e.detail.comment;
-      this._removeComment(comment, e.detail.patchNum);
+    _handleCommentDiscard(e) {
+      const comment = e.detail.comment;
+      this._removeComment(comment);
     },
 
-    _removeComment: function(comment, opt_patchNum) {
-      var side = comment.__commentSide;
+    _removeComment(comment) {
+      const side = comment.__commentSide;
       this._removeCommentFromSide(comment, side);
     },
 
-    _handleCommentSave: function(e) {
-      var comment = e.detail.comment;
-      var side = e.detail.comment.__commentSide;
-      var idx = this._findDraftIndex(comment, side);
-      this.set(['_comments', side, idx], comment);
+    _handleCommentSave(e) {
+      const comment = e.detail.comment;
+      const side = e.detail.comment.__commentSide;
+      const idx = this._findDraftIndex(comment, side);
+      this.set(['comments', side, idx], comment);
     },
 
-    _handleCommentUpdate: function(e) {
-      var comment = e.detail.comment;
-      var side = e.detail.comment.__commentSide;
-      var idx = this._findCommentIndex(comment, side);
+    /**
+     * Closure annotation for Polymer.prototype.push is off. Submitted PR:
+     * https://github.com/Polymer/polymer/pull/4776
+     * but for not supressing annotations.
+     *
+     * @suppress {checkTypes} */
+    _handleCommentUpdate(e) {
+      const comment = e.detail.comment;
+      const side = e.detail.comment.__commentSide;
+      let idx = this._findCommentIndex(comment, side);
       if (idx === -1) {
         idx = this._findDraftIndex(comment, side);
       }
       if (idx !== -1) { // Update draft or comment.
-        this.set(['_comments', side, idx], comment);
+        this.set(['comments', side, idx], comment);
       } else { // Create new draft.
-        this.push(['_comments', side], comment);
+        this.push(['comments', side], comment);
       }
     },
 
-    _removeCommentFromSide: function(comment, side) {
-      var idx = this._findCommentIndex(comment, side);
+    _removeCommentFromSide(comment, side) {
+      let idx = this._findCommentIndex(comment, side);
       if (idx === -1) {
         idx = this._findDraftIndex(comment, side);
       }
       if (idx !== -1) {
-        this.splice('_comments.' + side, idx, 1);
+        this.splice('comments.' + side, idx, 1);
       }
     },
 
-    _findCommentIndex: function(comment, side) {
-      if (!comment.id || !this._comments[side]) {
+    /** @return {number} */
+    _findCommentIndex(comment, side) {
+      if (!comment.id || !this.comments[side]) {
         return -1;
       }
-      return this._comments[side].findIndex(function(item) {
+      return this.comments[side].findIndex(item => {
         return item.id === comment.id;
       });
     },
 
-    _findDraftIndex: function(comment, side) {
-      if (!comment.__draftID || !this._comments[side]) {
+    /** @return {number} */
+    _findDraftIndex(comment, side) {
+      if (!comment.__draftID || !this.comments[side]) {
         return -1;
       }
-      return this._comments[side].findIndex(function(item) {
+      return this.comments[side].findIndex(item => {
         return item.__draftID === comment.__draftID;
       });
     },
 
-    _prefsObserver: function(newPrefs, oldPrefs) {
+    _prefsObserver(newPrefs, oldPrefs) {
       // Scan the preference objects one level deep to see if they differ.
-      var differ = !oldPrefs;
+      let differ = !oldPrefs;
       if (newPrefs && oldPrefs) {
-        for (var key in newPrefs) {
+        for (const key in newPrefs) {
           if (newPrefs[key] !== oldPrefs[key]) {
             differ = true;
           }
@@ -397,158 +535,118 @@
       }
     },
 
-    _viewModeObserver: function() {
+    _viewModeObserver() {
       this._prefsChanged(this.prefs);
     },
 
-    _lineWrappingObserver: function() {
+    _lineWrappingObserver() {
       this._prefsChanged(this.prefs);
     },
 
-    _prefsChanged: function(prefs) {
+    _prefsChanged(prefs) {
       if (!prefs) { return; }
+
+      this.clearBlame();
+
+      const stylesToUpdate = {};
+
       if (prefs.line_wrapping) {
         this._diffTableClass = 'full-width';
         if (this.viewMode === 'SIDE_BY_SIDE') {
-          this.customStyle['--content-width'] = 'none';
+          stylesToUpdate['--content-width'] = 'none';
         }
       } else {
         this._diffTableClass = '';
-        this.customStyle['--content-width'] = prefs.line_length + 'ch';
+        stylesToUpdate['--content-width'] = prefs.line_length + 'ch';
       }
 
-      if (!!prefs.font_size) {
-        this.customStyle['--font-size'] = prefs.font_size + 'px';
+      if (prefs.font_size) {
+        stylesToUpdate['--font-size'] = prefs.font_size + 'px';
       }
 
-      this.updateStyles();
+      this.updateStyles(stylesToUpdate);
 
-      if (this._diff && this._comments) {
+      if (this._diff && this.comments && !this.noRenderOnPrefsChange) {
         this._renderDiffTable();
       }
     },
 
-    _renderDiffTable: function() {
-      return this.$.diffBuilder.render(this._comments, this.prefs);
+    _renderDiffTable() {
+      if (this.prefs.context === -1 &&
+          this._diffLength(this._diff) >= LARGE_DIFF_THRESHOLD_LINES &&
+          this._safetyBypass === null) {
+        this._showWarning = true;
+        return Promise.resolve();
+      }
+
+      this._showWarning = false;
+      return this.$.diffBuilder.render(this.comments, this._getBypassPrefs());
     },
 
-    _clearDiffContent: function() {
+    /**
+     * Get the preferences object including the safety bypass context (if any).
+     */
+    _getBypassPrefs() {
+      if (this._safetyBypass !== null) {
+        return Object.assign({}, this.prefs, {context: this._safetyBypass});
+      }
+      return this.prefs;
+    },
+
+    _clearDiffContent() {
       this.$.diffTable.innerHTML = null;
     },
 
-    _handleGetDiffError: function(response) {
+    _handleGetDiffError(response) {
       // Loading the diff may respond with 409 if the file is too large. In this
       // case, use a toast error..
       if (response.status === 409) {
-        this.fire('server-error', {response: response});
+        this.fire('server-error', {response});
         return;
       }
-      this.fire('page-error', {response: response});
+      this.fire('page-error', {response});
     },
 
-    _getDiff: function() {
+    /** @return {!Promise<!Object>} */
+    _getDiff() {
       return this.$.restAPI.getDiff(
           this.changeNum,
           this.patchRange.basePatchNum,
           this.patchRange.patchNum,
           this.path,
-          this._handleGetDiffError.bind(this)).then(function(diff) {
+          this._handleGetDiffError.bind(this)).then(diff => {
             this.filesWeblinks = {
               meta_a: diff && diff.meta_a && diff.meta_a.web_links,
               meta_b: diff && diff.meta_b && diff.meta_b.web_links,
             };
             return diff;
-          }.bind(this));
+          });
     },
 
-    _getDiffComments: function() {
-      return this.$.restAPI.getDiffComments(
-          this.changeNum,
-          this.patchRange.basePatchNum,
-          this.patchRange.patchNum,
-          this.path);
-    },
-
-    _getDiffDrafts: function() {
-      return this._getLoggedIn().then(function(loggedIn) {
-        if (!loggedIn) {
-          return Promise.resolve({baseComments: [], comments: []});
-        }
-        return this.$.restAPI.getDiffDrafts(
-            this.changeNum,
-            this.patchRange.basePatchNum,
-            this.patchRange.patchNum,
-            this.path);
-      }.bind(this));
-    },
-
-    _getDiffRobotComments: function() {
-      return this.$.restAPI.getDiffRobotComments(
-          this.changeNum,
-          this.patchRange.basePatchNum,
-          this.patchRange.patchNum,
-          this.path);
-    },
-
-    _getDiffCommentsAndDrafts: function() {
-      var promises = [];
-      promises.push(this._getDiffComments());
-      promises.push(this._getDiffDrafts());
-      promises.push(this._getDiffRobotComments());
-      return Promise.all(promises).then(function(results) {
-        return Promise.resolve({
-          comments: results[0],
-          drafts: results[1],
-          robotComments: results[2],
-        });
-      }).then(this._normalizeDiffCommentsAndDrafts.bind(this));
-    },
-
-    _normalizeDiffCommentsAndDrafts: function(results) {
-      function markAsDraft(d) {
-        d.__draft = true;
-        return d;
-      }
-      var baseDrafts = results.drafts.baseComments.map(markAsDraft);
-      var drafts = results.drafts.comments.map(markAsDraft);
-
-      var baseRobotComments = results.robotComments.baseComments;
-      var robotComments = results.robotComments.comments;
-      return Promise.resolve({
-        meta: {
-          path: this.path,
-          changeNum: this.changeNum,
-          patchRange: this.patchRange,
-          projectConfig: this.projectConfig,
-        },
-        left: results.comments.baseComments.concat(baseDrafts)
-            .concat(baseRobotComments),
-        right: results.comments.comments.concat(drafts)
-            .concat(robotComments),
-      });
-    },
-
-    _getLoggedIn: function() {
+    /** @return {!Promise} */
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _computeIsImageDiff: function() {
+    /** @return {boolean} */
+    _computeIsImageDiff() {
       if (!this._diff) { return false; }
 
-      var isA = this._diff.meta_a &&
-          this._diff.meta_a.content_type.indexOf('image/') === 0;
-      var isB = this._diff.meta_b &&
-          this._diff.meta_b.content_type.indexOf('image/') === 0;
+      const isA = this._diff.meta_a &&
+          this._diff.meta_a.content_type.startsWith('image/');
+      const isB = this._diff.meta_b &&
+          this._diff.meta_b.content_type.startsWith('image/');
 
-      return this._diff.binary && (isA || isB);
+      return !!(this._diff.binary && (isA || isB));
     },
 
-    _loadDiffAssets: function() {
+    /** @return {!Promise} */
+    _loadDiffAssets() {
       if (this.isImageDiff) {
-        return this._getImages().then(function(images) {
+        return this._getImages().then(images => {
           this._baseImage = images.baseImage;
           this._revisionImage = images.revisionImage;
-        }.bind(this));
+        });
       } else {
         this._baseImage = null;
         this._revisionImage = null;
@@ -556,16 +654,68 @@
       }
     },
 
-    _getImages: function() {
-      return this.$.restAPI.getImagesForDiff(this.project, this.commit,
-          this.changeNum, this._diff, this.patchRange);
+    /** @return {!Promise} */
+    _getImages() {
+      return this.$.restAPI.getImagesForDiff(this.changeNum, this._diff,
+          this.patchRange);
     },
 
-    _projectConfigChanged: function(projectConfig) {
-      var threadEls = this._getCommentThreads();
-      for (var i = 0; i < threadEls.length; i++) {
+    _projectConfigChanged(projectConfig) {
+      const threadEls = this._getCommentThreads();
+      for (let i = 0; i < threadEls.length; i++) {
         threadEls[i].projectConfig = projectConfig;
       }
     },
+
+    /** @return {!Array} */
+    _computeDiffHeaderItems(diffInfoRecord) {
+      const diffInfo = diffInfoRecord.base;
+      if (!diffInfo || !diffInfo.diff_header || diffInfo.binary) { return []; }
+      return diffInfo.diff_header.filter(item => {
+        return !(item.startsWith('diff --git ') ||
+            item.startsWith('index ') ||
+            item.startsWith('+++ ') ||
+            item.startsWith('--- '));
+      });
+    },
+
+    /** @return {boolean} */
+    _computeDiffHeaderHidden(items) {
+      return items.length === 0;
+    },
+
+    /**
+     * The number of lines in the diff. For delta chunks that are different
+     * sizes on the left and the right, the longer side is used.
+     * @param {!Object} diff
+     * @return {number}
+     */
+    _diffLength(diff) {
+      return diff.content.reduce((sum, sec) => {
+        if (sec.hasOwnProperty('ab')) {
+          return sum + sec.ab.length;
+        } else {
+          return sum + Math.max(
+              sec.hasOwnProperty('a') ? sec.a.length : 0,
+              sec.hasOwnProperty('b') ? sec.b.length : 0
+          );
+        }
+      }, 0);
+    },
+
+    _handleFullBypass() {
+      this._safetyBypass = FULL_CONTEXT;
+      this._renderDiffTable();
+    },
+
+    _handleLimitedBypass() {
+      this._safetyBypass = LIMITED_CONTEXT;
+      this._renderDiffTable();
+    },
+
+    /** @return {string} */
+    _computeWarningClass(showWarning) {
+      return showWarning ? 'warn' : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index 34d6de21c..e422354 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-diff.html">
 
@@ -35,72 +35,76 @@
 </test-fixture>
 
 <script>
-  suite('gr-diff tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-diff tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    suite('not logged in', function() {
-      setup(function() {
+    test('reload cancels before network resolves', () => {
+      element = fixture('basic');
+      const cancelStub = sandbox.stub(element.$.diffBuilder, 'cancel');
+
+      // Stub the network calls into requests that never resolve.
+      sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
+
+      element.reload();
+      assert.isTrue(cancelStub.called);
+    });
+
+    test('_diffLength', () => {
+      element = fixture('basic');
+      const mock = document.createElement('mock-diff-response');
+      assert.equal(element._diffLength(mock.diffResponse), 52);
+    });
+
+    suite('not logged in', () => {
+      setup(() => {
         stub('gr-rest-api-interface', {
-          getLoggedIn: function() { return Promise.resolve(false); },
+          getLoggedIn() { return Promise.resolve(false); },
         });
         element = fixture('basic');
       });
 
-      test('toggleLeftDiff', function() {
+      test('toggleLeftDiff', () => {
         element.toggleLeftDiff();
         assert.isTrue(element.classList.contains('no-left'));
         element.toggleLeftDiff();
         assert.isFalse(element.classList.contains('no-left'));
       });
 
-      test('view does not start with displayLine classList', function() {
+      test('addDraftAtLine', done => {
+        sandbox.stub(element, '_selectLine');
+        const loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
+        element.addDraftAtLine();
+        flush(() => {
+          assert.isTrue(loggedInErrorSpy.called);
+          done();
+        });
+      });
+
+      test('view does not start with displayLine classList', () => {
         assert.isFalse(
             element.$$('.diffContainer').classList.contains('displayLine'));
       });
 
-      test('displayLine class added called when displayLine is true',
-          function() {
-        var spy = sandbox.spy(element, '_computeContainerClass');
+      test('displayLine class added called when displayLine is true', () => {
+        const spy = sandbox.spy(element, '_computeContainerClass');
         element.displayLine = true;
         assert.isTrue(spy.called);
         assert.isTrue(
             element.$$('.diffContainer').classList.contains('displayLine'));
       });
 
-      test('get drafts', function(done) {
-        element.patchRange = {basePatchNum: 0, patchNum: 0};
-
-        var getDraftsStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts');
-        element._getDiffDrafts().then(function(result) {
-          assert.deepEqual(result, {baseComments: [], comments: []});
-          sinon.assert.notCalled(getDraftsStub);
-          done();
-        });
-      });
-
-      test('get robot comments', function(done) {
-        element.patchRange = {basePatchNum: 0, patchNum: 0};
-
-        var getDraftsStub = sandbox.stub(element.$.restAPI,
-            'getDiffRobotComments');
-        element._getDiffDrafts().then(function(result) {
-          assert.deepEqual(result, {baseComments: [], comments: []});
-          sinon.assert.notCalled(getDraftsStub);
-          done();
-        });
-      });
-
-      test('loads files weblinks', function(done) {
-        var diffStub = sandbox.stub(element.$.restAPI, 'getDiff').returns(
+      test('loads files weblinks', done => {
+        sandbox.stub(element.$.restAPI, 'getDiff').returns(
             Promise.resolve({
               meta_a: {
                 web_links: 'foo',
@@ -110,7 +114,7 @@
               },
             }));
         element.patchRange = {};
-        element._getDiff().then(function() {
+        element._getDiff().then(() => {
           assert.deepEqual(element.filesWeblinks, {
             meta_a: 'foo',
             meta_b: 'bar',
@@ -119,8 +123,8 @@
         });
       });
 
-      test('remove comment', function() {
-        element._comments = {
+      test('remove comment', () => {
+        element.comments = {
           meta: {
             changeNum: '42',
             patchRange: {
@@ -147,7 +151,7 @@
         element._removeComment({});
         // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
         // to believe that one object deepEquals another even when they do :-/.
-        assert.equal(JSON.stringify(element._comments), JSON.stringify({
+        assert.equal(JSON.stringify(element.comments), JSON.stringify({
           meta: {
             changeNum: '42',
             patchRange: {
@@ -172,8 +176,8 @@
         }));
 
         element._removeComment({id: 'bc2', side: 'PARENT',
-            __commentSide: 'left'});
-        assert.deepEqual(element._comments, {
+          __commentSide: 'left'});
+        assert.deepEqual(element.comments, {
           meta: {
             changeNum: '42',
             patchRange: {
@@ -197,7 +201,7 @@
         });
 
         element._removeComment({id: 'd2', __commentSide: 'right'});
-        assert.deepEqual(element._comments, {
+        assert.deepEqual(element.comments, {
           meta: {
             changeNum: '42',
             patchRange: {
@@ -220,13 +224,26 @@
         });
       });
 
-      test('thread groups', function() {
-        var contentEl = document.createElement('div');
-        var rangeToCheck = 'line-left';
-        var commentSide = 'left';
-        var patchNum = 1;
-        var side = 'PARENT';
-        var range = {
+      test('_getRangeString', () => {
+        const side = 'PARENT';
+        const range = {
+          startLine: 1,
+          startChar: 1,
+          endLine: 1,
+          endChar: 2,
+        };
+        assert.equal(element._getRangeString(side, range),
+            'range-1-1-1-2-PARENT');
+        assert.equal(element._getRangeString(side, null),
+            'line-PARENT');
+      }),
+
+      test('thread groups', () => {
+        const contentEl = document.createElement('div');
+        const commentSide = 'left';
+        const patchNum = 1;
+        const side = 'PARENT';
+        let range = {
           startLine: 1,
           startChar: 1,
           endLine: 1,
@@ -237,10 +254,9 @@
         element.patchRange = {basePatchNum: 1, patchNum: 2};
         element.path = 'file.txt';
 
-        sandbox.stub(element.$.diffBuilder, 'createCommentThreadGroup',
-            function() {
-          var threadGroup =
-              document.createElement('gr-diff-comment-thread-group');
+        sandbox.stub(element.$.diffBuilder, 'createCommentThreadGroup', () => {
+          const threadGroup =
+          document.createElement('gr-diff-comment-thread-group');
           threadGroup.patchForNewThreads = 1;
           return threadGroup;
         });
@@ -268,116 +284,351 @@
         assert.equal(contentEl.querySelectorAll(
             'gr-diff-comment-thread-group').length, 1);
 
-        var threadGroup = contentEl.querySelector(
+        const threadGroup = contentEl.querySelector(
             'gr-diff-comment-thread-group');
-        var threadLength = Polymer.dom(threadGroup.root).
+        const threadLength = Polymer.dom(threadGroup.root).
               querySelectorAll('gr-diff-comment-thread').length;
         assert.equal(threadLength, 2);
       });
 
-      test('renders image diffs', function(done) {
-        var mockDiff = {
-          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
-          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 560},
-          intraline_status: 'OK',
-          change_type: 'MODIFIED',
-          diff_header: [
-            'diff --git a/carrot.jpg b/carrot.jpg',
-            'index 2adc47d..f9c2f2c 100644',
-            '--- a/carrot.jpg',
-            '+++ b/carrot.jpg',
-            'Binary files differ',
-          ],
-          content: [{skip: 66}],
-          binary: true,
-        };
-        var mockFile1 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAEwsA' +
-              'AAAAAAAAAAAAAAAA/w==',
-          type: 'image/bmp',
-        };
-        var mockFile2 = {
-          body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAEwsA' +
-              'AAAAAAAAAAAA/////w==',
-          type: 'image/bmp'
-        };
-        var mockCommit = {
-          commit: '9a1a1d10baece5efbba10bc4ccf808a67a50ac0a',
-          parents: [{
-            commit: '7338aa9adfe57909f1fdaf88975cdea467d3382f',
-            subject: 'Added a carrot',
-          }],
-          author: {
-            name: 'Wyatt Allen',
-            email: 'wyatta@google.com',
-            date: '2016-05-23 21:44:51.000000000',
-            tz: -420,
-          },
-          committer: {
-            name: 'Wyatt Allen',
-            email: 'wyatta@google.com',
-            date: '2016-05-25 00:25:41.000000000',
-            tz: -420,
-          },
-          subject: 'Updated the carrot',
-          message: 'Updated the carrot\n\nChange-Id: Iabcd123\n',
-        };
-        var mockComments = {baseComments: [], comments: []};
+      suite('image diffs', () => {
+        let mockFile1;
+        let mockFile2;
+        const stubs = [];
+        setup(() => {
+          mockFile1 = {
+            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAAAAAA/w==',
+            type: 'image/bmp',
+          };
+          mockFile2 = {
+            body: 'Qk06AAAAAAAAADYAAAAoAAAAAQAAAP////8BACAAAAAAAAAAAAATCwAAE' +
+            'wsAAAAAAAAAAAAA/////w==',
+            type: 'image/bmp',
+          };
+          const mockCommit = {
+            commit: '9a1a1d10baece5efbba10bc4ccf808a67a50ac0a',
+            parents: [{
+              commit: '7338aa9adfe57909f1fdaf88975cdea467d3382f',
+              subject: 'Added a carrot',
+            }],
+            author: {
+              name: 'Wyatt Allen',
+              email: 'wyatta@google.com',
+              date: '2016-05-23 21:44:51.000000000',
+              tz: -420,
+            },
+            committer: {
+              name: 'Wyatt Allen',
+              email: 'wyatta@google.com',
+              date: '2016-05-25 00:25:41.000000000',
+              tz: -420,
+            },
+            subject: 'Updated the carrot',
+            message: 'Updated the carrot\n\nChange-Id: Iabcd123\n',
+          };
+          const mockComments = {baseComments: [], comments: []};
 
-        var stubs = [];
-        stubs.push(sandbox.stub(element, '_getDiff',
-            function() { return Promise.resolve(mockDiff); }));
-        stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo',
-            function() { return Promise.resolve(mockCommit); }));
-        stubs.push(sandbox.stub(element.$.restAPI,
-            'getCommitFileContents',
-            function() { return Promise.resolve(mockFile1); }));
-        stubs.push(sandbox.stub(element.$.restAPI,
-            'getChangeFileContents',
-            function() { return Promise.resolve(mockFile2); }));
-        stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments',
-            function() { return Promise.resolve(mockComments); }));
-        stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts',
-            function() { return Promise.resolve(mockComments); }));
+          stubs.push(sandbox.stub(element.$.restAPI, 'getCommitInfo',
+              () => Promise.resolve(mockCommit)));
+          stubs.push(sandbox.stub(element.$.restAPI,
+              'getChangeFileContents',
+              (changeId, patchNum, path, opt_parentIndex) => {
+                return Promise.resolve(opt_parentIndex === 1 ? mockFile1 :
+                    mockFile2);
+              }));
+          stubs.push(sandbox.stub(element.$.restAPI, '_getDiffComments',
+              () => Promise.resolve(mockComments)));
+          stubs.push(sandbox.stub(element.$.restAPI, 'getDiffDrafts',
+              () => Promise.resolve(mockComments)));
 
-        element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+          element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+          element.comments = {left: [], right: []};
+        });
 
-        var rendered = function() {
-          // Recognizes that it should be an image diff.
-          assert.isTrue(element.isImageDiff);
-          assert.instanceOf(element.$.diffBuilder._builder, GrDiffBuilderImage);
+        test('renders image diffs with same file name', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              () => Promise.resolve(mockDiff)));
 
-          // Left image rendered with the parent commit's version of the file.
-          var leftInmage = element.$.diffTable.querySelector('td.left img');
-          assert.isOk(leftInmage);
-          assert.equal(leftInmage.getAttribute('src'),
-              'data:image/bmp;base64, ' + mockFile1.body);
+          const rendered = () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
 
-          // Right image rendered with this change's revision of the image.
-          var rightInmage = element.$.diffTable.querySelector('td.right img');
-          assert.isOk(rightInmage);
-          assert.equal(rightInmage.getAttribute('src'),
-              'data:image/bmp;base64, ' + mockFile2.body);
+            // Left image rendered with the parent commit's version of the file.
+            const leftImage = element.$.diffTable.querySelector('td.left img');
+            const leftLabel =
+                element.$.diffTable.querySelector('td.left label');
+            const leftLabelContent = leftLabel.querySelector('.label');
+            const leftLabelName = leftLabel.querySelector('.name');
 
-          // Cleanup.
-          element.removeEventListener('render', rendered);
+            const rightImage =
+                element.$.diffTable.querySelector('td.right img');
+            const rightLabel = element.$.diffTable.querySelector(
+                'td.right label');
+            const rightLabelContent = rightLabel.querySelector('.label');
+            const rightLabelName = rightLabel.querySelector('.name');
 
-          done();
-        };
+            assert.isNotOk(rightLabelName);
+            assert.isNotOk(leftLabelName);
 
-        element.addEventListener('render', rendered);
+            let leftLoaded = false;
+            let rightLoaded = false;
 
-        element.$.restAPI.getDiffPreferences().then(function(prefs) {
-          element.prefs = prefs;
-          element.reload();
+            leftImage.addEventListener('load', () => {
+              assert.isOk(leftImage);
+              assert.equal(leftImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile1.body);
+              assert.equal(leftLabelContent.textContent, '1⨉1 image/bmp');
+              leftLoaded = true;
+              if (rightLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+            rightImage.addEventListener('load', () => {
+              assert.isOk(rightImage);
+              assert.equal(rightImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile2.body);
+              assert.equal(rightLabelContent.textContent, '1⨉1 image/bmp');
+
+              rightLoaded = true;
+              if (leftLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+          };
+
+          element.addEventListener('render', rendered);
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders image diffs with a different file name', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot2.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot2.jpg',
+              'index 2adc47d..f9c2f2c 100644',
+              '--- a/carrot.jpg',
+              '+++ b/carrot2.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              () => Promise.resolve(mockDiff)));
+
+          const rendered = () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            // Left image rendered with the parent commit's version of the file.
+            const leftImage = element.$.diffTable.querySelector('td.left img');
+            const leftLabel =
+                element.$.diffTable.querySelector('td.left label');
+            const leftLabelContent = leftLabel.querySelector('.label');
+            const leftLabelName = leftLabel.querySelector('.name');
+
+            const rightImage =
+                element.$.diffTable.querySelector('td.right img');
+            const rightLabel = element.$.diffTable.querySelector(
+                'td.right label');
+            const rightLabelContent = rightLabel.querySelector('.label');
+            const rightLabelName = rightLabel.querySelector('.name');
+
+            assert.isOk(rightLabelName);
+            assert.isOk(leftLabelName);
+            assert.equal(leftLabelName.textContent, mockDiff.meta_a.name);
+            assert.equal(rightLabelName.textContent, mockDiff.meta_b.name);
+
+            let leftLoaded = false;
+            let rightLoaded = false;
+
+            leftImage.addEventListener('load', () => {
+              assert.isOk(leftImage);
+              assert.equal(leftImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile1.body);
+              assert.equal(leftLabelContent.textContent, '1⨉1 image/bmp');
+              leftLoaded = true;
+              if (rightLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+
+            rightImage.addEventListener('load', () => {
+              assert.isOk(rightImage);
+              assert.equal(rightImage.getAttribute('src'),
+                  'data:image/bmp;base64, ' + mockFile2.body);
+              assert.equal(rightLabelContent.textContent, '1⨉1 image/bmp');
+
+              rightLoaded = true;
+              if (leftLoaded) {
+                element.removeEventListener('render', rendered);
+                done();
+              }
+            });
+          };
+
+          element.addEventListener('render', rendered);
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders added image', done => {
+          const mockDiff = {
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'ADDED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index 0000000..f9c2f2c 100644',
+              '--- /dev/null',
+              '+++ b/carrot.jpg',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              () => Promise.resolve(mockDiff)));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            const leftImage = element.$.diffTable.querySelector('td.left img');
+            const rightImage = element.$.diffTable.querySelector('td.right img');
+
+            assert.isNotOk(leftImage);
+            assert.isOk(rightImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('renders removed image', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'DELETED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index f9c2f2c..0000000 100644',
+              '--- a/carrot.jpg',
+              '+++ /dev/null',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          stubs.push(sandbox.stub(element, '_getDiff',
+              () => Promise.resolve(mockDiff)));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+
+            const leftImage = element.$.diffTable.querySelector('td.left img');
+            const rightImage = element.$.diffTable.querySelector('td.right img');
+
+            assert.isOk(leftImage);
+            assert.isNotOk(rightImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
+        });
+
+        test('does not render disallowed image type', done => {
+          const mockDiff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg-evil',
+              lines: 560},
+            intraline_status: 'OK',
+            change_type: 'DELETED',
+            diff_header: [
+              'diff --git a/carrot.jpg b/carrot.jpg',
+              'index f9c2f2c..0000000 100644',
+              '--- a/carrot.jpg',
+              '+++ /dev/null',
+              'Binary files differ',
+            ],
+            content: [{skip: 66}],
+            binary: true,
+          };
+          mockFile1.type = 'image/jpeg-evil';
+
+          stubs.push(sandbox.stub(element, '_getDiff',
+              () => Promise.resolve(mockDiff)));
+
+          element.addEventListener('render', () => {
+            // Recognizes that it should be an image diff.
+            assert.isTrue(element.isImageDiff);
+            assert.instanceOf(
+                element.$.diffBuilder._builder, GrDiffBuilderImage);
+            const leftImage = element.$.diffTable.querySelector('td.left img');
+            assert.isNotOk(leftImage);
+            done();
+          });
+
+          element.$.restAPI.getDiffPreferences().then(prefs => {
+            element.prefs = prefs;
+            element.reload();
+          });
         });
       });
 
-      test('_handleTap lineNum', function(done) {
-        var addDraftStub = sinon.stub(element, 'addDraftAtLine');
-        var el = document.createElement('div');
+      test('_handleTap lineNum', done => {
+        const addDraftStub = sandbox.stub(element, 'addDraftAtLine');
+        const el = document.createElement('div');
         el.className = 'lineNum';
-        el.addEventListener('click', function(e) {
+        el.addEventListener('click', e => {
           element._handleTap(e);
           assert.isTrue(addDraftStub.called);
           assert.equal(addDraftStub.lastCall.args[0], el);
@@ -386,11 +637,12 @@
         el.click();
       });
 
-      test('_handleTap context', function(done) {
-        var showContextStub = sinon.stub(element.$.diffBuilder, 'showContext');
-        var el = document.createElement('div');
+      test('_handleTap context', done => {
+        const showContextStub =
+            sandbox.stub(element.$.diffBuilder, 'showContext');
+        const el = document.createElement('div');
         el.className = 'showContext';
-        el.addEventListener('click', function(e) {
+        el.addEventListener('click', e => {
           element._handleTap(e);
           assert.isTrue(showContextStub.called);
           done();
@@ -398,16 +650,15 @@
         el.click();
       });
 
-      test('_handleTap content', function(done) {
-        var content = document.createElement('div');
-        var lineEl = document.createElement('div');
+      test('_handleTap content', done => {
+        const content = document.createElement('div');
+        const lineEl = document.createElement('div');
 
-        var selectStub = sandbox.stub(element, '_selectLine');
-        var getLineStub = sandbox.stub(element.$.diffBuilder,
-            'getLineElByChild', function() { return lineEl; });
+        const selectStub = sandbox.stub(element, '_selectLine');
+        sandbox.stub(element.$.diffBuilder, 'getLineElByChild', () => lineEl);
 
         content.className = 'content';
-        content.addEventListener('click', function(e) {
+        content.addEventListener('click', e => {
           element._handleTap(e);
           assert.isTrue(selectStub.called);
           assert.equal(selectStub.lastCall.args[0], lineEl);
@@ -416,9 +667,9 @@
         content.click();
       });
 
-      test('_getDiff handles null diff responses', function(done) {
+      test('_getDiff handles null diff responses', done => {
         stub('gr-rest-api-interface', {
-          getDiff: function() { return Promise.resolve(null); },
+          getDiff() { return Promise.resolve(null); },
         });
         element.changeNum = 123;
         element.patchRange = {basePatchNum: 1, patchNum: 2};
@@ -426,12 +677,11 @@
         element._getDiff().then(done);
       });
 
-      suite('getCursorStops', function() {
-
-        var setupDiff = function() {
-          var mock = document.createElement('mock-diff-response');
+      suite('getCursorStops', () => {
+        const setupDiff = function() {
+          const mock = document.createElement('mock-diff-response');
           element._diff = mock.diffResponse;
-          element._comments = {
+          element.comments = {
             left: [],
             right: [],
           };
@@ -456,134 +706,89 @@
           flushAsynchronousOperations();
         };
 
-        test('getCursorStops returns [] when hidden and noAutoRender are true',
-             function() {
+        test('getCursorStops returns [] when hidden and noAutoRender', () => {
           element.noAutoRender = true;
           setupDiff();
           element.hidden = true;
           assert.equal(element.getCursorStops().length, 0);
         });
 
-        test('getCursorStops', function() {
+        test('getCursorStops', () => {
           setupDiff();
           assert.equal(element.getCursorStops().length, 50);
         });
       });
+
+      test('adds .hiddenscroll', () => {
+        Gerrit.hiddenscroll = true;
+        element.displayLine = true;
+        assert.include(element.$$('.diffContainer').className, 'hiddenscroll');
+      });
     });
 
-    suite('logged in', function() {
-      setup(function() {
+    suite('logged in', () => {
+      let fakeLineEl;
+      setup(() => {
         stub('gr-rest-api-interface', {
-          getLoggedIn: function() { return Promise.resolve(true); },
-          getPreferences: function() {
+          getLoggedIn() { return Promise.resolve(true); },
+          getPreferences() {
             return Promise.resolve({time_format: 'HHMM_12'});
           },
         });
         element = fixture('basic');
+        element.patchRange = {};
+
+        fakeLineEl = {
+          getAttribute: sandbox.stub().returns(42),
+          classList: {
+            contains: sandbox.stub().returns(true),
+          },
+        };
       });
 
-      test('get drafts', function(done) {
-        element.patchRange = {basePatchNum: 0, patchNum: 0};
-        var draftsResponse = {
-          baseComments: [{id: 'foo'}],
-          comments: [{id: 'bar'}],
-        };
-        var getDraftsStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts',
-            function() { return Promise.resolve(draftsResponse); });
-        element._getDiffDrafts().then(function(result) {
-          assert.deepEqual(result, draftsResponse);
+      test('addDraftAtLine', done => {
+        sandbox.stub(element, '_selectLine');
+        sandbox.stub(element, '_createComment');
+        const loggedInErrorSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
+        element.addDraftAtLine(fakeLineEl);
+        flush(() => {
+          assert.isFalse(loggedInErrorSpy.called);
+          assert.isTrue(element._createComment
+              .calledWithExactly(fakeLineEl, 42));
           done();
         });
       });
 
-      test('get comments and drafts', function(done) {
-        var comments = {
-          baseComments: [
-            {id: 'bc1', __commentSide: 'left'},
-            {id: 'bc2', __commentSide: 'left'},
-          ],
-          comments: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-          ],
-        };
-        var diffCommentsStub = sandbox.stub(element, '_getDiffComments',
-            function() { return Promise.resolve(comments); });
-
-        var drafts = {
-          baseComments: [
-            {id: 'bd1', __commentSide: 'left'},
-            {id: 'bd2', __commentSide: 'left'},
-          ],
-          comments: [
-            {id: 'd1', __commentSide: 'right'},
-            {id: 'd2', __commentSide: 'right'},
-          ],
-        };
-
-        var diffDraftsStub = sandbox.stub(element, '_getDiffDrafts',
-            function() { return Promise.resolve(drafts); });
-
-        var robotComments = {
-          baseComments: [
-            {id: 'br1', __commentSide: 'left'},
-            {id: 'br2', __commentSide: 'left'},
-          ],
-          comments: [
-            {id: 'r1', __commentSide: 'right'},
-            {id: 'r2', __commentSide: 'right'},
-          ],
-        };
-
-        var diffRobotCommentStub = sandbox.stub(element,
-            '_getDiffRobotComments', function() {
-          return Promise.resolve(robotComments); });
-
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        };
-        element.path = '/path/to/foo';
-        element.projectConfig = {foo: 'bar'};
-
-        element._getDiffCommentsAndDrafts().then(function(result) {
-          assert.deepEqual(result, {
-            meta: {
-              changeNum: '42',
-              patchRange: {
-                basePatchNum: 'PARENT',
-                patchNum: 3,
-              },
-              path: '/path/to/foo',
-              projectConfig: {foo: 'bar'},
-            },
-            left: [
-              {id: 'bc1', __commentSide: 'left'},
-              {id: 'bc2', __commentSide: 'left'},
-              {id: 'bd1', __draft: true, __commentSide: 'left'},
-              {id: 'bd2', __draft: true, __commentSide: 'left'},
-              {id: 'br1', __commentSide: 'left'},
-              {id: 'br2', __commentSide: 'left'},
-            ],
-            right: [
-              {id: 'c1', __commentSide: 'right'},
-              {id: 'c2', __commentSide: 'right'},
-              {id: 'd1', __draft: true, __commentSide: 'right'},
-              {id: 'd2', __draft: true, __commentSide: 'right'},
-              {id: 'r1', __commentSide: 'right'},
-              {id: 'r2', __commentSide: 'right'},
-            ],
-          });
-
+      test('addDraftAtLine on an edit', done => {
+        element.patchRange.basePatchNum = element.EDIT_NAME;
+        sandbox.stub(element, '_selectLine');
+        sandbox.stub(element, '_createComment');
+        const loggedInErrorSpy = sandbox.spy();
+        const alertSpy = sandbox.spy();
+        element.addEventListener('show-auth-required', loggedInErrorSpy);
+        element.addEventListener('show-alert', alertSpy);
+        element.addDraftAtLine(fakeLineEl);
+        flush(() => {
+          assert.isFalse(loggedInErrorSpy.called);
+          assert.isTrue(alertSpy.called);
+          assert.isFalse(element._createComment.called);
           done();
         });
       });
 
-      suite('handle comment-update', function() {
-
-        setup(function() {
-          element._comments = {
+      suite('change in preferences', () => {
+        setup(() => {
+          element._diff = {
+            meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+            meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+              lines: 560},
+            diff_header: [],
+            intraline_status: 'OK',
+            change_type: 'MODIFIED',
+            content: [{skip: 66}],
+          };
+          element.comments = {
             meta: {
               changeNum: '42',
               patchRange: {
@@ -608,24 +813,70 @@
           };
         });
 
-        test('creating a draft', function() {
-          var comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
-              __commentSide: 'left'};
-          element.fire('comment-update', {comment: comment});
-          assert.include(element._comments.left, comment);
+        test('change in preferences re-renders diff', () => {
+          sandbox.stub(element, '_renderDiffTable');
+          element.prefs = {};
+          element.prefs = {time_format: 'HHMM_12'};
+          assert.isTrue(element._renderDiffTable.called);
         });
 
-        test('saving a draft', function() {
-          var draftID = 'tempID';
-          var id = 'savedID';
-          element._comments.left.push(
-              {__draft: true, __draftID: draftID, side: 'PARENT',
-              __commentSide: 'left'});
-          element.fire('comment-update', {comment:
-              {id: id, __draft: true, __draftID: draftID, side: 'PARENT',
-              __commentSide: 'left'},
-          });
-          var drafts = element._comments.left.filter(function(item) {
+        test('change in preferences does not re-renders diff with ' +
+            'noRenderOnPrefsChange', () => {
+          sandbox.stub(element, '_renderDiffTable');
+          element.noRenderOnPrefsChange = true;
+          element.prefs = {};
+          element.prefs = {time_format: 'HHMM_12'};
+          assert.isFalse(element._renderDiffTable.called);
+        });
+      });
+
+      suite('handle comment-update', () => {
+        setup(() => {
+          element.comments = {
+            meta: {
+              changeNum: '42',
+              patchRange: {
+                basePatchNum: 'PARENT',
+                patchNum: 3,
+              },
+              path: '/path/to/foo',
+              projectConfig: {foo: 'bar'},
+            },
+            left: [
+              {id: 'bc1', side: 'PARENT', __commentSide: 'left'},
+              {id: 'bc2', side: 'PARENT', __commentSide: 'left'},
+              {id: 'bd1', __draft: true, side: 'PARENT', __commentSide: 'left'},
+              {id: 'bd2', __draft: true, side: 'PARENT', __commentSide: 'left'},
+            ],
+            right: [
+              {id: 'c1', __commentSide: 'right'},
+              {id: 'c2', __commentSide: 'right'},
+              {id: 'd1', __draft: true, __commentSide: 'right'},
+              {id: 'd2', __draft: true, __commentSide: 'right'},
+            ],
+          };
+        });
+
+        test('creating a draft', () => {
+          const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
+            __commentSide: 'left'};
+          element.fire('comment-update', {comment});
+          assert.include(element.comments.left, comment);
+        });
+
+        test('saving a draft', () => {
+          const draftID = 'tempID';
+          const id = 'savedID';
+          const comment = {
+            __draft: true,
+            __draftID: draftID,
+            side: 'PARENT',
+            __commentSide: 'left',
+          };
+          element.comments.left.push(comment);
+          comment.id = id;
+          element.fire('comment-update', {comment});
+          const drafts = element.comments.left.filter(item => {
             return item.__draftID === draftID;
           });
           assert.equal(drafts.length, 1);
@@ -633,5 +884,136 @@
         });
       });
     });
+
+    suite('diff header', () => {
+      setup(() => {
+        element._diff = {
+          meta_a: {name: 'carrot.jpg', content_type: 'image/jpeg', lines: 66},
+          meta_b: {name: 'carrot.jpg', content_type: 'image/jpeg',
+            lines: 560},
+          diff_header: [],
+          intraline_status: 'OK',
+          change_type: 'MODIFIED',
+          content: [{skip: 66}],
+        };
+      });
+
+      test('hidden', () => {
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', 'diff --git a/test.jpg b/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', 'index 2adc47d..f9c2f2c 100644');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', '--- a/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', '+++ b/test.jpg');
+        assert.equal(element._diffHeaderItems.length, 0);
+        element.push('_diff.diff_header', 'test');
+        assert.equal(element._diffHeaderItems.length, 1);
+        flushAsynchronousOperations();
+
+        assert.equal(element.$.diffHeader.textContent.trim(), 'test');
+        element.set('_diff.binary', true);
+        assert.equal(element._diffHeaderItems.length, 0);
+      });
+    });
+
+    suite('safety and bypass', () => {
+      let renderStub;
+
+      setup(() => {
+        element = fixture('basic');
+        renderStub = sandbox.stub(element.$.diffBuilder, 'render',
+            () => Promise.resolve());
+        const mock = document.createElement('mock-diff-response');
+        element._diff = mock.diffResponse;
+        element.comments = {left: [], right: []};
+        element.noRenderOnPrefsChange = true;
+      });
+
+      test('lage render w/ context = 10', () => {
+        element.prefs = {context: 10};
+        sandbox.stub(element, '_diffLength', () => 10000);
+        return element._renderDiffTable().then(() => {
+          assert.isTrue(renderStub.called);
+          assert.isFalse(element._showWarning);
+        });
+      });
+
+      test('lage render w/ whole file and bypass', () => {
+        element.prefs = {context: -1};
+        element._safetyBypass = 10;
+        sandbox.stub(element, '_diffLength', () => 10000);
+        return element._renderDiffTable().then(() => {
+          assert.isTrue(renderStub.called);
+          assert.isFalse(element._showWarning);
+        });
+      });
+
+      test('lage render w/ whole file and no bypass', () => {
+        element.prefs = {context: -1};
+        sandbox.stub(element, '_diffLength', () => 10000);
+        return element._renderDiffTable().then(() => {
+          assert.isFalse(renderStub.called);
+          assert.isTrue(element._showWarning);
+        });
+      });
+    });
+
+    suite('blame', () => {
+      setup(() => {
+        element = fixture('basic');
+      });
+
+      test('clearBlame', () => {
+        element._blame = [];
+        const setBlameSpy = sandbox.spy(element.$.diffBuilder, 'setBlame');
+        element.classList.add('showBlame');
+        element.clearBlame();
+        assert.isNull(element._blame);
+        assert.isTrue(setBlameSpy.calledWithExactly(null));
+        assert.isFalse(element.classList.contains('showBlame'));
+      });
+
+      test('loadBlame', () => {
+        const mockBlame = [{id: 'commit id', ranges: [{start: 1, end: 2}]}];
+        const showAlertStub = sinon.stub();
+        element.addEventListener('show-alert', showAlertStub);
+        const getBlameStub = sandbox.stub(element.$.restAPI, 'getBlame')
+            .returns(Promise.resolve(mockBlame));
+        element.changeNum = 42;
+        element.patchRange = {patchNum: 5, basePatchNum: 4};
+        element.path = 'foo/bar.baz';
+        return element.loadBlame().then(() => {
+          assert.isTrue(getBlameStub.calledWithExactly(
+              42, 5, 'foo/bar.baz', true));
+          assert.isFalse(showAlertStub.called);
+          assert.equal(element._blame, mockBlame);
+          assert.isTrue(element.classList.contains('showBlame'));
+        });
+      });
+
+      test('loadBlame empty', () => {
+        const mockBlame = [];
+        const showAlertStub = sinon.stub();
+        element.addEventListener('show-alert', showAlertStub);
+        sandbox.stub(element.$.restAPI, 'getBlame')
+            .returns(Promise.resolve(mockBlame));
+        element.changeNum = 42;
+        element.patchRange = {patchNum: 5, basePatchNum: 4};
+        element.path = 'foo/bar.baz';
+        return element.loadBlame()
+            .then(() => {
+              assert.isTrue(false, 'Promise should not resolve');
+            })
+            .catch(() => {
+              assert.isTrue(showAlertStub.calledOnce);
+              assert.isNull(element._blame);
+              assert.isFalse(element.classList.contains('showBlame'));
+            });
+      });
+    });
   });
+
+  a11ySuite('basic');
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
index 66e6ee7..f532e3f 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.html
@@ -13,45 +13,49 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
 
 <link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-dropdown-list/gr-dropdown-list.html">
+
 <link rel="import" href="../../shared/gr-select/gr-select.html">
 
 <dom-module id="gr-patch-range-select">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
-        display: block;
-      }
-      .patchRange {
-        display: inline-block;
+        align-items: center;
+        display: flex;
       }
       select {
         max-width: 15em;
       }
+      .arrow {
+        color: rgba(0,0,0,.7);
+        margin: 0 .5em;
+      }
+      gr-dropdown-list {
+        --trigger-style: {
+          color: rgba(0,0,0,.7);
+          text-transform: none;
+          font-family: var(--font-family);
+        }
+        --trigger-hover-color: rgba(0,0,0,.6);
+      }
       @media screen and (max-width: 50em) {
         .filesWeblinks {
           display: none;
         }
-        select {
-          max-width: 5.25em;
-        }
       }
     </style>
-    Patch set:
     <span class="patchRange">
-      <select id="leftPatchSelect" bind-value="{{_leftSelected}}"
-          on-change="_handlePatchChange" is="gr-select">
-        <option value="PARENT">Base</option>
-        <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
-          <option value$="[[patchNum]]"
-              disabled$="[[_computeLeftDisabled(patchNum, patchRange)]]">
-            [[patchNum]]
-            [[_computePatchSetDescription(revisions, patchNum)]]
-          </option>
-        </template>
-      </select>
+      <gr-dropdown-list
+          id="basePatchDropdown"
+          value="[[basePatchNum]]"
+          on-value-change="_handlePatchChange"
+          items="[[_baseDropdownContent]]">
+      </gr-dropdown-list>
     </span>
     <span is="dom-if" if="[[filesWeblinks.meta_a]]" class="filesWeblinks">
       <template is="dom-repeat" items="[[filesWeblinks.meta_a]]" as="weblink">
@@ -59,18 +63,14 @@
            href$="[[weblink.url]]">[[weblink.name]]</a>
       </template>
     </span>
-    &rarr;
+    <span class="arrow">&rarr;</span>
     <span class="patchRange">
-      <select id="rightPatchSelect" bind-value="{{_rightSelected}}"
-          on-change="_handlePatchChange" is="gr-select">
-        <template is="dom-repeat" items="{{availablePatches}}" as="patchNum">
-          <option value$="[[patchNum]]"
-              disabled$="[[_computeRightDisabled(patchNum, patchRange)]]">
-            [[patchNum]]
-            [[_computePatchSetDescription(revisions, patchNum)]]
-          </option>
-        </template>
-      </select>
+      <gr-dropdown-list
+          id="patchNumDropdown"
+          value="[[patchNum]]"
+          on-value-change="_handlePatchChange"
+          items="[[_patchDropdownContent]]">
+      </gr-dropdown-list>
       <span is="dom-if" if="[[filesWeblinks.meta_b]]" class="filesWeblinks">
         <template is="dom-repeat" items="[[filesWeblinks.meta_b]]" as="weblink">
           <a target="_blank"
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
index 58d29bd..a8314e7 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select.js
@@ -15,69 +15,214 @@
   'use strict';
 
   // Maximum length for patch set descriptions.
-  var PATCH_DESC_MAX_LENGTH = 500;
+  const PATCH_DESC_MAX_LENGTH = 500;
+
+  /**
+   * Fired when the patch range changes
+   *
+   * @event patch-range-change
+   *
+   * @property {string} patchNum
+   * @property {string} basePatchNum
+   */
 
   Polymer({
     is: 'gr-patch-range-select',
 
     properties: {
       availablePatches: Array,
-      changeNum: String,
-      filesWeblinks: Object,
-      path: String,
-      patchRange: {
+      _baseDropdownContent: {
         type: Object,
-        observer: '_updateSelected',
+        computed: '_computeBaseDropdownContent(availablePatches, patchNum,' +
+            '_sortedRevisions, revisions, comments)',
       },
+      _patchDropdownContent: {
+        type: Object,
+        computed: '_computePatchDropdownContent(availablePatches,' +
+            'basePatchNum, _sortedRevisions, revisions, comments)',
+      },
+      changeNum: String,
+      // In the case of a patch range select (like diff view) comments should
+      // be an empty array, so that the patch and base content computed values
+      // get triggered.
+      comments: {
+        type: Object,
+        value: () => { return {}; },
+      },
+      /** @type {{ meta_a: !Array, meta_b: !Array}} */
+      filesWeblinks: Object,
+      patchNum: String,
+      basePatchNum: String,
       revisions: Object,
-      _rightSelected: String,
-      _leftSelected: String,
+      _sortedRevisions: Array,
     },
 
+    observers: [
+      '_updateSortedRevisions(revisions.*)',
+    ],
+
     behaviors: [Gerrit.PatchSetBehavior],
 
-    _updateSelected: function() {
-      this._rightSelected = this.patchRange.patchNum;
-      this._leftSelected = this.patchRange.basePatchNum;
-    },
-
-    _handlePatchChange: function(e) {
-      var leftPatch = this._leftSelected;
-      var rightPatch = this._rightSelected;
-      var rangeStr = rightPatch;
-      if (leftPatch != 'PARENT') {
-        rangeStr = leftPatch + '..' + rangeStr;
+    _computeBaseDropdownContent(availablePatches, patchNum, _sortedRevisions,
+        revisions, comments) {
+      const dropdownContent = [];
+      dropdownContent.push({
+        text: 'Base',
+        value: 'PARENT',
+      });
+      for (const basePatch of availablePatches) {
+        const basePatchNum = basePatch.num;
+        dropdownContent.push({
+          disabled: this._computeLeftDisabled(
+              basePatch.num, patchNum, _sortedRevisions),
+          triggerText: `Patchset ${basePatchNum}`,
+          text: `Patchset ${basePatchNum}` +
+              this._computePatchSetCommentsString(this.comments, basePatchNum),
+          mobileText: this._computeMobileText(basePatchNum, comments,
+              revisions),
+          bottomText: `${this._computePatchSetDescription(
+              revisions, basePatchNum)}`,
+          value: basePatch.num,
+        });
       }
-      page.show('/c/' + this.changeNum + '/' + rangeStr + '/' + this.path);
-      e.target.blur();
+      return dropdownContent;
     },
 
-    _computeLeftDisabled: function(patchNum, patchRange) {
-      return parseInt(patchNum, 10) >= parseInt(patchRange.patchNum, 10);
+    _computeMobileText(patchNum, comments, revisions) {
+      return `${patchNum}` +
+          `${this._computePatchSetCommentsString(this.comments, patchNum)}` +
+          `${this._computePatchSetDescription(revisions, patchNum, true)}`;
     },
 
-    _computeRightDisabled: function(patchNum, patchRange) {
-      if (patchRange.basePatchNum == 'PARENT') { return false; }
-      return parseInt(patchNum, 10) <= parseInt(patchRange.basePatchNum, 10);
+    _computePatchDropdownContent(availablePatches, basePatchNum,
+        _sortedRevisions, revisions, comments) {
+      const dropdownContent = [];
+      for (const patch of availablePatches) {
+        const patchNum = patch.num;
+        dropdownContent.push({
+          disabled: this._computeRightDisabled(patchNum, basePatchNum,
+              _sortedRevisions),
+          triggerText: `${patchNum === 'edit' ? '': 'Patchset '}` +
+              patchNum,
+          text: `${patchNum === 'edit' ? '': 'Patchset '}${patchNum}` +
+              `${this._computePatchSetCommentsString(
+                  this.comments, patchNum)}`,
+          mobileText: this._computeMobileText(patchNum, comments, revisions),
+          bottomText: `${this._computePatchSetDescription(
+              revisions, patchNum)}`,
+          value: patchNum,
+        });
+      }
+      return dropdownContent;
     },
 
-    // On page load, the dom-if for options getting added occurs after
-    // the value was set in the select. This ensures that after they
-    // are loaded, the correct value will get selected.  I attempted to
-    // debounce these, but because they are detecting two different
-    // events, sometimes the timing was off and one ended up missing.
-    _synchronizeSelectionRight: function() {
-      this.$.rightPatchSelect.value = this._rightSelected;
+    _updateSortedRevisions(revisionsRecord) {
+      const revisions = revisionsRecord.base;
+      this._sortedRevisions = this.sortRevisions(Object.values(revisions));
     },
 
-    _synchronizeSelectionLeft: function() {
-      this.$.leftPatchSelect.value = this._leftSelected;
+    _computeLeftDisabled(basePatchNum, patchNum, sortedRevisions) {
+      return this.findSortedIndex(basePatchNum, sortedRevisions) >=
+          this.findSortedIndex(patchNum, sortedRevisions);
     },
 
-    _computePatchSetDescription: function(revisions, patchNum) {
-      var rev = this.getRevisionByPatchNum(revisions, patchNum);
+    _computeRightDisabled(patchNum, basePatchNum, sortedRevisions) {
+      if (basePatchNum == 'PARENT') { return false; }
+
+      return this.findSortedIndex(patchNum, sortedRevisions) <=
+          this.findSortedIndex(basePatchNum, sortedRevisions);
+    },
+
+    // Copied from gr-file-list
+    // @todo(beckysiegel) clean up.
+    _getCommentsForPath(comments, patchNum, path) {
+      return (comments[path] || []).filter(c => {
+        return this.patchNumEquals(c.patch_set, patchNum);
+      });
+    },
+
+    // Copied from gr-file-list
+    // @todo(beckysiegel) clean up.
+    _computeUnresolvedNum(comments, drafts, patchNum, path) {
+      comments = this._getCommentsForPath(comments, patchNum, path);
+      drafts = this._getCommentsForPath(drafts, patchNum, path);
+      comments = comments.concat(drafts);
+
+      // Create an object where every comment ID is the key of an unresolved
+      // comment.
+
+      const idMap = comments.reduce((acc, comment) => {
+        if (comment.unresolved) {
+          acc[comment.id] = true;
+        }
+        return acc;
+      }, {});
+
+      // Set false for the comments that are marked as parents.
+      for (const comment of comments) {
+        idMap[comment.in_reply_to] = false;
+      }
+
+      // The unresolved comments are the comments that still have true.
+      const unresolvedLeaves = Object.keys(idMap).filter(key => {
+        return idMap[key];
+      });
+
+      return unresolvedLeaves.length;
+    },
+
+    _computePatchSetCommentsString(allComments, patchNum) {
+      // todo (beckysiegel) get comment strings for diff view also.
+      if (!allComments) { return ''; }
+      let numComments = 0;
+      let numUnresolved = 0;
+      for (const file in allComments) {
+        if (allComments.hasOwnProperty(file)) {
+          numComments += this._getCommentsForPath(
+              allComments, patchNum, file).length;
+          numUnresolved += this._computeUnresolvedNum(
+              allComments, {}, patchNum, file);
+        }
+      }
+      let commentsStr = '';
+      if (numComments > 0) {
+        commentsStr = ' (' + numComments + ' comments';
+        if (numUnresolved > 0) {
+          commentsStr += ', ' + numUnresolved + ' unresolved';
+        }
+        commentsStr += ')';
+      }
+      return commentsStr;
+    },
+
+    /**
+     * @param {!Array} revisions
+     * @param {number|string} patchNum
+     * @param {boolean=} opt_addFrontSpace
+     */
+    _computePatchSetDescription(revisions, patchNum, opt_addFrontSpace) {
+      const rev = this.getRevisionByPatchNum(revisions, patchNum);
       return (rev && rev.description) ?
+          (opt_addFrontSpace ? ' ' : '') +
           rev.description.substring(0, PATCH_DESC_MAX_LENGTH) : '';
     },
+
+    /**
+     * Catches value-change events from the patchset dropdowns and determines
+     * whether or not a patch change event should be fired.
+     */
+    _handlePatchChange(e) {
+      const detail = {patchNum: this.patchNum, basePatchNum: this.basePatchNum};
+      const target = Polymer.dom(e).localTarget;
+
+      if (target === this.$.patchNumDropdown) {
+        detail.patchNum = e.detail.value;
+      } else {
+        detail.basePatchNum = e.detail.value;
+      }
+
+      this.dispatchEvent(
+          new CustomEvent('patch-range-change', {detail, bubbles: false}));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
index 00d73bf..ff89d30 100644
--- a/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-patch-range-select/gr-patch-range-select_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/page/page.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-patch-range-select.html">
 
 <script>void(0);</script>
@@ -34,91 +34,361 @@
 </test-fixture>
 
 <script>
-  suite('gr-patch-range-select tests', function() {
-    var element;
+  suite('gr-patch-range-select tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
     });
 
-    test('enabled/disabled options', function() {
-      var patchRange = {
+    teardown(() => sandbox.restore());
+
+    test('enabled/disabled options', () => {
+      const patchRange = {
         basePatchNum: 'PARENT',
         patchNum: '3',
       };
-      ['1', '2', '3'].forEach(function(patchNum) {
-        assert.isFalse(element._computeRightDisabled(patchNum, patchRange));
-      });
-      ['PARENT', '1', '2'].forEach(function(patchNum) {
-        assert.isFalse(element._computeLeftDisabled(patchNum, patchRange));
-      });
-      assert.isTrue(element._computeLeftDisabled('3', patchRange));
+      const sortedRevisions = [
+        {_number: 1},
+        {_number: 2},
+        {_number: element.EDIT_NAME, basePatchNum: 2},
+        {_number: 3},
+      ];
+      for (const patchNum of ['1', '2', '3']) {
+        assert.isFalse(element._computeRightDisabled(patchNum,
+            patchRange.basePatchNum, sortedRevisions));
+      }
+      for (const patchNum of ['PARENT', '1', '2']) {
+        assert.isFalse(element._computeLeftDisabled(patchNum,
+            patchRange.patchNum, sortedRevisions));
+      }
+      assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum));
 
-      patchRange.basePatchNum = '2';
-      assert.isTrue(element._computeLeftDisabled('3', patchRange));
-      assert.isTrue(element._computeRightDisabled('1', patchRange));
-      assert.isTrue(element._computeRightDisabled('2', patchRange));
-      assert.isFalse(element._computeRightDisabled('3', patchRange));
+      patchRange.basePatchNum = element.EDIT_NAME;
+      assert.isTrue(element._computeLeftDisabled('3', patchRange.patchNum,
+          sortedRevisions));
+      assert.isTrue(element._computeRightDisabled('1', patchRange.basePatchNum,
+          sortedRevisions));
+      assert.isTrue(element._computeRightDisabled('2', patchRange.basePatchNum,
+          sortedRevisions));
+      assert.isFalse(element._computeRightDisabled('3', patchRange.basePatchNum,
+          sortedRevisions));
+      assert.isTrue(element._computeRightDisabled(element.EDIT_NAME,
+          patchRange.basePatchNum, sortedRevisions));
     });
 
-    test('navigation', function(done) {
-      var showStub = sinon.stub(page, 'show');
-      var leftSelectEl = element.$.leftPatchSelect;
-      var rightSelectEl = element.$.rightPatchSelect;
-      var blurSpy = sinon.spy(leftSelectEl, 'blur');
-      element.changeNum = '42';
-      element.path = 'path/to/file.txt';
-      element.availablePatches = ['1', '2', '3'];
-      element.patchRange = {
-        basePatchNum: 'PARENT',
-        patchNum: '3',
-      };
+    test('_computeBaseDropdownContent', () => {
+      const comments = {};
+      const availablePatches = [
+        {num: 1},
+        {num: 2},
+        {num: 3},
+        {num: 'edit'},
+      ];
+      const revisions = [
+        {
+          commit: {},
+          _number: 2,
+          description: 'description',
+        },
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+      ];
+      const patchNum = 1;
+      const sortedRevisions = [
+        {_number: 1},
+        {_number: 2},
+        {_number: element.EDIT_NAME, basePatchNum: 2},
+        {_number: 3},
+      ];
+      const expectedResult = [
+        {
+          text: 'Base',
+          value: 'PARENT',
+        },
+        {
+          disabled: true,
+          triggerText: 'Patchset 1',
+          text: 'Patchset 1',
+          mobileText: '1',
+          bottomText: '',
+          value: 1,
+        },
+        {
+          disabled: true,
+          triggerText: 'Patchset 2',
+          text: 'Patchset 2',
+          mobileText: '2 description',
+          bottomText: 'description',
+          value: 2,
+        },
+        {
+          disabled: true,
+          triggerText: 'Patchset 3',
+          text: 'Patchset 3',
+          mobileText: '3',
+          bottomText: '',
+          value: 3,
+        },
+        {
+          disabled: true,
+          triggerText: 'Patchset edit',
+          text: 'Patchset edit',
+          mobileText: 'edit',
+          bottomText: '',
+          value: 'edit',
+        },
+      ];
+      assert.deepEqual(element._computeBaseDropdownContent(availablePatches,
+          patchNum, sortedRevisions, revisions, comments), expectedResult);
+    });
+
+    test('_computeBaseDropdownContent called when patchNum updates', () => {
+      element.revisions = [
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+      ];
+      element.availablePatches = [
+        {num: 1},
+        {num: 2},
+        {num: 3},
+        {num: 'edit'},
+      ];
+      element.patchNum = 2;
+      element.basePatchNum = 'PARENT';
       flushAsynchronousOperations();
 
-      var numEvents = 0;
-      leftSelectEl.addEventListener('change', function(e) {
-        numEvents++;
-        if (numEvents == 1) {
-          assert(showStub.lastCall.calledWithExactly(
-              '/c/42/3/path/to/file.txt'),
-              'Should navigate to /c/42/3/path/to/file.txt');
-          leftSelectEl.value = '1';
-          element.fire('change', {}, {node: leftSelectEl});
-          assert(blurSpy.called, 'Dropdown should be blurred after selection');
-        } else if (numEvents == 2) {
-          assert(showStub.lastCall.calledWithExactly(
-              '/c/42/1..3/path/to/file.txt'),
-              'Should navigate to /c/42/1..3/path/to/file.txt');
-          showStub.restore();
-          done();
-        }
-      });
-      leftSelectEl.value = 'PARENT';
-      rightSelectEl.value = '3';
-      element.fire('change', {}, {node: leftSelectEl});
+      sandbox.stub(element, '_computeBaseDropdownContent');
+
+      // Should be recomputed for each available patch
+      element.set('patchNum', 1);
+      assert.equal(element._computeBaseDropdownContent.callCount, 1);
     });
 
-    test('filesWeblinks', function() {
+    test('_computeBaseDropdownContent called when comments update', () => {
+      element.revisions = [
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+      ];
+      element.availablePatches = [
+        {num: 1},
+        {num: 2},
+        {num: 3},
+        {num: 'edit'},
+      ];
+      element.patchNum = 2;
+      element.basePatchNum = 'PARENT';
+      flushAsynchronousOperations();
+
+      // Should be recomputed for each available patch
+      sandbox.stub(element, '_computeBaseDropdownContent');
+      assert.equal(element._computeBaseDropdownContent.callCount, 0);
+      element.set('comments', {
+        file: [{
+          message: 'test',
+          patch_set: 2,
+        }],
+      });
+      assert.equal(element._computeBaseDropdownContent.callCount, 1);
+    });
+
+    test('_computePatchDropdownContent called when basePatchNum updates', () => {
+      element.revisions = [
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+      ];
+      element.availablePatches = [
+        {num: 1},
+        {num: 2},
+        {num: 3},
+        {num: 'edit'},
+      ];
+      element.patchNum = 2;
+      element.basePatchNum = 'PARENT';
+      flushAsynchronousOperations();
+
+      // Should be recomputed for each available patch
+      sandbox.stub(element, '_computePatchDropdownContent');
+      element.set('basePatchNum', 1);
+      assert.equal(element._computePatchDropdownContent.callCount, 1);
+    });
+
+    test('_computePatchDropdownContent called when comments update', () => {
+      element.revisions = [
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+      ];
+      element.availablePatches = [
+        {num: 1},
+        {num: 2},
+        {num: 3},
+        {num: 'edit'},
+      ];
+      element.patchNum = 2;
+      element.basePatchNum = 'PARENT';
+      flushAsynchronousOperations();
+
+      // Should be recomputed for each available patch
+      sandbox.stub(element, '_computePatchDropdownContent');
+      assert.equal(element._computePatchDropdownContent.callCount, 0);
+      element.set('comments', {
+        file: [{
+          message: 'test',
+          patch_set: 2,
+        }],
+      });
+      assert.equal(element._computePatchDropdownContent.callCount, 1);
+    });
+
+    test('_computePatchDropdownContent', () => {
+      const comments = {};
+      const availablePatches = [
+        {num: 1},
+        {num: 2},
+        {num: 3},
+        {num: 'edit'},
+      ];
+      const revisions = [
+        {
+          commit: {},
+          _number: 2,
+          description: 'description',
+        },
+        {commit: {}},
+        {commit: {}},
+        {commit: {}},
+      ];
+      const basePatchNum = 1;
+      const sortedRevisions = [
+        {_number: 1},
+        {_number: 2},
+        {_number: element.EDIT_NAME, basePatchNum: 2},
+        {_number: 3},
+      ];
+
+      const expectedResult = [
+        {
+          disabled: true,
+          triggerText: 'Patchset 1',
+          text: 'Patchset 1',
+          mobileText: '1',
+          bottomText: '',
+          value: 1,
+        },
+        {
+          disabled: false,
+          triggerText: 'Patchset 2',
+          text: 'Patchset 2',
+          mobileText: '2 description',
+          bottomText: 'description',
+          value: 2,
+        },
+        {
+          disabled: false,
+          triggerText: 'Patchset 3',
+          text: 'Patchset 3',
+          mobileText: '3',
+          bottomText: '',
+          value: 3,
+        },
+        {
+          disabled: false,
+          triggerText: 'edit',
+          text: 'edit',
+          mobileText: 'edit',
+          bottomText: '',
+          value: 'edit',
+        },
+      ];
+
+      assert.deepEqual(element._computePatchDropdownContent(availablePatches,
+          basePatchNum, sortedRevisions, revisions, comments), expectedResult);
+    });
+
+    test('filesWeblinks', () => {
       element.filesWeblinks = {
         meta_a: [
           {
             name: 'foo',
             url: 'f.oo',
-          }
+          },
         ],
         meta_b: [
           {
             name: 'bar',
             url: 'ba.r',
-          }
+          },
         ],
       };
       flushAsynchronousOperations();
-      var domApi = Polymer.dom(element.root);
+      const domApi = Polymer.dom(element.root);
       assert.equal(
           domApi.querySelector('a[href="f.oo"]').textContent, 'foo');
       assert.equal(
           domApi.querySelector('a[href="ba.r"]').textContent, 'bar');
     });
+
+    test('_computePatchSetCommentsString', () => {
+      // Test string with unresolved comments.
+      comments = {
+        foo: [{
+          id: '27dcee4d_f7b77cfa',
+          message: 'test',
+          patch_set: 1,
+          unresolved: true,
+        }],
+        bar: [{
+          id: '27dcee4d_f7b77cfa',
+          message: 'test',
+          patch_set: 1,
+        },
+        {
+          id: '27dcee4d_f7b77cfa',
+          message: 'test',
+          patch_set: 1,
+        }],
+        abc: [],
+      };
+
+      assert.equal(element._computePatchSetCommentsString(comments, 1),
+          ' (3 comments, 1 unresolved)');
+
+      // Test string with no unresolved comments.
+      delete comments['foo'];
+      assert.equal(element._computePatchSetCommentsString(comments, 1),
+          ' (2 comments)');
+
+      // Test string with no comments.
+      delete comments['bar'];
+      assert.equal(element._computePatchSetCommentsString(comments, 1), '');
+    });
+
+    test('patch-range-change fires', () => {
+      const handler = sandbox.stub();
+      element.basePatchNum = 1;
+      element.patchNum = 3;
+      element.addEventListener('patch-range-change', handler);
+
+      element.$.basePatchDropdown._handleValueChange(2, [{value: 2}]);
+      assert.isTrue(handler.calledOnce);
+      assert.deepEqual(handler.lastCall.args[0].detail,
+          {basePatchNum: 2, patchNum: 3});
+
+      // BasePatchNum should not have changed, due to one-way data binding.
+      element.$.patchNumDropdown._handleValueChange('edit', [{value: 'edit'}]);
+      assert.deepEqual(handler.lastCall.args[0].detail,
+          {basePatchNum: 1, patchNum: 'edit'});
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
index 1425a79..3de18be 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer.js
@@ -14,13 +14,13 @@
 (function() {
   'use strict';
 
-  var HOVER_PATH_PATTERN = /^comments\.(left|right)\.\#(\d+)\.__hovering$/;
-  var SPLICE_PATH_PATTERN = /^comments\.(left|right)\.splices$/;
+  const HOVER_PATH_PATTERN = /^comments\.(left|right)\.\#(\d+)\.__hovering$/;
+  const SPLICE_PATH_PATTERN = /^comments\.(left|right)\.splices$/;
 
-  var RANGE_HIGHLIGHT = 'range';
-  var HOVER_HIGHLIGHT = 'rangeHighlight';
+  const RANGE_HIGHLIGHT = 'range';
+  const HOVER_HIGHLIGHT = 'rangeHighlight';
 
-  var NORMALIZE_RANGE_EVENT = 'normalize-range';
+  const NORMALIZE_RANGE_EVENT = 'normalize-range';
 
   Polymer({
     is: 'gr-ranged-comment-layer',
@@ -29,11 +29,11 @@
       comments: Object,
       _listeners: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _commentMap: {
         type: Object,
-        value: function() { return {left: [], right: []}; },
+        value() { return {left: [], right: []}; },
       },
     },
 
@@ -43,12 +43,12 @@
 
     /**
      * Layer method to add annotations to a line.
-     * @param {HTMLElement} el The DIV.contentText element to apply the
+     * @param {!HTMLElement} el The DIV.contentText element to apply the
      *     annotation to.
-     * @param {GrDiffLine} line The line object.
+     * @param {!Object} line The line object. (GrDiffLine)
      */
-    annotate: function(el, line) {
-      var ranges = [];
+    annotate(el, line) {
+      let ranges = [];
       if (line.type === GrDiffLine.Type.REMOVE || (
           line.type === GrDiffLine.Type.BOTH &&
           el.getAttribute('data-side') !== 'right')) {
@@ -60,11 +60,11 @@
         ranges = ranges.concat(this._getRangesForLine(line, 'right'));
       }
 
-      ranges.forEach(function(range) {
+      for (const range of ranges) {
         GrAnnotation.annotateElement(el, range.start,
             range.end - range.start,
             range.hovering ? HOVER_HIGHLIGHT : RANGE_HIGHLIGHT);
-      });
+      }
     },
 
     /**
@@ -73,20 +73,20 @@
      *     Should accept as arguments the line numbers for the start and end of
      *     the update and the side as a string.
      */
-    addListener: function(fn) {
+    addListener(fn) {
       this._listeners.push(fn);
     },
 
     /**
      * Notify Layer listeners of changes to annotations.
-     * @param {Number} start The line where the update starts.
-     * @param {Number} end The line where the update ends.
-     * @param {String} side The side of the update. ('left' or 'right')
+     * @param {number} start The line where the update starts.
+     * @param {number} end The line where the update ends.
+     * @param {string} side The side of the update. ('left' or 'right')
      */
-    _notifyUpdateRange: function(start, end, side) {
-      this._listeners.forEach(function(listener) {
+    _notifyUpdateRange(start, end, side) {
+      for (const listener of this._listeners) {
         listener(start, end, side);
-      });
+      }
     },
 
     /**
@@ -94,7 +94,7 @@
      * emitting appropriate update notifications.
      * @param {Object} record The change record.
      */
-    _handleCommentChange: function(record) {
+    _handleCommentChange(record) {
       if (!record.path) { return; }
 
       // If the entire set of comments was changed.
@@ -105,11 +105,13 @@
       }
 
       // If the change only changed the `hovering` property of a comment.
-      var match = record.path.match(HOVER_PATH_PATTERN);
+      let match = record.path.match(HOVER_PATH_PATTERN);
+      let side;
+
       if (match) {
-        var side = match[1];
-        var index = match[2];
-        var comment = this.comments[side][index];
+        side = match[1];
+        const index = match[2];
+        const comment = this.comments[side][index];
         if (comment && comment.range) {
           this._commentMap[side] = this._computeCommentMap(this.comments[side]);
           this._notifyUpdateRange(
@@ -121,7 +123,7 @@
       // If comments were spliced in or out.
       match = record.path.match(SPLICE_PATH_PATTERN);
       if (match) {
-        var side = match[1];
+        side = match[1];
         this._commentMap[side] = this._computeCommentMap(this.comments[side]);
         this._handleCommentSplice(record.value, side);
       }
@@ -131,47 +133,49 @@
      * Take a list of comments and return a sparse list mapping line numbers to
      * partial ranges. Uses an end-character-index of -1 to indicate the end of
      * the line.
-     * @param {Array<Object>} commentList The list of comments.
-     * @return {Object} The sparse list.
+     * @param {?} commentList The list of comments.
+     *    Getting this param to match closure requirements caused problems.
+     * @return {!Object} The sparse list.
      */
-    _computeCommentMap: function(commentList) {
-      var result = {};
-      commentList.forEach(function(comment) {
-        if (!comment.range) { return; }
-        var range = comment.range;
-        for (var line = range.start_line; line <= range.end_line; line++) {
+    _computeCommentMap(commentList) {
+      const result = {};
+      for (const comment of commentList) {
+        if (!comment.range) { continue; }
+        const range = comment.range;
+        for (let line = range.start_line; line <= range.end_line; line++) {
           if (!result[line]) { result[line] = []; }
           result[line].push({
-            comment: comment,
+            comment,
             start: line === range.start_line ? range.start_character : 0,
             end: line === range.end_line ? range.end_character : -1,
           });
         }
-      });
+      }
       return result;
     },
 
     /**
      * Translate a splice record into range update notifications.
      */
-    _handleCommentSplice: function(record, side) {
+    _handleCommentSplice(record, side) {
       if (!record || !record.indexSplices) { return; }
-      record.indexSplices.forEach(function(splice) {
-        var ranges = splice.removed.length ?
-          splice.removed.map(function(c) { return c.range; }) :
-          [splice.object[splice.index].range];
-        ranges.forEach(function(range) {
-          if (!range) { return; }
+
+      for (const splice of record.indexSplices) {
+        const ranges = splice.removed.length ?
+            splice.removed.map(c => { return c.range; }) :
+            [splice.object[splice.index].range];
+        for (const range of ranges) {
+          if (!range) { continue; }
           this._notifyUpdateRange(range.start_line, range.end_line, side);
-        }.bind(this));
-      }.bind(this));
+        }
+      }
     },
 
-    _getRangesForLine: function(line, side) {
-      var lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
-      var ranges = this.get(['_commentMap', side, lineNum]) || [];
+    _getRangesForLine(line, side) {
+      const lineNum = side === 'left' ? line.beforeNumber : line.afterNumber;
+      const ranges = this.get(['_commentMap', side, lineNum]) || [];
       return ranges
-          .map(function(range) {
+          .map(range => {
             range = {
               start: range.start,
               end: range.end === -1 ? line.text.length : range.end,
@@ -189,11 +193,9 @@
             }
 
             return range;
-          }.bind(this))
-          .sort(function(a, b) {
-            // Sort the ranges so that hovering highlights are on top.
-            return a.hovering && !b.hovering ? 1 : 0;
-          });
+          })
+          // Sort the ranges so that hovering highlights are on top.
+          .sort((a, b) => a.hovering && !b.hovering ? 1 : 0);
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
index 20fba4d..afdf630 100644
--- a/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-ranged-comment-layer/gr-ranged-comment-layer_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../gr-diff/gr-diff-line.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-ranged-comment-layer.html">
 
 <script>void(0);</script>
@@ -34,12 +34,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-ranged-comment-layer', function() {
-    var element;
-    var sandbox;
+  suite('gr-ranged-comment-layer', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
-      var initialComments = {
+    setup(() => {
+      const initialComments = {
         left: [
           {
             id: '12345',
@@ -97,17 +97,17 @@
       element.comments = initialComments;
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    suite('annotate', function() {
-      var sandbox;
-      var el;
-      var line;
-      var annotateElementStub;
+    suite('annotate', () => {
+      let sandbox;
+      let el;
+      let line;
+      let annotateElementStub;
 
-      setup(function() {
+      setup(() => {
         sandbox = sinon.sandbox.create();
         annotateElementStub = sandbox.stub(GrAnnotation, 'annotateElement');
         el = document.createElement('div');
@@ -116,11 +116,11 @@
         line.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit,';
       });
 
-      teardown(function() {
+      teardown(() => {
         sandbox.restore();
       });
 
-      test('type=Remove no-comment', function() {
+      test('type=Remove no-comment', () => {
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 40;
 
@@ -129,58 +129,58 @@
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('type=Remove has-comment', function() {
+      test('type=Remove has-comment', () => {
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 36;
-        var expectedStart = 6;
-        var expectedLength = line.text.length - expectedStart;
+        const expectedStart = 6;
+        const expectedLength = line.text.length - expectedStart;
 
         element.annotate(el, line);
 
         assert.isTrue(annotateElementStub.called);
-        var lastCall = annotateElementStub.lastCall;
+        const lastCall = annotateElementStub.lastCall;
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
         assert.equal(lastCall.args[3], 'range');
       });
 
-      test('type=Remove has-comment hovering', function() {
+      test('type=Remove has-comment hovering', () => {
         line.type = GrDiffLine.Type.REMOVE;
         line.beforeNumber = 36;
         element.set(['comments', 'left', 0, '__hovering'], true);
 
-        var expectedStart = 6;
-        var expectedLength = line.text.length - expectedStart;
+        const expectedStart = 6;
+        const expectedLength = line.text.length - expectedStart;
 
         element.annotate(el, line);
 
         assert.isTrue(annotateElementStub.called);
-        var lastCall = annotateElementStub.lastCall;
+        const lastCall = annotateElementStub.lastCall;
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
         assert.equal(lastCall.args[3], 'rangeHighlight');
       });
 
-      test('type=Both has-comment', function() {
+      test('type=Both has-comment', () => {
         line.type = GrDiffLine.Type.BOTH;
         line.beforeNumber = 36;
 
-        var expectedStart = 6;
-        var expectedLength = line.text.length - expectedStart;
+        const expectedStart = 6;
+        const expectedLength = line.text.length - expectedStart;
 
         element.annotate(el, line);
 
         assert.isTrue(annotateElementStub.called);
-        var lastCall = annotateElementStub.lastCall;
+        const lastCall = annotateElementStub.lastCall;
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
         assert.equal(lastCall.args[3], 'range');
       });
 
-      test('type=Both has-comment off side', function() {
+      test('type=Both has-comment off side', () => {
         line.type = GrDiffLine.Type.BOTH;
         line.beforeNumber = 36;
         el.setAttribute('data-side', 'right');
@@ -190,18 +190,18 @@
         assert.isFalse(annotateElementStub.called);
       });
 
-      test('type=Add has-comment', function() {
+      test('type=Add has-comment', () => {
         line.type = GrDiffLine.Type.ADD;
         line.afterNumber = 12;
         el.setAttribute('data-side', 'right');
 
-        var expectedStart = 0;
-        var expectedLength = 22;
+        const expectedStart = 0;
+        const expectedLength = 22;
 
         element.annotate(el, line);
 
         assert.isTrue(annotateElementStub.called);
-        var lastCall = annotateElementStub.lastCall;
+        const lastCall = annotateElementStub.lastCall;
         assert.equal(lastCall.args[0], el);
         assert.equal(lastCall.args[1], expectedStart);
         assert.equal(lastCall.args[2], expectedLength);
@@ -209,9 +209,9 @@
       });
     });
 
-    test('_handleCommentChange overwrite', function() {
-      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      var mapSpy = sandbox.spy(element, '_computeCommentMap');
+    test('_handleCommentChange overwrite', () => {
+      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      const mapSpy = sandbox.spy(element, '_computeCommentMap');
 
       element.set('comments', {left: [], right: []});
 
@@ -222,10 +222,10 @@
       assert.equal(Object.keys(element._commentMap.right).length, 0);
     });
 
-    test('_handleCommentChange hovering', function() {
-      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      var mapSpy = sandbox.spy(element, '_computeCommentMap');
-      var notifyStub = sinon.stub();
+    test('_handleCommentChange hovering', () => {
+      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+      const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
       element.set(['comments', 'right', 0, '__hovering'], true);
@@ -234,16 +234,16 @@
       assert.isTrue(mapSpy.called);
 
       assert.isTrue(notifyStub.called);
-      var lastCall = notifyStub.lastCall;
+      const lastCall = notifyStub.lastCall;
       assert.equal(lastCall.args[0], 10);
       assert.equal(lastCall.args[1], 12);
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice out', function() {
-      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      var mapSpy = sandbox.spy(element, '_computeCommentMap');
-      var notifyStub = sinon.stub();
+    test('_handleCommentChange splice out', () => {
+      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+      const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
       element.splice('comments.right', 0, 1);
@@ -252,16 +252,16 @@
       assert.isTrue(mapSpy.called);
 
       assert.isTrue(notifyStub.called);
-      var lastCall = notifyStub.lastCall;
+      const lastCall = notifyStub.lastCall;
       assert.equal(lastCall.args[0], 10);
       assert.equal(lastCall.args[1], 12);
       assert.equal(lastCall.args[2], 'right');
     });
 
-    test('_handleCommentChange splice in', function() {
-      var handlerSpy = sandbox.spy(element, '_handleCommentChange');
-      var mapSpy = sandbox.spy(element, '_computeCommentMap');
-      var notifyStub = sinon.stub();
+    test('_handleCommentChange splice in', () => {
+      const handlerSpy = sandbox.spy(element, '_handleCommentChange');
+      const mapSpy = sandbox.spy(element, '_computeCommentMap');
+      const notifyStub = sinon.stub();
       element.addListener(notifyStub);
 
       element.splice('comments.left', element.comments.left.length, 0, {
@@ -280,16 +280,16 @@
       assert.isTrue(mapSpy.called);
 
       assert.isTrue(notifyStub.called);
-      var lastCall = notifyStub.lastCall;
+      const lastCall = notifyStub.lastCall;
       assert.equal(lastCall.args[0], 250);
       assert.equal(lastCall.args[1], 275);
       assert.equal(lastCall.args[2], 'left');
     });
 
-    test('_computeCommentMap creates maps correctly', function() {
+    test('_computeCommentMap creates maps correctly', () => {
       // There is only one ranged comment on the left, but it spans ll.36-39.
-      var leftKeys = [];
-      for (var i = 36; i <= 39; i++) { leftKeys.push('' + i); }
+      const leftKeys = [];
+      for (let i = 36; i <= 39; i++) { leftKeys.push('' + i); }
       assert.deepEqual(Object.keys(element._commentMap.left).sort(),
           leftKeys.sort());
 
@@ -311,8 +311,8 @@
 
       // The right has two ranged comments, one spanning ll.10-12 and the other
       // on line 100.
-      var rightKeys = [];
-      for (i = 10; i <= 12; i++) { rightKeys.push('' + i); }
+      const rightKeys = [];
+      for (let i = 10; i <= 12; i++) { rightKeys.push('' + i); }
       rightKeys.push('55', '100');
       assert.deepEqual(Object.keys(element._commentMap.right).sort(),
           rightKeys.sort());
@@ -334,14 +334,14 @@
       assert.equal(element._commentMap.right[100][0].end, 15);
     });
 
-    test('_getRangesForLine normalizes invalid ranges', function() {
-      var line = {
+    test('_getRangesForLine normalizes invalid ranges', () => {
+      const line = {
         afterNumber: 55,
-        text: '_getRangesForLine normalizes invalid ranges'
+        text: '_getRangesForLine normalizes invalid ranges',
       };
-      var ranges = element._getRangesForLine(line, 'right');
+      const ranges = element._getRangesForLine(line, 'right');
       assert.equal(ranges.length, 1);
-      var range = ranges[0];
+      const range = ranges[0];
       assert.isTrue(range.start < range.end, 'start and end are normalized');
       assert.equal(range.end, line.text.length);
     });
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
index 5f74f1f..47db5f0 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -16,10 +16,11 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-selection-action-box">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         --gr-arrow-size: .65em;
 
@@ -42,7 +43,7 @@
         width: 0;
       }
     </style>
-    Press <strong>C</strong> to comment.
+    Press <strong>c</strong> to comment.
     <div class="arrow"></div>
   </template>
   <script src="gr-selection-action-box.js"></script>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index 0f7f2f2..c228235 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -26,7 +26,7 @@
     properties: {
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
       range: {
         type: Object,
@@ -48,27 +48,28 @@
     ],
 
     listeners: {
-      'mousedown': '_handleMouseDown', // See https://crbug.com/gerrit/4767
+      mousedown: '_handleMouseDown', // See https://crbug.com/gerrit/4767
     },
 
     keyBindings: {
-      'c': '_handleCKey',
+      c: '_handleCKey',
     },
 
-    placeAbove: function(el) {
-      var rect = this._getTargetBoundingRect(el);
-      var boxRect = this.getBoundingClientRect();
-      var parentRect = this.parentElement.getBoundingClientRect();
+    placeAbove(el) {
+      Polymer.dom.flush();
+      const rect = this._getTargetBoundingRect(el);
+      const boxRect = this.getBoundingClientRect();
+      const parentRect = this.parentElement.getBoundingClientRect();
       this.style.top =
-          rect.top - parentRect.top - boxRect.height - 4 + 'px';
+          rect.top - parentRect.top - boxRect.height - 6 + 'px';
       this.style.left =
           rect.left - parentRect.left + (rect.width - boxRect.width) / 2 + 'px';
     },
 
-    _getTargetBoundingRect: function(el) {
-      var rect;
+    _getTargetBoundingRect(el) {
+      let rect;
       if (el instanceof Text) {
-        var range = document.createRange();
+        const range = document.createRange();
         range.selectNode(el);
         rect = range.getBoundingClientRect();
         range.detach();
@@ -78,7 +79,7 @@
       return rect;
     },
 
-    _handleCKey: function(e) {
+    _handleCKey(e) {
       if (this.shouldSuppressKeyboardShortcut(e) ||
           this.modifierPressed(e)) { return; }
 
@@ -86,13 +87,14 @@
       this._fireCreateComment();
     },
 
-    _handleMouseDown: function(e) {
+    _handleMouseDown(e) {
+      if (e.button !== 0) { return; } // 0 = main button
       e.preventDefault();
       e.stopPropagation();
       this._fireCreateComment();
     },
 
-    _fireCreateComment: function() {
+    _fireCreateComment() {
       this.fire('create-comment', {side: this.side, range: this.range});
     },
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index e9ac0a5..8c70772 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-selection-action-box.html">
 
 <script>void(0);</script>
@@ -36,33 +35,57 @@
 </test-fixture>
 
 <script>
-  suite('gr-selection-action-box', function() {
-    var container;
-    var element;
+  suite('gr-selection-action-box', () => {
+    let container;
+    let element;
 
-    setup(function() {
+    setup(() => {
       container = fixture('basic');
       element = container.querySelector('gr-selection-action-box');
       sinon.stub(element, 'fire');
     });
 
-    teardown(function() {
+    teardown(() => {
       element.fire.restore();
     });
 
-    test('ignores regular keys', function() {
+    test('ignores regular keys', () => {
       MockInteractions.pressAndReleaseKeyOn(document.body, 27, null, 'esc');
       assert.isFalse(element.fire.called);
     });
 
-    test('reacts to hotkey', function() {
+    test('reacts to hotkey', () => {
       MockInteractions.pressAndReleaseKeyOn(document.body, 67, null, 'c');
       assert.isTrue(element.fire.called);
     });
 
-    test('event fired contains playload', function() {
-      var side = 'left';
-      var range = {
+    suite('mousedown reacts only to main button', () => {
+      let e;
+
+      setup(() => {
+        e = {
+          button: 0,
+          preventDefault: sinon.stub(),
+          stopPropagation: sinon.stub(),
+        };
+        sinon.stub(element, '_fireCreateComment');
+      });
+
+      test('event handled if main button', () => {
+        element._handleMouseDown(e);
+        assert.isTrue(e.preventDefault.called);
+      });
+
+      test('event ignored if not main button', () => {
+        e.button = 1;
+        element._handleMouseDown(e);
+        assert.isFalse(e.preventDefault.called);
+      });
+    });
+
+    test('event fired contains playload', () => {
+      const side = 'left';
+      const range = {
         startLine: 1,
         startChar: 11,
         endLine: 2,
@@ -74,15 +97,15 @@
       assert(element.fire.calledWithExactly(
           'create-comment',
           {
-            side: side,
-            range: range,
+            side,
+            range,
           }));
     });
 
-    suite('placeAbove', function() {
-      var target;
+    suite('placeAbove', () => {
+      let target;
 
-      setup(function() {
+      setup(() => {
         target = container.querySelector('.target');
         sinon.stub(container, 'getBoundingClientRect').returns(
             {top: 1, bottom: 2, left: 3, right: 4, width: 50, height: 6});
@@ -92,25 +115,25 @@
             {width: 10, height: 10});
       });
 
-      teardown(function() {
+      teardown(() => {
         element.getBoundingClientRect.restore();
         container.getBoundingClientRect.restore();
         element._getTargetBoundingRect.restore();
       });
 
-      test('placeAbove for Element argument', function() {
+      test('placeAbove for Element argument', () => {
         element.placeAbove(target);
-        assert.equal(element.style.top, '27px');
+        assert.equal(element.style.top, '25px');
         assert.equal(element.style.left, '72px');
       });
 
-      test('placeAbove for Text Node argument', function() {
+      test('placeAbove for Text Node argument', () => {
         element.placeAbove(target.firstChild);
-        assert.equal(element.style.top, '27px');
+        assert.equal(element.style.top, '25px');
         assert.equal(element.style.left, '72px');
       });
 
-      test('uses document.createRange', function() {
+      test('uses document.createRange', () => {
         sinon.spy(document, 'createRange');
         element._getTargetBoundingRect.restore();
         sinon.spy(element, '_getTargetBoundingRect');
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index 7dea848..91e6287 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var LANGUAGE_MAP = {
+  const LANGUAGE_MAP = {
     'application/dart': 'dart',
     'application/json': 'json',
     'application/typescript': 'typescript',
@@ -51,9 +51,9 @@
     'text/x-swift': 'swift',
     'text/x-yaml': 'yaml',
   };
-  var ASYNC_DELAY = 10;
+  const ASYNC_DELAY = 10;
 
-  var CLASS_WHITELIST = {
+  const CLASS_WHITELIST = {
     'gr-diff gr-syntax gr-syntax-literal': true,
     'gr-diff gr-syntax gr-syntax-keyword': true,
     'gr-diff gr-syntax gr-syntax-selector-tag': true,
@@ -82,11 +82,11 @@
     'gr-diff gr-syntax gr-syntax-selector-class': true,
   };
 
-  var CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
-  var CPP_WCHAR_PATTERN = /L\'.\'/g;
-  var JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
-  var GO_BACKSLASH_LITERAL = '\'\\\\\'';
-  var GLOBAL_LT_PATTERN = /</g;
+  const CPP_DIRECTIVE_WITH_LT_PATTERN = /^\s*#(if|define).*</;
+  const CPP_WCHAR_PATTERN = /L\'(\\)?.\'/g;
+  const JAVA_PARAM_ANNOT_PATTERN = /(@[^\s]+)\(([^)]+)\)/g;
+  const GO_BACKSLASH_LITERAL = '\'\\\\\'';
+  const GLOBAL_LT_PATTERN = /</g;
 
   Polymer({
     is: 'gr-syntax-layer',
@@ -102,23 +102,24 @@
       },
       _baseRanges: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _revisionRanges: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _baseLanguage: String,
       _revisionLanguage: String,
       _listeners: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
+      /** @type {?number} */
       _processHandle: Number,
       _hljs: Object,
     },
 
-    addListener: function(fn) {
+    addListener(fn) {
       this.push('_listeners', fn);
     },
 
@@ -126,13 +127,13 @@
      * Annotation layer method to add syntax annotations to the given element
      * for the given line.
      * @param {!HTMLElement} el
-     * @param {!GrDiffLine} line
+     * @param {!Object} line (GrDiffLine)
      */
-    annotate: function(el, line) {
+    annotate(el, line) {
       if (!this.enabled) { return; }
 
       // Determine the side.
-      var side;
+      let side;
       if (line.type === GrDiffLine.Type.REMOVE || (
           line.type === GrDiffLine.Type.BOTH &&
           el.getAttribute('data-side') !== 'right')) {
@@ -143,7 +144,7 @@
       }
 
       // Find the relevant syntax ranges, if any.
-      var ranges = [];
+      let ranges = [];
       if (side === 'left' && this._baseRanges.length >= line.beforeNumber) {
         ranges = this._baseRanges[line.beforeNumber - 1] || [];
       } else if (side === 'right' &&
@@ -152,10 +153,10 @@
       }
 
       // Apply the ranges to the element.
-      ranges.forEach(function(range) {
+      for (const range of ranges) {
         GrAnnotation.annotateElement(
             el, range.start, range.length, range.className);
-      });
+      }
     },
 
     /**
@@ -163,7 +164,7 @@
      * as syntax info comes online.
      * @return {Promise}
      */
-    process: function() {
+    process() {
       // Discard existing ranges.
       this._baseRanges = [];
       this._revisionRanges = [];
@@ -184,7 +185,7 @@
         return Promise.resolve();
       }
 
-      var state = {
+      const state = {
         sectionIndex: 0,
         lineIndex: 0,
         baseContext: undefined,
@@ -193,9 +194,9 @@
         lastNotify: {left: 1, right: 1},
       };
 
-      return this._loadHLJS().then(function() {
-        return new Promise(function(resolve) {
-          var nextStep = function() {
+      return this._loadHLJS().then(() => {
+        return new Promise(resolve => {
+          const nextStep = () => {
             this._processHandle = null;
             this._processNextLine(state);
 
@@ -224,21 +225,21 @@
           };
 
           this._processHandle = this.async(nextStep, 1);
-        }.bind(this));
-      }.bind(this));
+        });
+      });
     },
 
     /**
      * Cancel any asynchronous syntax processing jobs.
      */
-    cancel: function() {
+    cancel() {
       if (this._processHandle) {
         this.cancelAsync(this._processHandle);
         this._processHandle = null;
       }
     },
 
-    _diffChanged: function() {
+    _diffChanged() {
       this.cancel();
       this._baseRanges = [];
       this._revisionRanges = [];
@@ -251,17 +252,16 @@
      * @param {string} str The string of HTML.
      * @return {!Array<!Object>} The list of ranges.
      */
-    _rangesFromString: function(str) {
-      var div = document.createElement('div');
+    _rangesFromString(str) {
+      const div = document.createElement('div');
       div.innerHTML = str;
       return this._rangesFromElement(div, 0);
     },
 
-    _rangesFromElement: function(elem, offset) {
-      var result = [];
-      for (var i = 0; i < elem.childNodes.length; i++) {
-        var node = elem.childNodes[i];
-        var nodeLength = GrAnnotation.getLength(node);
+    _rangesFromElement(elem, offset) {
+      let result = [];
+      for (const node of elem.childNodes) {
+        const nodeLength = GrAnnotation.getLength(node);
         // Note: HLJS may emit a span with class undefined when it thinks there
         // may be a syntax error.
         if (node.tagName === 'SPAN' && node.className !== 'undefined') {
@@ -286,11 +286,11 @@
      * lines).
      * @param {!Object} state The processing state for the layer.
      */
-    _processNextLine: function(state) {
-      var baseLine;
-      var revisionLine;
+    _processNextLine(state) {
+      let baseLine;
+      let revisionLine;
 
-      var section = this.diff.content[state.sectionIndex];
+      const section = this.diff.content[state.sectionIndex];
       if (section.ab) {
         baseLine = section.ab[state.lineIndex];
         revisionLine = section.ab[state.lineIndex];
@@ -308,7 +308,7 @@
       }
 
       // To store the result of the syntax highlighter.
-      var result;
+      let result;
 
       if (this._baseLanguage && baseLine !== undefined) {
         baseLine = this._workaround(this._baseLanguage, baseLine);
@@ -346,7 +346,7 @@
      * @param {!string} line The line of code to potentially rewrite.
      * @return {string} A potentially-rewritten line of code.
      */
-    _workaround: function(language, line) {
+    _workaround(language, line) {
       if (language === 'cpp') {
         /**
          * Prevent confusing < and << operators for the start of a meta string
@@ -365,7 +365,7 @@
          * {#see https://github.com/isagalaev/highlight.js/issues/1412}
          */
         if (CPP_WCHAR_PATTERN.test(line)) {
-          line = line.replace(CPP_WCHAR_PATTERN, 'L"."');
+          line = line.replace(CPP_WCHAR_PATTERN, 'L"$1."');
         }
 
         return line;
@@ -387,7 +387,7 @@
        * {@see Issue 5007}
        * {#see https://github.com/isagalaev/highlight.js/issues/1411}
        */
-      if (language === 'go' && line.indexOf(GO_BACKSLASH_LITERAL) !== -1) {
+      if (language === 'go' && line.includes(GO_BACKSLASH_LITERAL)) {
         return line.replace(GO_BACKSLASH_LITERAL, '"\\\\"');
       }
 
@@ -399,8 +399,8 @@
      * @param {!Object} state
      * @return {boolean}
      */
-    _isSectionDone: function(state) {
-      var section = this.diff.content[state.sectionIndex];
+    _isSectionDone(state) {
+      const section = this.diff.content[state.sectionIndex];
       if (section.ab) {
         return state.lineIndex >= section.ab.length;
       } else {
@@ -414,33 +414,33 @@
      * that have not yet been notified.
      * @param {!Object} state
      */
-    _notify: function(state) {
+    _notify(state) {
       if (state.lineNums.left - state.lastNotify.left) {
         this._notifyRange(
-          state.lastNotify.left,
-          state.lineNums.left,
-          'left');
+            state.lastNotify.left,
+            state.lineNums.left,
+            'left');
         state.lastNotify.left = state.lineNums.left;
       }
       if (state.lineNums.right - state.lastNotify.right) {
         this._notifyRange(
-          state.lastNotify.right,
-          state.lineNums.right,
-          'right');
+            state.lastNotify.right,
+            state.lineNums.right,
+            'right');
         state.lastNotify.right = state.lineNums.right;
       }
     },
 
-    _notifyRange: function(start, end, side) {
-      this._listeners.forEach(function(fn) {
+    _notifyRange(start, end, side) {
+      for (const fn of this._listeners) {
         fn(start, end, side);
-      });
+      }
     },
 
-    _loadHLJS: function() {
-      return this.$.libLoader.get().then(function(hljs) {
+    _loadHLJS() {
+      return this.$.libLoader.get().then(hljs => {
         this._hljs = hljs;
-      }.bind(this));
+      });
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
index eaa8d29..98a6f8e 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -20,7 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="../../shared/gr-rest-api-interface/mock-diff-response_test.html">
 <link rel="import" href="gr-syntax-layer.html">
 
@@ -33,17 +33,17 @@
 </test-fixture>
 
 <script>
-  suite('gr-syntax-layer tests', function() {
-    var sandbox;
-    var diff;
-    var element;
+  suite('gr-syntax-layer tests', () => {
+    let sandbox;
+    let diff;
+    let element;
 
     function getMockHLJS() {
-      var html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
+      const html = '<span class="gr-diff gr-syntax gr-syntax-string">' +
           'ipsum</span>';
       return {
-        configure: function() {},
-        highlight: function(lang, line, ignore, state) {
+        configure() {},
+        highlight(lang, line, ignore, state) {
           return {
             value: line.replace(/ipsum/, html),
             top: state === undefined ? 1 : state + 1,
@@ -52,23 +52,23 @@
       };
     }
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      var mock = document.createElement('mock-diff-response');
+      const mock = document.createElement('mock-diff-response');
       diff = mock.diffResponse;
       element.diff = diff;
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('annotate without range does nothing', function() {
-      var annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      var el = document.createElement('div');
+    test('annotate without range does nothing', () => {
+      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      const el = document.createElement('div');
       el.textContent = 'Etiam dui, blandit wisi.';
-      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
       line.beforeNumber = 12;
 
       element.annotate(el, line);
@@ -76,21 +76,21 @@
       assert.isFalse(annotationSpy.called);
     });
 
-    test('annotate with range applies it', function() {
-      var str = 'Etiam dui, blandit wisi.';
-      var start = 6;
-      var length = 3;
-      var className = 'foobar';
+    test('annotate with range applies it', () => {
+      const str = 'Etiam dui, blandit wisi.';
+      const start = 6;
+      const length = 3;
+      const className = 'foobar';
 
-      var annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      var el = document.createElement('div');
+      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      const el = document.createElement('div');
       el.textContent = str;
-      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
       line.beforeNumber = 12;
       element._baseRanges[11] = [{
-        start: start,
-        length: length,
-        className: className,
+        start,
+        length,
+        className,
       }];
 
       element.annotate(el, line);
@@ -103,21 +103,21 @@
       assert.isOk(el.querySelector('hl.' + className));
     });
 
-    test('annotate with range but disabled does nothing', function() {
-      var str = 'Etiam dui, blandit wisi.';
-      var start = 6;
-      var length = 3;
-      var className = 'foobar';
+    test('annotate with range but disabled does nothing', () => {
+      const str = 'Etiam dui, blandit wisi.';
+      const start = 6;
+      const length = 3;
+      const className = 'foobar';
 
-      var annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
-      var el = document.createElement('div');
+      const annotationSpy = sandbox.spy(GrAnnotation, 'annotateElement');
+      const el = document.createElement('div');
       el.textContent = str;
-      var line = new GrDiffLine(GrDiffLine.Type.REMOVE);
+      const line = new GrDiffLine(GrDiffLine.Type.REMOVE);
       line.beforeNumber = 12;
       element._baseRanges[11] = [{
-        start: start,
-        length: length,
-        className: className,
+        start,
+        length,
+        className,
       }];
       element.enabled = false;
 
@@ -126,17 +126,17 @@
       assert.isFalse(annotationSpy.called);
     });
 
-    test('process on empty diff does nothing', function(done) {
+    test('process on empty diff does nothing', done => {
       element.diff = {
         meta_a: {content_type: 'application/json'},
         meta_b: {content_type: 'application/json'},
         content: [],
       };
-      var processNextSpy = sandbox.spy(element, '_processNextLine');
+      const processNextSpy = sandbox.spy(element, '_processNextLine');
 
-      var processPromise = element.process();
+      const processPromise = element.process();
 
-      processPromise.then(function() {
+      processPromise.then(() => {
         assert.isFalse(processNextSpy.called);
         assert.equal(element._baseRanges.length, 0);
         assert.equal(element._revisionRanges.length, 0);
@@ -144,17 +144,17 @@
       });
     });
 
-    test('process for unsupported languages does nothing', function(done) {
+    test('process for unsupported languages does nothing', done => {
       element.diff = {
         meta_a: {content_type: 'text/x+objective-cobol-plus-plus'},
         meta_b: {content_type: 'application/not-a-real-language'},
         content: [],
       };
-      var processNextSpy = sandbox.spy(element, '_processNextLine');
+      const processNextSpy = sandbox.spy(element, '_processNextLine');
 
-      var processPromise = element.process();
+      const processPromise = element.process();
 
-      processPromise.then(function() {
+      processPromise.then(() => {
         assert.isFalse(processNextSpy.called);
         assert.equal(element._baseRanges.length, 0);
         assert.equal(element._revisionRanges.length, 0);
@@ -162,14 +162,14 @@
       });
     });
 
-    test('process while disabled does nothing', function(done) {
-      var processNextSpy = sandbox.spy(element, '_processNextLine');
+    test('process while disabled does nothing', done => {
+      const processNextSpy = sandbox.spy(element, '_processNextLine');
       element.enabled = false;
-      var loadHLJSSpy = sandbox.spy(element, '_loadHLJS');
+      const loadHLJSSpy = sandbox.spy(element, '_loadHLJS');
 
-      var processPromise = element.process();
+      const processPromise = element.process();
 
-      processPromise.then(function() {
+      processPromise.then(() => {
         assert.isFalse(processNextSpy.called);
         assert.equal(element._baseRanges.length, 0);
         assert.equal(element._revisionRanges.length, 0);
@@ -178,20 +178,20 @@
       });
     });
 
-    test('process highlight ipsum', function(done) {
+    test('process highlight ipsum', done => {
       element.diff.meta_a.content_type = 'application/json';
       element.diff.meta_b.content_type = 'application/json';
 
-      var mockHLJS = getMockHLJS();
-      var highlightSpy = sinon.spy(mockHLJS, 'highlight');
+      const mockHLJS = getMockHLJS();
+      const highlightSpy = sinon.spy(mockHLJS, 'highlight');
       sandbox.stub(element.$.libLoader, 'get',
-          function() { return Promise.resolve(mockHLJS); });
-      var processNextSpy = sandbox.spy(element, '_processNextLine');
-      var processPromise = element.process();
+          () => { return Promise.resolve(mockHLJS); });
+      const processNextSpy = sandbox.spy(element, '_processNextLine');
+      const processPromise = element.process();
 
-      processPromise.then(function() {
-        var linesA = diff.meta_a.lines;
-        var linesB = diff.meta_b.lines;
+      processPromise.then(() => {
+        const linesA = diff.meta_a.lines;
+        const linesB = diff.meta_b.lines;
 
         assert.isTrue(processNextSpy.called);
         assert.equal(element._baseRanges.length, linesA);
@@ -200,37 +200,39 @@
         assert.equal(highlightSpy.callCount, linesA + linesB);
 
         // The first line of both sides have a range.
-        [element._baseRanges[0], element._revisionRanges[0]]
-            .forEach(function(range) {
-              assert.equal(range.length, 1);
-              assert.equal(range[0].className,
-                  'gr-diff gr-syntax gr-syntax-string');
-              assert.equal(range[0].start, 'lorem '.length);
-              assert.equal(range[0].length, 'ipsum'.length);
-            });
+        let ranges = [element._baseRanges[0], element._revisionRanges[0]];
+        for (const range of ranges) {
+          assert.equal(range.length, 1);
+          assert.equal(range[0].className,
+              'gr-diff gr-syntax gr-syntax-string');
+          assert.equal(range[0].start, 'lorem '.length);
+          assert.equal(range[0].length, 'ipsum'.length);
+        }
 
         // There are no ranges from ll.1-12 on the left and ll.1-11 on the
         // right.
-        element._baseRanges.slice(1, 12)
-            .concat(element._revisionRanges.slice(1, 11))
-            .forEach(function(range) {
-              assert.equal(range.length, 0);
-            });
+        ranges = element._baseRanges.slice(1, 12)
+            .concat(element._revisionRanges.slice(1, 11));
+
+        for (const range of ranges) {
+          assert.equal(range.length, 0);
+        }
 
         // There should be another pair of ranges on l.13 for the left and
         // l.12 for the right.
-        [element._baseRanges[13], element._revisionRanges[12]]
-            .forEach(function(range) {
-              assert.equal(range.length, 1);
-              assert.equal(range[0].className,
-                  'gr-diff gr-syntax gr-syntax-string');
-              assert.equal(range[0].start, 32);
-              assert.equal(range[0].length, 'ipsum'.length);
-            });
+        ranges = [element._baseRanges[13], element._revisionRanges[12]];
+
+        for (const range of ranges) {
+          assert.equal(range.length, 1);
+          assert.equal(range[0].className,
+              'gr-diff gr-syntax gr-syntax-string');
+          assert.equal(range[0].start, 32);
+          assert.equal(range[0].length, 'ipsum'.length);
+        }
 
         // The next group should have a similar instance on either side.
 
-        var range = element._baseRanges[15];
+        let range = element._baseRanges[15];
         assert.equal(range.length, 1);
         assert.equal(range[0].className, 'gr-diff gr-syntax gr-syntax-string');
         assert.equal(range[0].start, 34);
@@ -246,38 +248,38 @@
       });
     });
 
-    test('_diffChanged calls cancel', function() {
-      var cancelSpy = sandbox.spy(element, '_diffChanged');
+    test('_diffChanged calls cancel', () => {
+      const cancelSpy = sandbox.spy(element, '_diffChanged');
       element.diff = {content: []};
       assert.isTrue(cancelSpy.called);
     });
 
-    test('_rangesFromElement no ranges', function() {
-      var elem = document.createElement('span');
+    test('_rangesFromElement no ranges', () => {
+      const elem = document.createElement('span');
       elem.textContent = 'Etiam dui, blandit wisi.';
-      var offset = 100;
+      const offset = 100;
 
-      var result = element._rangesFromElement(elem, offset);
+      const result = element._rangesFromElement(elem, offset);
 
       assert.equal(result.length, 0);
     });
 
-    test('_rangesFromElement single range', function() {
-      var str0 = 'Etiam ';
-      var str1 = 'dui, blandit';
-      var str2 = ' wisi.';
-      var className = 'gr-diff gr-syntax gr-syntax-string';
-      var offset = 100;
+    test('_rangesFromElement single range', () => {
+      const str0 = 'Etiam ';
+      const str1 = 'dui, blandit';
+      const str2 = ' wisi.';
+      const className = 'gr-diff gr-syntax gr-syntax-string';
+      const offset = 100;
 
-      var elem = document.createElement('span');
+      const elem = document.createElement('span');
       elem.appendChild(document.createTextNode(str0));
-      var span = document.createElement('span');
+      const span = document.createElement('span');
       span.textContent = str1;
       span.className = className;
       elem.appendChild(span);
       elem.appendChild(document.createTextNode(str2));
 
-      var result = element._rangesFromElement(elem, offset);
+      const result = element._rangesFromElement(elem, offset);
 
       assert.equal(result.length, 1);
       assert.equal(result[0].start, str0.length + offset);
@@ -285,37 +287,37 @@
       assert.equal(result[0].className, className);
     });
 
-    test('_rangesFromElement non-whitelist', function() {
-      var str0 = 'Etiam ';
-      var str1 = 'dui, blandit';
-      var str2 = ' wisi.';
-      var className = 'not-in-the-whitelist';
-      var offset = 100;
+    test('_rangesFromElement non-whitelist', () => {
+      const str0 = 'Etiam ';
+      const str1 = 'dui, blandit';
+      const str2 = ' wisi.';
+      const className = 'not-in-the-whitelist';
+      const offset = 100;
 
-      var elem = document.createElement('span');
+      const elem = document.createElement('span');
       elem.appendChild(document.createTextNode(str0));
-      var span = document.createElement('span');
+      const span = document.createElement('span');
       span.textContent = str1;
       span.className = className;
       elem.appendChild(span);
       elem.appendChild(document.createTextNode(str2));
 
-      var result = element._rangesFromElement(elem, offset);
+      const result = element._rangesFromElement(elem, offset);
 
       assert.equal(result.length, 0);
     });
 
-    test('_rangesFromElement milti range', function() {
-      var str0 = 'Etiam ';
-      var str1 = 'dui,';
-      var str2 = ' blandit';
-      var str3 = ' wisi.';
-      var className = 'gr-diff gr-syntax gr-syntax-string';
-      var offset = 100;
+    test('_rangesFromElement milti range', () => {
+      const str0 = 'Etiam ';
+      const str1 = 'dui,';
+      const str2 = ' blandit';
+      const str3 = ' wisi.';
+      const className = 'gr-diff gr-syntax gr-syntax-string';
+      const offset = 100;
 
-      var elem = document.createElement('span');
+      const elem = document.createElement('span');
       elem.appendChild(document.createTextNode(str0));
-      var span = document.createElement('span');
+      let span = document.createElement('span');
       span.textContent = str1;
       span.className = className;
       elem.appendChild(span);
@@ -325,7 +327,7 @@
       span.className = className;
       elem.appendChild(span);
 
-      var result = element._rangesFromElement(elem, offset);
+      const result = element._rangesFromElement(elem, offset);
 
       assert.equal(result.length, 2);
 
@@ -339,27 +341,27 @@
       assert.equal(result[1].className, className);
     });
 
-    test('_rangesFromElement nested range', function() {
-      var str0 = 'Etiam ';
-      var str1 = 'dui,';
-      var str2 = ' blandit';
-      var str3 = ' wisi.';
-      var className = 'gr-diff gr-syntax gr-syntax-string';
-      var offset = 100;
+    test('_rangesFromElement nested range', () => {
+      const str0 = 'Etiam ';
+      const str1 = 'dui,';
+      const str2 = ' blandit';
+      const str3 = ' wisi.';
+      const className = 'gr-diff gr-syntax gr-syntax-string';
+      const offset = 100;
 
-      var elem = document.createElement('span');
+      const elem = document.createElement('span');
       elem.appendChild(document.createTextNode(str0));
-      var span1 = document.createElement('span');
+      const span1 = document.createElement('span');
       span1.textContent = str1;
       span1.className = className;
       elem.appendChild(span1);
-      var span2 = document.createElement('span');
+      const span2 = document.createElement('span');
       span2.textContent = str2;
       span2.className = className;
       span1.appendChild(span2);
       elem.appendChild(document.createTextNode(str3));
 
-      var result = element._rangesFromElement(elem, offset);
+      const result = element._rangesFromElement(elem, offset);
 
       assert.equal(result.length, 2);
 
@@ -372,17 +374,17 @@
       assert.equal(result[1].className, className);
     });
 
-    test('_rangesFromString whitelist allows recursion', function() {
-      var str = [
-          '<span class="non-whtelisted-class">',
-            '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
-          '</span>'].join('');
-      var result = element._rangesFromString(str);
+    test('_rangesFromString whitelist allows recursion', () => {
+      const str = [
+        '<span class="non-whtelisted-class">',
+        '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
+        '</span>'].join('');
+      const result = element._rangesFromString(str);
       assert.notEqual(result.length, 0);
     });
 
-    test('_isSectionDone', function() {
-      var state = {sectionIndex: 0, lineIndex: 0};
+    test('_isSectionDone', () => {
+      let state = {sectionIndex: 0, lineIndex: 0};
       assert.isFalse(element._isSectionDone(state));
 
       state = {sectionIndex: 0, lineIndex: 2};
@@ -407,9 +409,9 @@
       assert.isTrue(element._isSectionDone(state));
     });
 
-    test('workaround CPP LT directive', function() {
+    test('workaround CPP LT directive', () => {
       // Does nothing to regular line.
-      var line = 'int main(int argc, char** argv) { return 0; }';
+      let line = 'int main(int argc, char** argv) { return 0; }';
       assert.equal(element._workaround('cpp', line), line);
 
       // Does nothing to include directive.
@@ -418,7 +420,7 @@
 
       // Converts left-shift operator in #define.
       line = '#define GiB (1ull << 30)';
-      var expected = '#define GiB (1ull || 30)';
+      let expected = '#define GiB (1ull || 30)';
       assert.equal(element._workaround('cpp', line), expected);
 
       // Converts less-than operator in #if.
@@ -427,9 +429,9 @@
       assert.equal(element._workaround('cpp', line), expected);
     });
 
-    test('workaround Java param-annotation', function() {
+    test('workaround Java param-annotation', () => {
       // Does nothing to regular line.
-      var line = 'public static void foo(int bar) { }';
+      let line = 'public static void foo(int bar) { }';
       assert.equal(element._workaround('java', line), line);
 
       // Does nothing to regular annotation.
@@ -438,14 +440,14 @@
 
       // Converts parameterized annotation.
       line = 'public static void foo(@SuppressWarnings("unused") int bar) { }';
-      var expected = 'public static void foo(@SuppressWarnings "unused" ' +
+      const expected = 'public static void foo(@SuppressWarnings "unused" ' +
           ' int bar) { }';
       assert.equal(element._workaround('java', line), expected);
     });
 
-    test('workaround CPP whcar_t character literals', function() {
+    test('workaround CPP whcar_t character literals', () => {
       // Does nothing to regular line.
-      var line = 'int main(int argc, char** argv) { return 0; }';
+      let line = 'int main(int argc, char** argv) { return 0; }';
       assert.equal(element._workaround('cpp', line), line);
 
       // Does nothing to wchar_t string.
@@ -454,13 +456,18 @@
 
       // Converts wchar_t character literal to string.
       line = 'wchar_t myChar = L\'#\'';
-      var expected = 'wchar_t myChar = L"."';
+      let expected = 'wchar_t myChar = L"."';
+      assert.equal(element._workaround('cpp', line), expected);
+
+      // Converts wchar_t character literal with escape sequence to string.
+      line = 'wchar_t myChar = L\'\\"\'';
+      expected = 'wchar_t myChar = L"\\."';
       assert.equal(element._workaround('cpp', line), expected);
     });
 
-    test('workaround go backslash character literals', function() {
+    test('workaround go backslash character literals', () => {
       // Does nothing to regular line.
-      var line = 'func foo(in []byte) (lit []byte, n int, err error) {';
+      let line = 'func foo(in []byte) (lit []byte, n int, err error) {';
       assert.equal(element._workaround('go', line), line);
 
       // Does nothing to string with backslash literal
@@ -469,7 +476,7 @@
 
       // Converts backslash literal character to a string.
       line = 'c := \'\\\\\'';
-      var expected = 'c := "\\\\"';
+      const expected = 'c := "\\\\"';
       assert.equal(element._workaround('go', line), expected);
     });
   });
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
index 520f24d..bfd8e90 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader.js
@@ -14,8 +14,8 @@
 (function() {
   'use strict';
 
-  var HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
-  var LIB_ROOT_PATTERN = /(.+\/)elements\/gr-app\.html/;
+  const HLJS_PATH = 'bower_components/highlightjs/highlight.min.js';
+  const LIB_ROOT_PATTERN = /(.+\/)elements\/gr-app\.html/;
 
   Polymer({
     is: 'gr-syntax-lib-loader',
@@ -26,17 +26,16 @@
 
         // NOTE: intended singleton.
         value: {
-          loaded: false,
           loading: false,
           callbacks: [],
         },
-      }
+      },
     },
 
-    get: function() {
-      return new Promise(function(resolve) {
+    get() {
+      return new Promise((resolve, reject) => {
         // If the lib is totally loaded, resolve immediately.
-        if (this._state.loaded) {
+        if (this._getHighlightLib()) {
           resolve(this._getHighlightLib());
           return;
         }
@@ -44,50 +43,68 @@
         // If the library is not currently being loaded, then start loading it.
         if (!this._state.loading) {
           this._state.loading = true;
-          this._loadHLJS().then(this._onLibLoaded.bind(this));
+          this._loadHLJS().then(this._onLibLoaded.bind(this)).catch(reject);
         }
 
         this._state.callbacks.push(resolve);
-      }.bind(this));
+      });
     },
 
-    _onLibLoaded: function() {
-      var lib = this._getHighlightLib();
-      this._state.loaded = true;
+    _onLibLoaded() {
+      const lib = this._getHighlightLib();
       this._state.loading = false;
-      this._state.callbacks.forEach(function(cb) { cb(lib); });
+      for (const cb of this._state.callbacks) {
+        cb(lib);
+      }
       this._state.callbacks = [];
     },
 
-    _getHighlightLib: function() {
+    _getHighlightLib() {
       return window.hljs;
     },
 
-    _configureHighlightLib: function() {
+    _configureHighlightLib() {
       this._getHighlightLib().configure(
           {classPrefix: 'gr-diff gr-syntax gr-syntax-'});
     },
 
-    _getLibRoot: function() {
+    _getLibRoot() {
       if (this._cachedLibRoot) { return this._cachedLibRoot; }
 
-      return this._cachedLibRoot = document.head
-          .querySelector('link[rel=import][href$="gr-app.html"]')
+      const appLink = document.head
+        .querySelector('link[rel=import][href$="gr-app.html"]');
+
+      if (!appLink) { return null; }
+
+      return this._cachedLibRoot = appLink
           .href
           .match(LIB_ROOT_PATTERN)[1];
     },
     _cachedLibRoot: null,
 
-    _loadHLJS: function() {
-      return new Promise(function(resolve) {
-        var script = document.createElement('script');
-        script.src = this._getLibRoot() + HLJS_PATH;
+    _loadHLJS() {
+      return new Promise((resolve, reject) => {
+        const script = document.createElement('script');
+        const src = this._getHLJSUrl();
+
+        if (!src) {
+          reject(new Error('Unable to load blank HLJS url.'));
+          return;
+        }
+
+        script.src = src;
         script.onload = function() {
           this._configureHighlightLib();
           resolve();
         }.bind(this);
         Polymer.dom(document.head).appendChild(script);
-      }.bind(this));
+      });
+    },
+
+    _getHLJSUrl() {
+      const root = this._getLibRoot();
+      if (!root) { return null; }
+      return root + HLJS_PATH;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
index 985fc6d..6ddde46 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html
@@ -20,7 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-syntax-lib-loader.html">
 
 <script>void(0);</script>
@@ -32,26 +32,23 @@
 </test-fixture>
 
 <script>
-  suite('gr-syntax-lib-loader tests', function() {
-    var element;
-    var resolveLoad;
-    var loadStub;
+  suite('gr-syntax-lib-loader tests', () => {
+    let element;
+    let resolveLoad;
+    let loadStub;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
 
-      loadStub = sinon.stub(element, '_loadHLJS', function() {
-        return new Promise(function(resolve) {
-          resolveLoad = resolve;
-        });
-      });
+      loadStub = sinon.stub(element, '_loadHLJS', () =>
+        new Promise(resolve => resolveLoad = resolve)
+      );
 
       // Assert preconditions:
-      assert.isFalse(element._state.loaded);
       assert.isFalse(element._state.loading);
     });
 
-    teardown(function() {
+    teardown(() => {
       if (window.hljs) {
         delete window.hljs;
       }
@@ -59,39 +56,80 @@
 
       // Because the element state is a singleton, clean it up.
       element._state.loading = false;
-      element._state.loaded = false;
       element._state.callbacks = [];
     });
 
-    test('only load once', function(done) {
-      var firstCallHandler = sinon.stub();
+    test('only load once', done => {
+      const firstCallHandler = sinon.stub();
       element.get().then(firstCallHandler);
 
       // It should now be in the loading state.
       assert.isTrue(loadStub.called);
       assert.isTrue(element._state.loading);
-      assert.isFalse(element._state.loaded);
       assert.isFalse(firstCallHandler.called);
 
-      var secondCallHandler = sinon.stub();
+      const secondCallHandler = sinon.stub();
       element.get().then(secondCallHandler);
 
       // No change in state.
       assert.isTrue(element._state.loading);
-      assert.isFalse(element._state.loaded);
       assert.isFalse(firstCallHandler.called);
       assert.isFalse(secondCallHandler.called);
 
       // Now load the library.
       resolveLoad();
-      flush(function() {
+      flush(() => {
         // The state should be loaded and both handlers called.
         assert.isFalse(element._state.loading);
-        assert.isTrue(element._state.loaded);
         assert.isTrue(firstCallHandler.called);
         assert.isTrue(secondCallHandler.called);
         done();
       });
     });
+
+    suite('preloaded', () => {
+      setup(() => {
+        window.hljs = 'test-object';
+      });
+
+      teardown(() => {
+        delete window.hljs;
+      });
+
+      test('returns hljs', done => {
+        const firstCallHandler = sinon.stub();
+        element.get().then(firstCallHandler);
+        flush(() => {
+          assert.isTrue(firstCallHandler.called);
+          assert.isTrue(firstCallHandler.calledWith('test-object'));
+          done();
+        });
+      });
+    });
+
+    suite('_getHLJSUrl', () => {
+      suite('checking _getLibRoot', () => {
+        let libRootStub;
+        let root;
+
+        setup(() => {
+          libRootStub = sinon.stub(element, '_getLibRoot', () => root);
+        });
+
+        teardown(() => {
+          libRootStub.restore();
+        });
+
+        test('with no root', () => {
+          assert.isNull(element._getHLJSUrl());
+        });
+
+        test('with root', () => {
+          root = 'test-root.com/';
+          assert.equal(element._getHLJSUrl(),
+              'test-root.com/bower_components/highlightjs/highlight.min.js');
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
index fabc347..062a90e 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-themes/gr-theme-default.html
@@ -28,9 +28,9 @@
       .gr-syntax-meta {
          color: #FF1717;
       }
-      .gr-syntax-keyword {
-        color: #7F0055;
-        font-weight: bold;
+      .gr-syntax-keyword,
+      .gr-syntax-name {
+        color: #9E0069;
         line-height: 1em;
       }
       .gr-syntax-number,
@@ -61,7 +61,7 @@
         color: #219;
       }
       .gr-syntax-type {
-        color: #00f;
+        color: var(--color-link);
       }
       .gr-syntax-title {
         color: #0000C0;
@@ -80,8 +80,8 @@
         font-style: italic;
       }
       .gr-syntax-strong {
-        font-weight: bold;
+        font-weight: 700;
       }
     </style>
   </template>
-</dom-module>
\ No newline at end of file
+</dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app-it_test.html b/polygerrit-ui/app/elements/gr-app-it_test.html
new file mode 100644
index 0000000..7b134c1
--- /dev/null
+++ b/polygerrit-ui/app/elements/gr-app-it_test.html
@@ -0,0 +1,92 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-app-it_test</title>
+
+<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../test/common-test-setup.html"/>
+<link rel="import" href="gr-app.html">
+
+<script>void(0);</script>
+
+<test-fixture id="element">
+  <template>
+    <gr-app id="app"></gr-app>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-app integration tests', () => {
+    let sandbox;
+    let element;
+
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+      stub('gr-reporting', {
+        appStarted: sandbox.stub(),
+      });
+      stub('gr-account-dropdown', {
+        _getTopContent: sinon.stub(),
+      });
+      stub('gr-rest-api-interface', {
+        getAccount() { return Promise.resolve(null); },
+        getAccountCapabilities() { return Promise.resolve({}); },
+        getConfig() {
+          return Promise.resolve({
+            gerrit: {web_uis: ['GWT', 'POLYGERRIT']},
+            plugin: {
+              js_resource_paths: [],
+              html_resource_paths: [
+                new URL('test/plugin.html', window.location.href).toString(),
+              ],
+            },
+          });
+        },
+        getVersion() { return Promise.resolve(42); },
+        getLoggedIn() { return Promise.resolve(false); },
+      });
+      element = fixture('element');
+
+      const importSpy = sandbox.spy(element.$.externalStyle, '_import');
+      Gerrit.awaitPluginsLoaded().then(() => {
+        Promise.all(importSpy.returnValues).then(() => {
+          flush(done);
+        });
+      });
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('applies --primary-text-color', () => {
+      assert.equal(
+          element.getComputedStyleValue('--primary-text-color'), '#F00BAA');
+    });
+
+    test('applies --header-background-color', () => {
+      assert.equal(element.getComputedStyleValue('--header-background-color'),
+          '#F01BAA');
+    });
+    test('applies --footer-background-color', () => {
+      assert.equal(element.getComputedStyleValue('--footer-background-color'),
+          '#F02BAA');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index c92610bf..6f8a4a1 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -13,62 +13,90 @@
 See the License for the specific language governing permissions and
 limitations under the License.
 -->
+<script>
+  // This must be set prior to loading Polymer for the first time.
+  if (localStorage.getItem('USE_SHADOW_DOM') === 'true') {
+    window.Polymer = {
+      dom: 'shadow',
+    };
+  }
+</script>
+
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../bower_components/polymer-resin/standalone/polymer-resin.html">
+<script>
+  security.polymer_resin.install({
+    allowedIdentifierPrefixes: [''],
+    reportHandler: security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER,
+  });
+</script>
 
 <link rel="import" href="../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
-<link rel="import" href="../bower_components/polymer/polymer.html">
 <link rel="import" href="../styles/app-theme.html">
-
+<link rel="import" href="../styles/shared-styles.html">
 <link rel="import" href="./admin/gr-admin-view/gr-admin-view.html">
-
-<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
-<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
-<link rel="import" href="./core/gr-main-header/gr-main-header.html">
-<link rel="import" href="./core/gr-router/gr-router.html">
-<link rel="import" href="./core/gr-reporting/gr-reporting.html">
-
 <link rel="import" href="./change-list/gr-change-list-view/gr-change-list-view.html">
 <link rel="import" href="./change-list/gr-dashboard-view/gr-dashboard-view.html">
 <link rel="import" href="./change/gr-change-view/gr-change-view.html">
+<link rel="import" href="./core/gr-error-manager/gr-error-manager.html">
+<link rel="import" href="./core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html">
+<link rel="import" href="./core/gr-main-header/gr-main-header.html">
+<link rel="import" href="./core/gr-navigation/gr-navigation.html">
+<link rel="import" href="./core/gr-reporting/gr-reporting.html">
+<link rel="import" href="./core/gr-router/gr-router.html">
 <link rel="import" href="./diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="./plugins/gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="./plugins/gr-external-style/gr-external-style.html">
+<link rel="import" href="./plugins/gr-plugin-host/gr-plugin-host.html">
 <link rel="import" href="./settings/gr-cla-view/gr-cla-view.html">
 <link rel="import" href="./settings/gr-registration-dialog/gr-registration-dialog.html">
 <link rel="import" href="./settings/gr-settings-view/gr-settings-view.html">
 
-<link rel="import" href="./shared/gr-overlay/gr-overlay.html">
-<link rel="import" href="./shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
 <script src="../scripts/util.js"></script>
 
 <dom-module id="gr-app">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: flex;
-        min-height: 100vh;
+        min-height: 100%;
         flex-direction: column;
       }
+      gr-fixed-panel {
+        /**
+         * This one should be greater that the z-index in gr-diff-view
+         * because gr-main-header contains overlay.
+         */
+        z-index: 10;
+      }
       gr-main-header,
       footer {
         color: var(--primary-text-color);
       }
+      gr-main-header.shadow {
+        /* Make it obvious for shadow dom testing */
+        border-bottom: 1px solid pink;
+      }
       gr-main-header {
-        background-color: var(--header-background-color, #eee);
+        background-color: var(--header-background-color);
         padding: 0 var(--default-horizontal-margin);
       }
       footer {
-        background-color: var(--footer-background-color, #eee);
+        background-color: var(--footer-background-color);
         display: flex;
         justify-content: space-between;
         padding: .5rem var(--default-horizontal-margin);
+        z-index: 100;
       }
       main {
         flex: 1;
+        padding-bottom: 2em;
         position: relative;
       }
       .errorView {
         align-items: center;
-        display: flex;
+        display: none;
         flex-direction: column;
         justify-content: center;
         position: absolute;
@@ -77,6 +105,9 @@
         bottom: 0;
         left: 0;
       }
+      .errorView.show {
+        display: flex;
+      }
       .errorEmoji {
         font-size: 2.6em;
       }
@@ -94,8 +125,13 @@
         color: #b71c1c;
       }
     </style>
-    <gr-main-header id="mainHeader" search-query="{{params.query}}">
-    </gr-main-header>
+    <gr-fixed-panel id="header">
+      <gr-main-header
+          id="mainHeader"
+          search-query="{{params.query}}"
+          class$="[[_computeShadowClass(_isShadowDom)]]">
+      </gr-main-header>
+    </gr-fixed-panel>
     <main>
       <template is="dom-if" if="[[_showChangeListView]]" restamp="true">
         <gr-change-list-view
@@ -112,7 +148,6 @@
       <template is="dom-if" if="[[_showChangeView]]" restamp="true">
         <gr-change-view
             params="[[params]]"
-            server-config="[[_serverConfig]]"
             view-state="{{_viewState.changeView}}"
             back-page="[[_lastSearchPage]]"></gr-change-view>
       </template>
@@ -128,18 +163,19 @@
         </gr-settings-view>
       </template>
       <template is="dom-if" if="[[_showAdminView]]" restamp="true">
-        <gr-admin-view path="[[_path]]"></gr-admin-view>
+        <gr-admin-view path="[[_path]]"
+            params=[[params]]></gr-admin-view>
       </template>
       <template is="dom-if" if="[[_showCLAView]]" restamp="true">
         <gr-cla-view path="[[_path]]"></gr-cla-view>
       </template>
-      <div id="errorView" class="errorView" hidden>
+      <div id="errorView" class="errorView">
         <div class="errorEmoji">[[_lastError.emoji]]</div>
         <div class="errorText">[[_lastError.text]]</div>
         <div class="errorMoreInfo">[[_lastError.moreInfo]]</div>
       </div>
     </main>
-    <footer role="contentinfo">
+    <footer r="contentinfo" class$="[[_computeShadowClass(_isShadowDom)]]">
       <div>
         Powered by <a href="https://www.gerritcodereview.com/" rel="noopener"
         target="_blank">Gerrit Code Review</a>
@@ -151,7 +187,7 @@
             rel="noopener" target="_blank">Send feedback</a>
         <template is="dom-if" if="[[_computeShowGwtUiLink(_serverConfig)]]">
           |
-          <a id="gwtLink" href$="[[computeGwtUrl(_path)]]" rel="external">Old UI</a>
+          <a id="gwtLink" href$="[[computeGwtUrl(_path)]]" rel="external">Switch to Old UI</a>
         </template>
         | Press &ldquo;?&rdquo; for keyboard shortcuts
       </div>
@@ -167,10 +203,15 @@
           on-close="_handleRegistrationDialogClose">
       </gr-registration-dialog>
     </gr-overlay>
+    <gr-endpoint-decorator name="plugin-overlay"></gr-endpoint-decorator>
     <gr-error-manager id="errorManager"></gr-error-manager>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-reporting id="reporting"></gr-reporting>
     <gr-router id="router"></gr-router>
+    <gr-plugin-host id="plugins"
+        config="[[_serverConfig]]">
+    </gr-plugin-host>
+    <gr-external-style id="externalStyle" name="app-theme"></gr-external-style>
   </template>
   <script src="gr-app.js" crossorigin="anonymous"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index d820bc7..4a38b85 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -31,16 +31,22 @@
      */
 
     properties: {
+      /**
+       * @type {{ query: string, view: string }}
+       */
       params: Object,
       keyEventTarget: {
         type: Object,
-        value: function() { return document.body; },
+        value() { return document.body; },
       },
 
       _account: {
         type: Object,
         observer: '_accountChanged',
       },
+      /**
+       * @type {{ plugin: Object }}
+       */
       _serverConfig: Object,
       _version: String,
       _showChangeListView: Boolean,
@@ -48,10 +54,15 @@
       _showChangeView: Boolean,
       _showDiffView: Boolean,
       _showSettingsView: Boolean,
+      _showAdminView: Boolean,
+      _showCLAView: Boolean,
+      /** @type {?} */
       _viewState: Object,
+      /** @type {?} */
       _lastError: Object,
       _lastSearchPage: String,
       _path: String,
+      _isShadowDom: Boolean,
     },
 
     listeners: {
@@ -62,7 +73,6 @@
 
     observers: [
       '_viewChanged(params.view)',
-      '_loadPlugins(_serverConfig.plugin.js_resource_paths)',
     ],
 
     behaviors: [
@@ -74,18 +84,19 @@
       '?': '_showKeyboardShortcuts',
     },
 
-    ready: function() {
+    ready() {
+      this._isShadowDom = Polymer.Settings.useShadow;
       this.$.router.start();
 
-      this.$.restAPI.getAccount().then(function(account) {
+      this.$.restAPI.getAccount().then(account => {
         this._account = account;
-      }.bind(this));
-      this.$.restAPI.getConfig().then(function(config) {
+      });
+      this.$.restAPI.getConfig().then(config => {
         this._serverConfig = config;
-      }.bind(this));
-      this.$.restAPI.getVersion().then(function(version) {
+      });
+      this.$.restAPI.getVersion().then(version => {
         this._version = version;
-      }.bind(this));
+      });
 
       this.$.reporting.appStarted();
       this._viewState = {
@@ -95,6 +106,8 @@
           selectedFileIndex: 0,
           showReplyDialog: false,
           diffMode: null,
+          numFilesShown: null,
+          scrollTop: 0,
         },
         changeListView: {
           query: null,
@@ -107,104 +120,88 @@
       };
     },
 
-    _accountChanged: function(account) {
+    _accountChanged(account) {
       if (!account) { return; }
 
       // Preferences are cached when a user is logged in; warm them.
       this.$.restAPI.getPreferences();
       this.$.restAPI.getDiffPreferences();
       this.$.errorManager.knownAccountId =
-        this._account && this._account._account_id || null;
+          this._account && this._account._account_id || null;
     },
 
-    _viewChanged: function(view) {
-      this.$.errorView.hidden = true;
-      this.set('_showChangeListView', view === 'gr-change-list-view');
-      this.set('_showDashboardView', view === 'gr-dashboard-view');
-      this.set('_showChangeView', view === 'gr-change-view');
-      this.set('_showDiffView', view === 'gr-diff-view');
-      this.set('_showSettingsView', view === 'gr-settings-view');
-      this.set('_showAdminView', view === 'gr-admin-view');
-      this.set('_showCLAView', view === 'gr-cla-view');
+    _viewChanged(view) {
+      this.$.errorView.classList.remove('show');
+      this.set('_showChangeListView', view === Gerrit.Nav.View.SEARCH);
+      this.set('_showDashboardView', view === Gerrit.Nav.View.DASHBOARD);
+      this.set('_showChangeView', view === Gerrit.Nav.View.CHANGE);
+      this.set('_showDiffView', view === Gerrit.Nav.View.DIFF);
+      this.set('_showSettingsView', view === Gerrit.Nav.View.SETTINGS);
+      this.set('_showAdminView', view === Gerrit.Nav.View.ADMIN);
+      this.set('_showCLAView', view === Gerrit.Nav.View.AGREEMENTS);
       if (this.params.justRegistered) {
         this.$.registration.open();
       }
-    },
-
-    _loadPlugins: function(plugins) {
-      Gerrit._setPluginsCount(plugins.length);
-      for (var i = 0; i < plugins.length; i++) {
-        var scriptEl = document.createElement('script');
-        scriptEl.defer = true;
-        scriptEl.src = '/' + plugins[i];
-        scriptEl.onerror = Gerrit._pluginInstalled;
-        document.body.appendChild(scriptEl);
-      }
-    },
-
-    _loginTapHandler: function(e) {
-      e.preventDefault();
-      page.show('/login/' + encodeURIComponent(
-          window.location.pathname + window.location.hash));
+      this.$.header.unfloat();
     },
 
     // Argument used for binding update only.
-    _computeLoggedIn: function(account) {
+    _computeLoggedIn(account) {
       return !!(account && Object.keys(account).length > 0);
     },
 
-    _computeShowGwtUiLink: function(config) {
-      return config.gerrit.web_uis &&
-          config.gerrit.web_uis.indexOf('GWT') !== -1;
+    _computeShowGwtUiLink(config) {
+      return config.gerrit.web_uis && config.gerrit.web_uis.includes('GWT');
     },
 
-    _handlePageError: function(e) {
-      [
+    _handlePageError(e) {
+      const props = [
         '_showChangeListView',
         '_showDashboardView',
         '_showChangeView',
         '_showDiffView',
         '_showSettingsView',
-      ].forEach(function(showProp) {
+      ];
+      for (const showProp of props) {
         this.set(showProp, false);
-      }.bind(this));
+      }
 
-      this.$.errorView.hidden = false;
-      var response = e.detail.response;
-      var err = {text: [response.status, response.statusText].join(' ')};
+      this.$.errorView.classList.add('show');
+      const response = e.detail.response;
+      const err = {text: [response.status, response.statusText].join(' ')};
       if (response.status === 404) {
         err.emoji = '¯\\_(ツ)_/¯';
         this._lastError = err;
       } else {
         err.emoji = 'o_O';
-        response.text().then(function(text) {
+        response.text().then(text => {
           err.moreInfo = text;
           this._lastError = err;
-        }.bind(this));
+        });
       }
     },
 
-    _handleLocationChange: function(e) {
-      var hash = e.detail.hash.substring(1);
-      var pathname = e.detail.pathname;
-      if (pathname.indexOf('/c/') === 0 && parseInt(hash, 10) > 0) {
+    _handleLocationChange(e) {
+      const hash = e.detail.hash.substring(1);
+      let pathname = e.detail.pathname;
+      if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
         pathname += '@' + hash;
       }
       this.set('_path', pathname);
       this._handleSearchPageChange();
     },
 
-    _handleSearchPageChange: function() {
+    _handleSearchPageChange() {
       if (!this.params) {
         return;
       }
-      var viewsToCheck = ['gr-change-list-view', 'gr-dashboard-view'];
-      if (viewsToCheck.indexOf(this.params.view) !== -1) {
+      const viewsToCheck = [Gerrit.Nav.View.SEARCH, Gerrit.Nav.View.DASHBOARD];
+      if (viewsToCheck.includes(this.params.view)) {
         this.set('_lastSearchPage', location.pathname);
       }
     },
 
-    _handleTitleChange: function(e) {
+    _handleTitleChange(e) {
       if (e.detail.title) {
         document.title = e.detail.title + ' · Gerrit Code Review';
       } else {
@@ -212,25 +209,29 @@
       }
     },
 
-    _showKeyboardShortcuts: function(e) {
+    _showKeyboardShortcuts(e) {
       if (this.shouldSuppressKeyboardShortcut(e)) { return; }
       this.$.keyboardShortcuts.open();
     },
 
-    _handleKeyboardShortcutDialogClose: function() {
+    _handleKeyboardShortcutDialogClose() {
       this.$.keyboardShortcuts.close();
     },
 
-    _handleAccountDetailUpdate: function(e) {
+    _handleAccountDetailUpdate(e) {
       this.$.mainHeader.reload();
-      if (this.params.view === 'gr-settings-view') {
+      if (this.params.view === Gerrit.Nav.View.SETTINGS) {
         this.$$('gr-settings-view').reloadAccountDetail();
       }
     },
 
-    _handleRegistrationDialogClose: function(e) {
+    _handleRegistrationDialogClose(e) {
       this.params.justRegistered = false;
       this.$.registration.close();
     },
+
+    _computeShadowClass(isShadowDom) {
+      return isShadowDom ? 'shadow' : '';
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index 28251fe..3712ffa 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -18,9 +18,9 @@
 <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
 <title>gr-app</title>
 
-<script src="../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../test/common-test-setup.html"/>
 <link rel="import" href="gr-app.html">
 
 <script>void(0);</script>
@@ -32,11 +32,11 @@
 </test-fixture>
 
 <script>
-  suite('gr-app tests', function() {
-    var sandbox;
-    var element;
+  suite('gr-app tests', () => {
+    let sandbox;
+    let element;
 
-    setup(function(done) {
+    setup(done => {
       sandbox = sinon.sandbox.create();
       stub('gr-reporting', {
         appStarted: sandbox.stub(),
@@ -45,43 +45,43 @@
         _getTopContent: sinon.stub(),
       });
       stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve({}); },
-        getAccountCapabilities: function() { return Promise.resolve({}); },
-        getConfig: function() {
+        getAccount() { return Promise.resolve({}); },
+        getAccountCapabilities() { return Promise.resolve({}); },
+        getConfig() {
           return Promise.resolve({
             gerrit: {web_uis: ['GWT', 'POLYGERRIT']},
-            plugin: {js_resource_paths: []},
+            plugin: {},
           });
         },
-        getVersion: function() { return Promise.resolve(42); },
+        getPreferences() { return Promise.resolve({my: []}); },
+        getVersion() { return Promise.resolve(42); },
+        probePath() { return Promise.resolve(42); },
       });
 
       element = fixture('basic');
       flush(done);
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('reporting', function() {
+    test('reporting', () => {
       assert.isTrue(element.$.reporting.appStarted.calledOnce);
     });
 
-    test('location change updates gwt footer', function(done) {
+    test('location change updates gwt footer', done => {
       element._path = '/test/path';
-      flush(function() {
-        var gwtLink = element.$$('#gwtLink');
-        assert.equal(
-          gwtLink.href,
-          'http://' + location.host + element.getBaseUrl() + '/?polygerrit=0#/test/path'
-        );
+      flush(() => {
+        const gwtLink = element.$$('#gwtLink');
+        assert.equal(gwtLink.href, 'http://' + location.host +
+            element.getBaseUrl() + '/?polygerrit=0#/test/path');
         done();
       });
     });
 
-    test('_handleLocationChange handles hashes', function(done) {
-      var curLocation = {
+    test('_handleLocationChange handles hashes', done => {
+      const curLocation = {
         pathname: '/c/1/1/testfile.txt',
         hash: '#2',
         host: location.host,
@@ -89,21 +89,21 @@
       sandbox.stub(element, '_handleSearchPageChange');
       element._handleLocationChange({detail: curLocation});
 
-      flush(function() {
-        var gwtLink = element.$$('#gwtLink');
+      flush(() => {
+        const gwtLink = element.$$('#gwtLink');
         assert.equal(
-          gwtLink.href,
-          'http://' + location.host + element.getBaseUrl() +
+            gwtLink.href,
+            'http://' + location.host + element.getBaseUrl() +
             '/?polygerrit=0#/c/1/1/testfile.txt@2'
         );
         done();
       });
     });
 
-    test('sets plugins count', function() {
-      sandbox.stub(Gerrit, '_setPluginsCount');
-      element._loadPlugins([]);
-      assert.isTrue(Gerrit._setPluginsCount.calledWithExactly(0));
+    test('passes config to gr-plugin-host', () => {
+      return element.$.restAPI.getConfig.lastCall.returnValue.then(config => {
+        assert.deepEqual(element.$.plugins.config, config);
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
new file mode 100644
index 0000000..c495c94
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-attribute-helper">
+  <script src="gr-attribute-helper.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
new file mode 100644
index 0000000..301c12e
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper.js
@@ -0,0 +1,87 @@
+// 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.
+(function(window) {
+  'use strict';
+
+  function GrAttributeHelper(element) {
+    this.element = element;
+    this._promises = {};
+  }
+
+  GrAttributeHelper.prototype._getChangedEventName = function(name) {
+    return name.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase() + '-changed';
+  };
+
+  /**
+   * Returns true if the property is defined on wrapped element.
+   * @param {string} name
+   * @return {boolean}
+   */
+  GrAttributeHelper.prototype._elementHasProperty = function(name) {
+    return this.element[name] !== undefined;
+  };
+
+  GrAttributeHelper.prototype._reportValue = function(callback, value) {
+    try {
+      callback(value);
+    } catch (e) {
+      console.info(e);
+    }
+  };
+
+  /**
+   * Binds callback to property updates.
+   *
+   * @param {string} name Property name.
+   * @param {function(?)} callback
+   * @return {function()} Unbind function.
+   */
+  GrAttributeHelper.prototype.bind = function(name, callback) {
+    const attributeChangedEventName = this._getChangedEventName(name);
+    const changedHandler = e => this._reportValue(callback, e.detail.value);
+    const unbind = () => this.element.removeEventListener(
+        attributeChangedEventName, changedHandler);
+    this.element.addEventListener(
+        attributeChangedEventName, changedHandler);
+    if (this._elementHasProperty(name)) {
+      this._reportValue(callback, this.element[name]);
+    }
+    return unbind;
+  };
+
+  /**
+   * Get value of the property from wrapped object. Waits for the property
+   * to be initialized if it isn't defined.
+   *
+   * @param {string} name Property name.
+   * @return {!Promise<?>}
+   */
+  GrAttributeHelper.prototype.get = function(name) {
+    if (this._elementHasProperty(name)) {
+      return Promise.resolve(this.element[name]);
+    }
+    if (!this._promises[name]) {
+      let resolve;
+      const promise = new Promise(r => resolve = r);
+      const unbind = this.bind(name, value => {
+        resolve(value);
+        unbind();
+      });
+      this._promises[name] = promise;
+    }
+    return this._promises[name];
+  };
+
+  window.GrAttributeHelper = GrAttributeHelper;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
new file mode 100644
index 0000000..5dababe
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-attribute-helper/gr-attribute-helper_test.html
@@ -0,0 +1,97 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-attribute-helper</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-attribute-helper.html"/>
+
+<script>void(0);</script>
+
+<dom-element id="some-element">
+  <script>
+    Polymer({
+      is: 'some-element',
+      properties: {
+        fooBar: {
+          type: Object,
+          notify: true,
+        },
+      },
+    });
+  </script>
+</dom-element>
+
+<test-fixture id="basic">
+  <template>
+    <some-element></some-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-attribute-helper tests', () => {
+    let element;
+    let instance;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      instance = new GrAttributeHelper(element);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('resolved on value change from undefined', () => {
+      const promise = instance.get('fooBar').then(value => {
+        assert.equal(value, 'foo! bar!');
+      });
+      element.fooBar = 'foo! bar!';
+      return promise;
+    });
+
+    test('resolves to current attribute value', () => {
+      element.fooBar = 'foo-foo-bar';
+      const promise = instance.get('fooBar').then(value => {
+        assert.equal(value, 'foo-foo-bar');
+      });
+      element.fooBar = 'no bar';
+      return promise;
+    });
+
+    test('bind', () => {
+      const stub = sandbox.stub();
+      element.fooBar = 'bar foo';
+      const unbind = instance.bind('fooBar', stub);
+      element.fooBar = 'partridge in a foo tree';
+      element.fooBar = 'five gold bars';
+      assert.equal(stub.callCount, 3);
+      assert.deepEqual(stub.args[0], ['bar foo']);
+      assert.deepEqual(stub.args[1], ['partridge in a foo tree']);
+      assert.deepEqual(stub.args[2], ['five gold bars']);
+      stub.reset();
+      unbind();
+      instance.fooBar = 'ladies dancing';
+      assert.isFalse(stub.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
new file mode 100644
index 0000000..11891c17
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+
+<dom-module id="gr-dom-hooks">
+  <script src="gr-dom-hooks.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
new file mode 100644
index 0000000..889333b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks.js
@@ -0,0 +1,135 @@
+// 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.
+(function(window) {
+  'use strict';
+
+  function GrDomHooksManager(plugin) {
+    this._plugin = plugin;
+    this._hooks = {};
+  }
+
+  GrDomHooksManager.prototype._getHookName = function(endpointName,
+      opt_moduleName) {
+    if (opt_moduleName) {
+      return endpointName + ' ' + opt_moduleName;
+    } else {
+      return this._plugin.getPluginName() + '-autogenerated-' + endpointName;
+    }
+  };
+
+  GrDomHooksManager.prototype.getDomHook = function(endpointName,
+      opt_moduleName) {
+    const hookName = this._getHookName(endpointName, opt_moduleName);
+    if (!this._hooks[hookName]) {
+      this._hooks[hookName] = new GrDomHook(hookName, opt_moduleName);
+    }
+    return this._hooks[hookName];
+  };
+
+  function GrDomHook(hookName, opt_moduleName) {
+    this._instances = [];
+    this._callbacks = [];
+    if (opt_moduleName) {
+      this._moduleName = opt_moduleName;
+    } else {
+      this._moduleName = hookName;
+      this._createPlaceholder(hookName);
+    }
+  }
+
+  GrDomHook.prototype._createPlaceholder = function(hookName) {
+    Polymer({
+      is: hookName,
+      properties: {
+        plugin: Object,
+        content: Object,
+      },
+    });
+  };
+
+  GrDomHook.prototype.handleInstanceDetached = function(instance) {
+    const index = this._instances.indexOf(instance);
+    if (index !== -1) {
+      this._instances.splice(index, 1);
+    }
+  };
+
+  GrDomHook.prototype.handleInstanceAttached = function(instance) {
+    this._instances.push(instance);
+    this._callbacks.forEach(callback => callback(instance));
+  };
+
+  /**
+   * Get instance of last DOM hook element attached into the endpoint.
+   * Returns a Promise, that's resolved when attachment is done.
+   * @return {!Promise<!Element>}
+   */
+  GrDomHook.prototype.getLastAttached = function() {
+    if (this._instances.length) {
+      return Promise.resolve(this._instances.slice(-1)[0]);
+    }
+    if (!this._lastAttachedPromise) {
+      let resolve;
+      const promise = new Promise(r => resolve = r);
+      this._callbacks.push(resolve);
+      this._lastAttachedPromise = promise.then(element => {
+        this._lastAttachedPromise = null;
+        const index = this._callbacks.indexOf(resolve);
+        if (index !== -1) {
+          this._callbacks.splice(index, 1);
+        }
+        return element;
+      });
+    }
+    return this._lastAttachedPromise;
+  };
+
+  /**
+   * Get all DOM hook elements.
+   */
+  GrDomHook.prototype.getAllAttached = function() {
+    return this._instances;
+  };
+
+  /**
+   * Install a new callback to invoke when a new instance of DOM hook element
+   * is attached.
+   * @param {function(Element)} callback
+   */
+  GrDomHook.prototype.onAttached = function(callback) {
+    this._callbacks.push(callback);
+    return this;
+  };
+
+  /**
+   * Name of DOM hook element that will be installed into the endpoint.
+   */
+  GrDomHook.prototype.getModuleName = function() {
+    return this._moduleName;
+  };
+
+  GrDomHook.prototype.getPublicAPI = function() {
+    const result = {};
+    const exposedMethods = [
+      'onAttached', 'getLastAttached', 'getAllAttached', 'getModuleName',
+    ];
+    for (const p of exposedMethods) {
+      result[p] = this[p].bind(this);
+    }
+    return result;
+  };
+
+  window.GrDomHook = GrDomHook;
+  window.GrDomHooksManager = GrDomHooksManager;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
new file mode 100644
index 0000000..f5a7f6f
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-dom-hooks/gr-dom-hooks_test.html
@@ -0,0 +1,142 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-dom-hooks</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-dom-hooks.html"/>
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-dom-hooks tests', () => {
+    const PUBLIC_METHODS =
+        ['onAttached', 'getLastAttached', 'getAllAttached', 'getModuleName'];
+
+    let instance;
+    let sandbox;
+    let hook;
+    let hookInternal;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      instance = new GrDomHooksManager(plugin);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('placeholder', () => {
+      setup(()=>{
+        sandbox.stub(GrDomHook.prototype, '_createPlaceholder');
+        hookInternal = instance.getDomHook('foo-bar');
+        hook = hookInternal.getPublicAPI();
+      });
+
+      test('public hook API has only public methods', () => {
+        assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+      });
+
+      test('registers placeholder class', () => {
+        assert.isTrue(hookInternal._createPlaceholder.calledWithExactly(
+            'testplugin-autogenerated-foo-bar'));
+      });
+
+      test('getModuleName()', () => {
+        const hookName = Object.keys(instance._hooks).pop();
+        assert.equal(hookName, 'testplugin-autogenerated-foo-bar');
+        assert.equal(hook.getModuleName(), 'testplugin-autogenerated-foo-bar');
+      });
+    });
+
+    suite('custom element', () => {
+      setup(() => {
+        hookInternal = instance.getDomHook('foo-bar', 'my-el');
+        hook = hookInternal.getPublicAPI();
+      });
+
+      test('public hook API has only public methods', () => {
+        assert.deepEqual(Object.keys(hook), PUBLIC_METHODS);
+      });
+
+      test('getModuleName()', () => {
+        const hookName = Object.keys(instance._hooks).pop();
+        assert.equal(hookName, 'foo-bar my-el');
+        assert.equal(hook.getModuleName(), 'my-el');
+      });
+
+      test('onAttached', () => {
+        const onAttachedSpy = sandbox.spy();
+        hook.onAttached(onAttachedSpy);
+        const [el1, el2] = [
+          document.createElement(hook.getModuleName()),
+          document.createElement(hook.getModuleName()),
+        ];
+        hookInternal.handleInstanceAttached(el1);
+        hookInternal.handleInstanceAttached(el2);
+        assert.isTrue(onAttachedSpy.firstCall.calledWithExactly(el1));
+        assert.isTrue(onAttachedSpy.secondCall.calledWithExactly(el2));
+      });
+
+      test('getAllAttached', () => {
+        const [el1, el2] = [
+          document.createElement(hook.getModuleName()),
+          document.createElement(hook.getModuleName()),
+        ];
+        el1.textContent = 'one';
+        el2.textContent = 'two';
+        hookInternal.handleInstanceAttached(el1);
+        hookInternal.handleInstanceAttached(el2);
+        assert.deepEqual([el1, el2], hook.getAllAttached());
+        hookI.handleInstanceDetached(el1);
+        assert.deepEqual([el2], hook.getAllAttached());
+      });
+
+      test('getLastAttached', () => {
+        const beforeAttachedPromise = hook.getLastAttached().then(
+            el => assert.strictEqual(el1, el));
+        const [el1, el2] = [
+          document.createElement(hook.getModuleName()),
+          document.createElement(hook.getModuleName()),
+        ];
+        el1.textContent = 'one';
+        el2.textContent = 'two';
+        hookInternal.handleInstanceAttached(el1);
+        hookInternal.handleInstanceAttached(el2);
+        const afterAttachedPromise = hook.getLastAttached().then(
+            el => assert.strictEqual(el2, el));
+        return Promise.all([
+          beforeAttachedPromise,
+          afterAttachedPromise,
+        ]);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
new file mode 100644
index 0000000..0928534
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+
+<dom-module id="gr-endpoint-decorator">
+  <template strip-whitespace>
+    <content></content>
+  </template>
+  <script src="gr-endpoint-decorator.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
new file mode 100644
index 0000000..a2a1c0b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator.js
@@ -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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-endpoint-decorator',
+
+    properties: {
+      name: String,
+      /** @type {!Map} */
+      _domHooks: {
+        type: Map,
+        value() { return new Map(); },
+      },
+    },
+
+    detached() {
+      for (const [el, domHook] of this._domHooks) {
+        domHook.handleInstanceDetached(el);
+      }
+    },
+
+    _import(url) {
+      return new Promise((resolve, reject) => {
+        this.importHref(url, resolve, reject);
+      });
+    },
+
+    _initDecoration(name, plugin) {
+      const el = document.createElement(name);
+      this._initProperties(el, plugin, this.getContentChildren().find(
+          el => el.nodeName !== 'GR-ENDPOINT-PARAM'));
+      this._appendChild(el);
+      return el;
+    },
+
+    _initReplacement(name, plugin) {
+      this.getContentChildNodes().forEach(node => node.remove());
+      const el = document.createElement(name);
+      this._initProperties(el, plugin);
+      this._appendChild(el);
+      return el;
+    },
+
+    _getEndpointParams() {
+      return Polymer.dom(this).querySelectorAll('gr-endpoint-param').map(el => {
+        return {name: el.getAttribute('name'), value: el.value};
+      });
+    },
+
+    /**
+     * @param {!Element} el
+     * @param {!Object} plugin
+     * @param {!Element=} opt_content
+     */
+    _initProperties(el, plugin, opt_content) {
+      el.plugin = plugin;
+      if (opt_content) {
+        el.content = opt_content;
+      }
+      for (const {name, value} of this._getEndpointParams()) {
+        el[name] = value;
+      }
+    },
+
+    _appendChild(el) {
+      Polymer.dom(this.root).appendChild(el);
+    },
+
+    _initModule({moduleName, plugin, type, domHook}) {
+      let el;
+      switch (type) {
+        case 'decorate':
+          el = this._initDecoration(moduleName, plugin);
+          break;
+        case 'replace':
+          el = this._initReplacement(moduleName, plugin);
+          break;
+      }
+      if (el) {
+        domHook.handleInstanceAttached(el);
+      }
+      this._domHooks.set(el, domHook);
+    },
+
+    ready() {
+      Gerrit._endpoints.onNewEndpoint(this.name, this._initModule.bind(this));
+      Gerrit.awaitPluginsLoaded().then(() => Promise.all(
+          Gerrit._endpoints.getPlugins(this.name).map(
+              pluginUrl => this._import(pluginUrl)))
+      ).then(() =>
+        Gerrit._endpoints
+            .getDetails(this.name)
+            .forEach(this._initModule, this)
+      );
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
new file mode 100644
index 0000000..c7ab3d9
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html
@@ -0,0 +1,127 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-endpoint-decorator</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-endpoint-decorator.html">
+<link rel="import" href="../gr-endpoint-param/gr-endpoint-param.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-endpoint-decorator name="foo">
+      <gr-endpoint-param name="someparam" value="barbar"></gr-endpoint-param>
+    </gr-endpoint-decorator>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-endpoint-decorator', () => {
+    let sandbox;
+    let element;
+    let plugin;
+    let domHookStub;
+
+    setup(done => {
+      Gerrit._endpoints = new GrPluginEndpoints();
+
+      sandbox = sinon.sandbox.create();
+
+      domHookStub = {
+        handleInstanceAttached: sandbox.stub(),
+        handleInstanceDetached: sandbox.stub(),
+        getPublicAPI: () => domHookStub,
+      };
+      sandbox.stub(
+          GrDomHooksManager.prototype, 'getDomHook').returns(domHookStub);
+
+      // NB: Order is important.
+      Gerrit.install(p => {
+        plugin = p;
+        plugin.registerCustomComponent('foo', 'some-module');
+        plugin.registerCustomComponent('foo', 'other-module', {replace: true});
+        plugin.registerCustomComponent('bar', 'some-module');
+      }, '0.1', 'http://some/plugin/url.html');
+
+      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+
+      element = fixture('basic');
+      sandbox.stub(element, '_initDecoration').returns({});
+      sandbox.stub(element, '_initReplacement').returns({});
+      sandbox.stub(element, 'importHref', (url, resolve) => resolve());
+
+      flush(done);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('imports plugin-provided module', () => {
+      assert.isTrue(
+          element.importHref.calledWith(new URL('http://some/plugin/url.html')));
+    });
+
+    test('inits decoration dom hook', () => {
+      assert.strictEqual(
+          element._initDecoration.lastCall.args[0], 'some-module');
+      assert.strictEqual(
+          element._initDecoration.lastCall.args[1], plugin);
+    });
+
+    test('inits replacement dom hook', () => {
+      assert.strictEqual(
+          element._initReplacement.lastCall.args[0], 'other-module');
+      assert.strictEqual(
+          element._initReplacement.lastCall.args[1], plugin);
+    });
+
+    test('calls dom hook handleInstanceAttached', () => {
+      assert.equal(domHookStub.handleInstanceAttached.callCount, 2);
+    });
+
+    test('calls dom hook handleInstanceDetached', () => {
+      element.detached();
+      assert.equal(domHookStub.handleInstanceDetached.callCount, 2);
+    });
+
+    test('installs modules on late registration', done => {
+      domHookStub.handleInstanceAttached.reset();
+      plugin.registerCustomComponent('foo', 'noob-noob');
+      flush(() => {
+        assert.equal(domHookStub.handleInstanceAttached.callCount, 1);
+        assert.strictEqual(
+            element._initDecoration.lastCall.args[0], 'noob-noob');
+        assert.strictEqual(
+            element._initDecoration.lastCall.args[1], plugin);
+        done();
+      });
+    });
+
+    test('params', () => {
+      const instance = document.createElement('foo');
+      element._initProperties(instance, plugin);
+      assert.equal(instance.someparam, 'barbar');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
new file mode 100644
index 0000000..1aa9e7c
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-endpoint-param">
+  <script src="gr-endpoint-param.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
new file mode 100644
index 0000000..5a2ab59
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-endpoint-param/gr-endpoint-param.js
@@ -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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-endpoint-param',
+    properties: {
+      name: String,
+      value: Object,
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
new file mode 100644
index 0000000..d62ac99
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-event-helper">
+  <script src="gr-event-helper.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
new file mode 100644
index 0000000..e750c07
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper.js
@@ -0,0 +1,69 @@
+// 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.
+(function(window) {
+  'use strict';
+
+  function GrEventHelper(element) {
+    this.element = element;
+    this._unsubscribers = [];
+  }
+
+  /**
+   * Add a callback to element click or touch.
+   * The callback may return false to prevent event bubbling.
+   * @param {function(Event):boolean} callback
+   * @return {function()} Unsubscribe function.
+   */
+  GrEventHelper.prototype.onTap = function(callback) {
+    return this._listen(this.element, callback);
+  };
+
+  /**
+   * Add a callback to element click or touch ahead of normal flow.
+   * Callback is installed on parent during capture phase.
+   * https://www.w3.org/TR/DOM-Level-3-Events/#event-flow
+   * The callback may return false to cancel regular event listeners.
+   * @param {function(Event):boolean} callback
+   * @return {function()} Unsubscribe function.
+   */
+  GrEventHelper.prototype.captureTap = function(callback) {
+    return this._listen(this.element.parentElement, callback, {capture: true});
+  };
+
+  GrEventHelper.prototype._listen = function(container, callback, opt_options) {
+    const capture = opt_options && opt_options.capture;
+    const handler = e => {
+      if (e.path.indexOf(this.element) !== -1) {
+        let mayContinue = true;
+        try {
+          mayContinue = callback(e);
+        } catch (e) {
+          console.warn(`Plugin error handing event: ${e}`);
+        }
+        if (mayContinue === false) {
+          e.stopImmediatePropagation();
+          e.stopPropagation();
+          e.preventDefault();
+        }
+      }
+    };
+    container.addEventListener('tap', handler, capture);
+    const unsubscribe = () =>
+      container.removeEventListener('tap', handler, capture);
+    this._unsubscribers.push(unsubscribe);
+    return unsubscribe;
+  };
+
+  window.GrEventHelper = GrEventHelper;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
new file mode 100644
index 0000000..9d42851
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-event-helper/gr-event-helper_test.html
@@ -0,0 +1,96 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-event-helper</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-event-helper.html"/>
+
+<script>void(0);</script>
+
+<dom-element id="some-element">
+  <script>
+    Polymer({
+      is: 'some-element',
+      properties: {
+        fooBar: {
+          type: Object,
+          notify: true,
+        },
+      },
+    });
+  </script>
+</dom-element>
+
+<test-fixture id="basic">
+  <template>
+    <some-element></some-element>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-event-helper tests', () => {
+    let element;
+    let instance;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      instance = new GrEventHelper(element);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('onTap()', done => {
+      instance.onTap(() => {
+        done();
+      });
+      element.fire('tap');
+    });
+
+    test('onTap() cancel', () => {
+      const tapStub = sandbox.stub();
+      element.parentElement.addEventListener('tap', tapStub);
+      instance.onTap(() => false);
+      element.fire('tap');
+      flushAsynchronousOperations();
+      assert.isFalse(tapStub.called);
+    });
+
+    test('captureTap()', done => {
+      instance.captureTap(() => {
+        done();
+      });
+      element.fire('tap');
+    });
+
+    test('captureTap() cancels tap()', () => {
+      const tapStub = sandbox.stub();
+      element.addEventListener('tap', tapStub);
+      instance.captureTap(() => false);
+      element.fire('tap');
+      flushAsynchronousOperations();
+      assert.isFalse(tapStub.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
new file mode 100644
index 0000000..623d304
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+
+<dom-module id="gr-external-style">
+  <template>
+    <content></content>
+  </template>
+  <script src="gr-external-style.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
new file mode 100644
index 0000000..a4f1f74
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style.js
@@ -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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-external-style',
+
+    properties: {
+      name: String,
+    },
+
+    _import(url) {
+      return new Promise((resolve, reject) => {
+        this.importHref(url, resolve, reject);
+      });
+    },
+
+    _applyStyle(name) {
+      const s = document.createElement('style', 'custom-style');
+      s.setAttribute('include', name);
+      Polymer.dom(this.root).appendChild(s);
+    },
+
+    ready() {
+      Gerrit.awaitPluginsLoaded().then(() => Promise.all(
+          Gerrit._endpoints.getPlugins(this.name).map(
+              pluginUrl => this._import(pluginUrl)))
+      ).then(() => {
+        const moduleNames = Gerrit._endpoints.getModules(this.name);
+        for (const name of moduleNames) {
+          this._applyStyle(name);
+        }
+      });
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
new file mode 100644
index 0000000..bc24c2b
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-external-style/gr-external-style_test.html
@@ -0,0 +1,69 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-external-style</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-external-style.html">
+
+<test-fixture id="basic">
+  <template>
+    <gr-external-style name="foo"></gr-external-style>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-external-style integration tests', () => {
+    let sandbox;
+    let element;
+
+    setup(done => {
+      sandbox = sinon.sandbox.create();
+
+      // NB: Order is important.
+      let plugin;
+      Gerrit.install(p => {
+        plugin = p;
+        plugin.registerStyleModule('foo', 'some-module');
+      }, '0.1', 'http://some/plugin/url.html');
+
+      sandbox.stub(Gerrit, 'awaitPluginsLoaded').returns(Promise.resolve());
+
+      element = fixture('basic');
+      sandbox.stub(element, '_applyStyle');
+      sandbox.stub(element, 'importHref', (url, resolve) => { resolve(); });
+
+      flush(done);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('imports plugin-provided module', () => {
+      assert.isTrue(element.importHref.calledWith(
+          new URL('http://some/plugin/url.html')));
+    });
+
+    test('applies plugin-provided styles', () => {
+      assert.isTrue(element._applyStyle.calledWith('some-module'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
new file mode 100644
index 0000000..ad4e2b0
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+
+<dom-module id="gr-plugin-host">
+  <script src="gr-plugin-host.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
new file mode 100644
index 0000000..d3ad997
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host.js
@@ -0,0 +1,95 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-plugin-host',
+
+    properties: {
+      config: {
+        type: Object,
+        observer: '_configChanged',
+      },
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+    ],
+
+    _configChanged(config) {
+      const plugins = config.plugin;
+      const htmlPlugins = plugins.html_resource_paths || [];
+      const jsPlugins = this._handleMigrations(plugins.js_resource_paths || [],
+          htmlPlugins);
+      const defaultTheme = config.default_theme;
+      if (defaultTheme) {
+        // Make theme first to be first to load.
+        htmlPlugins.unshift(defaultTheme);
+      }
+      Gerrit._setPluginsCount(jsPlugins.length + htmlPlugins.length);
+      this._loadJsPlugins(jsPlugins);
+      this._importHtmlPlugins(htmlPlugins);
+    },
+
+    /**
+     * Omit .js plugins that have .html counterparts.
+     * For example, if plugin provides foo.js and foo.html, skip foo.js.
+     */
+    _handleMigrations(jsPlugins, htmlPlugins) {
+      return jsPlugins.filter(url => {
+        const counterpart = url.replace(/\.js$/, '.html');
+        return !htmlPlugins.includes(counterpart);
+      });
+    },
+
+    /**
+     * @suppress {checkTypes}
+     * States that it expects no more than 3 parameters, but that's not true.
+     * @todo (beckysiegel) check Polymer annotations and submit change.
+     */
+    _importHtmlPlugins(plugins) {
+      for (const url of plugins) {
+        // onload (second param) needs to be a function. When null or undefined
+        // were passed, plugins were not loaded correctly.
+        this.importHref(
+            this._urlFor(url), () => {}, Gerrit._pluginInstalled, true);
+      }
+    },
+
+    _loadJsPlugins(plugins) {
+      for (const url of plugins) {
+        this._createScriptTag(this._urlFor(url));
+      }
+    },
+
+    _createScriptTag(url) {
+      const el = document.createElement('script');
+      el.defer = true;
+      el.src = url;
+      el.onerror = Gerrit._pluginInstalled;
+      return document.body.appendChild(el);
+    },
+
+    _urlFor(pathOrUrl) {
+      if (pathOrUrl.startsWith('http')) {
+        return pathOrUrl;
+      }
+      if (!pathOrUrl.startsWith('/')) {
+        pathOrUrl = '/' + pathOrUrl;
+      }
+      return this.getBaseUrl() + pathOrUrl;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
new file mode 100644
index 0000000..dd664ff
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-plugin-host/gr-plugin-host_test.html
@@ -0,0 +1,175 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-host</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-plugin-host.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-plugin-host></gr-plugin-host>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-diff tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(document.body, 'appendChild');
+      sandbox.stub(element, 'importHref');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('counts plugins', () => {
+      sandbox.stub(Gerrit, '_setPluginsCount');
+      element.config = {
+        plugin: {
+          html_resource_paths: ['foo/bar', 'baz'],
+          js_resource_paths: ['42'],
+        },
+      };
+      assert.isTrue(Gerrit._setPluginsCount.calledWith(3));
+    });
+
+    test('imports relative html plugins from config', () => {
+      element.config = {
+        plugin: {html_resource_paths: ['foo/bar', 'baz']},
+      };
+      assert.equal(element.importHref.firstCall.args[0], '/foo/bar');
+      assert.equal(element.importHref.firstCall.args[2],
+          Gerrit._pluginInstalled);
+      assert.isTrue(element.importHref.firstCall.args[3]);
+
+      assert.equal(element.importHref.secondCall.args[0], '/baz');
+      assert.equal(element.importHref.secondCall.args[2],
+          Gerrit._pluginInstalled);
+      assert.isTrue(element.importHref.secondCall.args[3]);
+    });
+
+    test('imports relative html plugins from config with a base url', () => {
+      sandbox.stub(element, 'getBaseUrl').returns('/the-base');
+      element.config = {
+        plugin: {html_resource_paths: ['foo/bar', 'baz']}};
+      assert.equal(element.importHref.firstCall.args[0], '/the-base/foo/bar');
+      assert.equal(element.importHref.firstCall.args[2],
+          Gerrit._pluginInstalled);
+      assert.isTrue(element.importHref.firstCall.args[3]);
+
+      assert.equal(element.importHref.secondCall.args[0], '/the-base/baz');
+      assert.equal(element.importHref.secondCall.args[2],
+          Gerrit._pluginInstalled);
+      assert.isTrue(element.importHref.secondCall.args[3]);
+    });
+
+    test('inportHref is not called with null callback functions', () => {
+      const plugins = ['path/to/plugin'];
+      element._importHtmlPlugins(plugins);
+      assert.isTrue(element.importHref.calledOnce);
+      assert.isFunction(element.importHref.lastCall.args[1]);
+      assert.isFunction(element.importHref.lastCall.args[2]);
+    });
+
+    test('imports absolute html plugins from config', () => {
+      element.config = {
+        plugin: {
+          html_resource_paths: [
+            'http://example.com/foo/bar',
+            'https://example.com/baz',
+          ],
+        },
+      };
+      assert.equal(element.importHref.firstCall.args[0],
+          'http://example.com/foo/bar');
+      assert.equal(element.importHref.firstCall.args[2],
+          Gerrit._pluginInstalled);
+      assert.isTrue(element.importHref.firstCall.args[3]);
+
+      assert.equal(element.importHref.secondCall.args[0],
+          'https://example.com/baz');
+      assert.equal(element.importHref.secondCall.args[2],
+          Gerrit._pluginInstalled);
+      assert.isTrue(element.importHref.secondCall.args[3]);
+    });
+
+    test('adds js plugins from config to the body', () => {
+      element.config = {plugin: {js_resource_paths: ['foo/bar', 'baz']}};
+      assert.isTrue(document.body.appendChild.calledTwice);
+    });
+
+    test('imports relative js plugins from config', () => {
+      sandbox.stub(element, '_createScriptTag');
+      element.config = {plugin: {js_resource_paths: ['foo/bar', 'baz']}};
+      assert.isTrue(element._createScriptTag.calledWith('/foo/bar'));
+      assert.isTrue(element._createScriptTag.calledWith('/baz'));
+    });
+
+    test('imports relative html plugins from config with a base url', () => {
+      sandbox.stub(element, '_createScriptTag');
+      sandbox.stub(element, 'getBaseUrl').returns('/the-base');
+      element.config = {plugin: {js_resource_paths: ['foo/bar', 'baz']}};
+      assert.isTrue(element._createScriptTag.calledWith('/the-base/foo/bar'));
+      assert.isTrue(element._createScriptTag.calledWith('/the-base/baz'));
+    });
+
+    test('imports absolute html plugins from config', () => {
+      sandbox.stub(element, '_createScriptTag');
+      element.config = {
+        plugin: {
+          js_resource_paths: [
+            'http://example.com/foo/bar',
+            'https://example.com/baz',
+          ],
+        },
+      };
+      assert.isTrue(element._createScriptTag.calledWith(
+          'http://example.com/foo/bar'));
+      assert.isTrue(element._createScriptTag.calledWith(
+          'https://example.com/baz'));
+    });
+
+    test('default theme is loaded with html plugins', () => {
+      element.config = {
+        default_theme: '/oof',
+        plugin: {
+          html_resource_paths: ['some'],
+        },
+      };
+      assert.equal(element.importHref.firstCall.args[0], '/oof');
+      assert.equal(element.importHref.firstCall.args[2],
+          Gerrit._pluginInstalled);
+      assert.isTrue(element.importHref.firstCall.args[3]);
+
+      assert.equal(element.importHref.secondCall.args[0], '/some');
+      assert.equal(element.importHref.secondCall.args[2],
+          Gerrit._pluginInstalled);
+      assert.isTrue(element.importHref.secondCall.args[3]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
new file mode 100644
index 0000000..3ccb3fd
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.html
@@ -0,0 +1,28 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+
+<dom-module id="gr-plugin-popup">
+  <template>
+    <style include="shared-styles"></style>
+    <gr-overlay id="overlay" with-backdrop>
+      <content></content>
+    </gr-overlay>
+  </template>
+  <script src="gr-plugin-popup.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
new file mode 100644
index 0000000..8286eae
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup.js
@@ -0,0 +1,28 @@
+// 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.
+(function(window) {
+  'use strict';
+  Polymer({
+    is: 'gr-plugin-popup',
+    get opened() {
+      return this.$.overlay.opened;
+    },
+    open() {
+      return this.$.overlay.open();
+    },
+    close() {
+      this.$.overlay.close();
+    },
+  });
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
new file mode 100644
index 0000000..2dbf96d
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-plugin-popup_test.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-popup</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-plugin-popup.html"/>
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-plugin-popup></gr-plugin-popup>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-plugin-popup tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      stub('gr-overlay', {
+        open: sandbox.stub().returns(Promise.resolve()),
+        close: sandbox.stub(),
+      });
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('exists', () => {
+      assert.isOk(element);
+    });
+
+    test('open uses open() from gr-overlay', () => {
+      return element.open().then(() => {
+        assert.isTrue(element.$.overlay.open.called);
+      });
+    });
+
+    test('close uses close() from gr-overlay', () => {
+      element.close();
+      assert.isTrue(element.$.overlay.close.called);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
new file mode 100644
index 0000000..6bf37de
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="gr-plugin-popup.html">
+
+<dom-module id="gr-popup-interface">
+  <script src="gr-popup-interface.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
new file mode 100644
index 0000000..e62e882
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface.js
@@ -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.
+(function(window) {
+  'use strict';
+
+  /**
+   * Plugin popup API.
+   * Provides method for opening and closing popups from plugin.
+   * opt_moduleName is a name of custom element that will be automatically
+   * inserted on popup opening.
+   * @param {!Object} plugin
+   * @param {opt_moduleName=} string
+   */
+  function GrPopupInterface(plugin, opt_moduleName) {
+    this.plugin = plugin;
+    this._openingPromise = null;
+    this._popup = null;
+    this._moduleName = opt_moduleName || null;
+  }
+
+  GrPopupInterface.prototype._getElement = function() {
+    return Polymer.dom(this._popup);
+  };
+
+  /**
+   * Opens the popup, inserts it into DOM over current UI.
+   * Creates the popup if not previously created. Creates popup content element,
+   * if it was provided with constructor.
+   * @returns {!Promise<!Object>}
+   */
+  GrPopupInterface.prototype.open = function() {
+    if (!this._openingPromise) {
+      this._openingPromise =
+          this.plugin.hook('plugin-overlay').getLastAttached()
+      .then(hookEl => {
+        const popup = document.createElement('gr-plugin-popup');
+        if (this._moduleName) {
+          const el = Polymer.dom(popup).appendChild(
+              document.createElement(this._moduleName));
+          el.plugin = this.plugin;
+        }
+        this._popup = Polymer.dom(hookEl).appendChild(popup);
+        Polymer.dom.flush();
+        return this._popup.open().then(() => this);
+      });
+    }
+    return this._openingPromise;
+  };
+
+  /**
+   * Hides the popup.
+   */
+  GrPopupInterface.prototype.close = function() {
+    if (!this._popup) { return; }
+    this._popup.close();
+    this._openingPromise = null;
+  };
+
+  window.GrPopupInterface = GrPopupInterface;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
new file mode 100644
index 0000000..7d9dd28
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-popup-interface/gr-popup-interface_test.html
@@ -0,0 +1,112 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-popup-interface</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-popup-interface.html"/>
+
+<script>void(0);</script>
+
+<test-fixture id="container">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<dom-module id="gr-user-test-popup">
+  <template>
+    <div id="barfoo">some test module</div>
+  </template>
+  <script>Polymer({is: 'gr-user-test-popup'});</script>
+</dom-module>
+
+<script>
+  suite('gr-popup-interface tests', () => {
+    let container;
+    let instance;
+    let plugin;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      container = fixture('container');
+      sandbox.stub(plugin, 'hook').returns({
+        getLastAttached() {
+          return Promise.resolve(container);
+        },
+      });
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('manual', () => {
+      setup(() => {
+        instance = new GrPopupInterface(plugin);
+      });
+
+      test('open', () => {
+        return instance.open().then(api => {
+          assert.strictEqual(api, instance);
+          const manual = document.createElement('div');
+          manual.id = 'foobar';
+          manual.innerHTML = 'manual content';
+          api._getElement().appendChild(manual);
+          flushAsynchronousOperations();
+          assert.equal(
+              container.querySelector('#foobar').textContent, 'manual content');
+        });
+      });
+
+      test('close', () => {
+        return instance.open().then(api => {
+          assert.isTrue(api._getElement().node.opened);
+          api.close();
+          assert.isFalse(api._getElement().node.opened);
+        });
+      });
+    });
+
+    suite('components', () => {
+      setup(() => {
+        instance = new GrPopupInterface(plugin, 'gr-user-test-popup');
+      });
+
+      test('open', () => {
+        return instance.open().then(api => {
+          assert.isNotNull(
+              Polymer.dom(container).querySelector('gr-user-test-popup'));
+        });
+      });
+
+      test('close', () => {
+        return instance.open().then(api => {
+          assert.isTrue(api._getElement().node.opened);
+          api.close();
+          assert.isFalse(api._getElement().node.opened);
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html
new file mode 100644
index 0000000..233e83e
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-custom-plugin-header.html
@@ -0,0 +1,45 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-custom-plugin-header">
+  <template>
+    <style>
+      img {
+        width: 1em;
+        height: 1em;
+        vertical-align: middle;
+      }
+      .title {
+        margin-left: .25em;
+      }
+    </style>
+    <span>
+      <img src="[[logoUrl]]" hidden$="[[!logoUrl]]">
+      <span class="title">[[title]]</span>
+    </span>
+  </template>
+  <script>
+    Polymer({
+      is: 'gr-custom-plugin-header',
+      properties: {
+        logoUrl: String,
+        title: String,
+      },
+    });
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
new file mode 100644
index 0000000..973254a
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
+<link rel="import" href="gr-custom-plugin-header.html">
+
+<dom-module id="gr-theme-api">
+  <script src="gr-theme-api.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
new file mode 100644
index 0000000..d57b301
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api.js
@@ -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.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrThemeApi) { return; }
+
+  function GrThemeApi(plugin) {
+    this.plugin = plugin;
+  }
+
+  GrThemeApi.prototype.setHeaderLogoAndTitle = function(logoUrl, title) {
+    this.plugin.hook('header-title', {replace: true}).onAttached(
+        element => {
+          const customHeader =
+                document.createElement('gr-custom-plugin-header');
+          customHeader.logoUrl = logoUrl;
+          customHeader.title = title;
+          element.appendChild(customHeader);
+        });
+  };
+
+  window.GrThemeApi = GrThemeApi;
+})(window);
diff --git a/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
new file mode 100644
index 0000000..74dff66
--- /dev/null
+++ b/polygerrit-ui/app/elements/plugins/gr-theme-api/gr-theme-api_test.html
@@ -0,0 +1,81 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-theme-api</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../gr-endpoint-decorator/gr-endpoint-decorator.html">
+<link rel="import" href="gr-theme-api.html">
+
+<script>void(0);</script>
+
+<test-fixture id="header-title">
+  <template>
+    <gr-endpoint-decorator name="header-title">
+      <span class="titleText"></span>
+    </gr-endpoint-decorator>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-theme-api tests', () => {
+    let sandbox;
+    let theme;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      theme = plugin.theme();
+    });
+
+    teardown(() => {
+      theme = null;
+      sandbox.restore();
+    });
+
+    test('exists', () => {
+      assert.isOk(theme);
+    });
+
+    suite('header-title', () => {
+      let customHeader;
+
+      setup(() => {
+        fixture('header-title');
+        stub('gr-custom-plugin-header', {
+          ready() { customHeader = this; },
+        });
+        Gerrit._resolveAllPluginsLoaded();
+      });
+
+      test('sets logo and title', done => {
+        theme.setHeaderLogoAndTitle('foo.jpg', 'bar');
+        flush(() => {
+          assert.isNotNull(customHeader);
+          assert.equal(customHeader.logoUrl, 'foo.jpg');
+          assert.equal(customHeader.title, 'bar');
+          done();
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
index 0c61998..55164e0 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.html
@@ -19,12 +19,15 @@
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
 
 <dom-module id="gr-account-info">
   <template>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <section>
         <span class="title">ID</span>
         <span class="value">[[_account._account_id]]</span>
@@ -41,17 +44,28 @@
               date-str="[[_account.registered_on]]"></gr-date-formatter>
         </span>
       </section>
-      <section>
+      <section id="usernameSection">
         <span class="title">Username</span>
-        <span class="value">[[_account.username]]</span>
+        <span
+            hidden$="[[usernameMutable]]"
+            class="value">[[_username]]</span>
+        <span
+            hidden$="[[!usernameMutable]]"
+            class="value">
+          <input
+              is="iron-input"
+              id="usernameInput"
+              disabled="[[_saving]]"
+              on-keydown="_handleKeydown"
+              bind-value="{{_username}}">
       </section>
       <section id="nameSection">
         <span class="title">Full name</span>
         <span
-            hidden$="[[mutable]]"
+            hidden$="[[nameMutable]]"
             class="value">[[_account.name]]</span>
         <span
-            hidden$="[[!mutable]]"
+            hidden$="[[!nameMutable]]"
             class="value">
           <input
               is="iron-input"
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
index 91bc628..3cec65a 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info.js
@@ -24,25 +24,26 @@
      */
 
     properties: {
-      mutable: {
+      usernameMutable: {
         type: Boolean,
         notify: true,
-        computed: '_computeMutable(_serverConfig)',
+        computed: '_computeUsernameMutable(_serverConfig, _account.username)',
+      },
+      nameMutable: {
+        type: Boolean,
+        notify: true,
+        computed: '_computeNameMutable(_serverConfig)',
       },
       hasUnsavedChanges: {
         type: Boolean,
         notify: true,
-        computed: '_computeHasUnsavedChanges(_hasNameChange, _hasStatusChange)',
+        computed: '_computeHasUnsavedChanges(_hasNameChange, ' +
+            '_hasUsernameChange, _hasStatusChange)',
       },
 
-      _hasNameChange: {
-        type: Boolean,
-        value: false,
-      },
-      _hasStatusChange: {
-        type: Boolean,
-        value: false,
-      },
+      _hasNameChange: Boolean,
+      _hasUsernameChange: Boolean,
+      _hasStatusChange: Boolean,
       _loading: {
         type: Boolean,
         value: false,
@@ -51,8 +52,13 @@
         type: Boolean,
         value: false,
       },
+      /** @type {?} */
       _account: Object,
       _serverConfig: Object,
+      _username: {
+        type: String,
+        observer: '_usernameChanged',
+      },
     },
 
     observers: [
@@ -60,25 +66,32 @@
       '_statusChanged(_account.status)',
     ],
 
-    loadData: function() {
-      var promises = [];
+    loadData() {
+      const promises = [];
 
       this._loading = true;
 
-      promises.push(this.$.restAPI.getConfig().then(function(config) {
+      promises.push(this.$.restAPI.getConfig().then(config => {
         this._serverConfig = config;
-      }.bind(this)));
+      }));
 
-      promises.push(this.$.restAPI.getAccount().then(function(account) {
+      promises.push(this.$.restAPI.getAccount().then(account => {
+        this._hasNameChange = false;
+        this._hasUsernameChange = false;
+        this._hasStatusChange = false;
+        // Provide predefined value for username to trigger computation of
+        // username mutability.
+        account.username = account.username || '';
         this._account = account;
-      }.bind(this)));
+        this._username = account.username;
+      }));
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         this._loading = false;
-      }.bind(this));
+      });
     },
 
-    save: function() {
+    save() {
       if (!this.hasUnsavedChanges) {
         return Promise.resolve();
       }
@@ -87,46 +100,65 @@
       // Set only the fields that have changed.
       // Must be done in sequence to avoid race conditions (@see Issue 5721)
       return this._maybeSetName()
+          .then(this._maybeSetUsername.bind(this))
           .then(this._maybeSetStatus.bind(this))
-          .then(function() {
+          .then(() => {
             this._hasNameChange = false;
             this._hasStatusChange = false;
             this._saving = false;
             this.fire('account-detail-update');
-          }.bind(this));
+          });
     },
 
-    _maybeSetName: function() {
-      return this._hasNameChange && this.mutable ?
-                this.$.restAPI.setAccountName(this._account.name) :
-                Promise.resolve();
+    _maybeSetName() {
+      return this._hasNameChange && this.nameMutable ?
+          this.$.restAPI.setAccountName(this._account.name) :
+          Promise.resolve();
     },
 
-    _maybeSetStatus: function() {
+    _maybeSetUsername() {
+      return this._hasUsernameChange && this.usernameMutable ?
+          this.$.restAPI.setAccountUsername(this._username) :
+          Promise.resolve();
+    },
+
+    _maybeSetStatus() {
       return this._hasStatusChange ?
           this.$.restAPI.setAccountStatus(this._account.status) :
           Promise.resolve();
     },
 
-    _computeHasUnsavedChanges: function(name, status) {
-      return name || status;
+    _computeHasUnsavedChanges(nameChanged, usernameChanged, statusChanged) {
+      return nameChanged || usernameChanged || statusChanged;
     },
 
-    _computeMutable: function(config) {
-      return config.auth.editable_account_fields.indexOf('FULL_NAME') !== -1;
+    _computeUsernameMutable(config, username) {
+      // Username may not be changed once it is set.
+      return config.auth.editable_account_fields.includes('USER_NAME') &&
+          !username;
     },
 
-    _statusChanged: function() {
+    _computeNameMutable(config) {
+      return config.auth.editable_account_fields.includes('FULL_NAME');
+    },
+
+    _statusChanged() {
       if (this._loading) { return; }
       this._hasStatusChange = true;
     },
 
-    _nameChanged: function() {
+    _usernameChanged() {
+      if (this._loading || !this._account) { return; }
+      this._hasUsernameChange =
+          (this._account.username || '') !== (this._username || '');
+    },
+
+    _nameChanged() {
       if (this._loading) { return; }
       this._hasNameChange = true;
     },
 
-    _handleKeydown: function(e) {
+    _handleKeydown(e) {
       if (e.keyCode === 13) { // Enter
         e.stopPropagation();
         this.save();
diff --git a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
index cf35450..82997a5 100644
--- a/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-account-info/gr-account-info_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-account-info.html">
 
 <script>void(0);</script>
@@ -33,16 +32,16 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-info tests', function() {
-    var element;
-    var account;
-    var config;
-    var sandbox;
+  suite('gr-account-info tests', () => {
+    let element;
+    let account;
+    let config;
+    let sandbox;
 
     function valueOf(title) {
-      var sections = Polymer.dom(element.root).querySelectorAll('section');
-      var titleEl;
-      for (var i = 0; i < sections.length; i++) {
+      const sections = Polymer.dom(element.root).querySelectorAll('section');
+      let titleEl;
+      for (let i = 0; i < sections.length; i++) {
         titleEl = sections[i].querySelector('.title');
         if (titleEl.textContent === title) {
           return sections[i].querySelector('.value');
@@ -50,7 +49,7 @@
       }
     }
 
-    setup(function(done) {
+    setup(done => {
       sandbox = sinon.sandbox.create();
       account = {
         _account_id: 123,
@@ -62,22 +61,22 @@
       config = {auth: {editable_account_fields: []}};
 
       stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve(account); },
-        getConfig: function() { return Promise.resolve(config); },
-        getPreferences: function() {
+        getAccount() { return Promise.resolve(account); },
+        getConfig() { return Promise.resolve(config); },
+        getPreferences() {
           return Promise.resolve({time_format: 'HHMM_12'});
         },
       });
       element = fixture('basic');
       // Allow the element to render.
-      element.loadData().then(function() { flush(done); });
+      element.loadData().then(() => { flush(done); });
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('basic account info render', function() {
+    test('basic account info render', () => {
       assert.isFalse(element._loading);
 
       assert.equal(valueOf('ID').textContent, account._account_id);
@@ -85,51 +84,83 @@
       assert.equal(valueOf('Username').textContent, account.username);
     });
 
-    test('user name render (immutable)', function() {
-      var section = element.$.nameSection;
-      var displaySpan = section.querySelectorAll('.value')[0];
-      var inputSpan = section.querySelectorAll('.value')[1];
+    test('full name render (immutable)', () => {
+      const section = element.$.nameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
 
-      assert.isFalse(element.mutable);
+      assert.isFalse(element.nameMutable);
       assert.isFalse(displaySpan.hasAttribute('hidden'));
       assert.equal(displaySpan.textContent, account.name);
       assert.isTrue(inputSpan.hasAttribute('hidden'));
     });
 
-    test('user name render (mutable)', function() {
+    test('full name render (mutable)', () => {
       element.set('_serverConfig',
           {auth: {editable_account_fields: ['FULL_NAME']}});
 
-      var section = element.$.nameSection;
-      var displaySpan = section.querySelectorAll('.value')[0];
-      var inputSpan = section.querySelectorAll('.value')[1];
+      const section = element.$.nameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
 
-      assert.isTrue(element.mutable);
+      assert.isTrue(element.nameMutable);
       assert.isTrue(displaySpan.hasAttribute('hidden'));
       assert.equal(element.$.nameInput.bindValue, account.name);
       assert.isFalse(inputSpan.hasAttribute('hidden'));
     });
 
-    suite('account info edit', function() {
-      var nameChangedSpy;
-      var statusChangedSpy;
-      var nameStub;
-      var statusStub;
+    test('username render (immutable)', () => {
+      const section = element.$.usernameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
 
-      setup(function() {
+      assert.isFalse(element.usernameMutable);
+      assert.isFalse(displaySpan.hasAttribute('hidden'));
+      assert.equal(displaySpan.textContent, account.username);
+      assert.isTrue(inputSpan.hasAttribute('hidden'));
+    });
+
+    test('username render (mutable)', () => {
+      element.set('_serverConfig',
+          {auth: {editable_account_fields: ['USER_NAME']}});
+      element.set('_account.username', '');
+      element.set('_username', '');
+
+      const section = element.$.usernameSection;
+      const displaySpan = section.querySelectorAll('.value')[0];
+      const inputSpan = section.querySelectorAll('.value')[1];
+
+      assert.isTrue(element.usernameMutable);
+      assert.isTrue(displaySpan.hasAttribute('hidden'));
+      assert.equal(element.$.usernameInput.bindValue, account.username);
+      assert.isFalse(inputSpan.hasAttribute('hidden'));
+    });
+
+    suite('account info edit', () => {
+      let nameChangedSpy;
+      let usernameChangedSpy;
+      let statusChangedSpy;
+      let nameStub;
+      let usernameStub;
+      let statusStub;
+
+      setup(() => {
         nameChangedSpy = sandbox.spy(element, '_nameChanged');
+        usernameChangedSpy = sandbox.spy(element, '_usernameChanged');
         statusChangedSpy = sandbox.spy(element, '_statusChanged');
         element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME']}});
+          {auth: {editable_account_fields: ['FULL_NAME', 'USER_NAME']}});
 
         nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
-            function(name) { return Promise.resolve(); });
+            name => Promise.resolve());
+        usernameStub = sandbox.stub(element.$.restAPI, 'setAccountUsername',
+            username => Promise.resolve());
         statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            function(status) { return Promise.resolve(); });
+            status => Promise.resolve());
       });
 
-      test('name', function(done) {
-        assert.isTrue(element.mutable);
+      test('name', done => {
+        assert.isTrue(element.nameMutable);
         assert.isFalse(element.hasUnsavedChanges);
 
         element.set('_account.name', 'new name');
@@ -138,18 +169,40 @@
         assert.isFalse(statusChangedSpy.called);
         assert.isTrue(element.hasUnsavedChanges);
 
-        MockInteractions.pressAndReleaseKeyOn(element.$.nameInput, 13);
-
-        assert.isTrue(nameStub.called);
-        assert.isFalse(statusStub.called);
-        nameStub.lastCall.returnValue.then(function() {
-          assert.equal(nameStub.lastCall.args[0], 'new name');
-          done();
+        element.save().then(() => {
+          assert.isFalse(usernameStub.called);
+          assert.isTrue(nameStub.called);
+          assert.isFalse(statusStub.called);
+          nameStub.lastCall.returnValue.then(() => {
+            assert.equal(nameStub.lastCall.args[0], 'new name');
+            done();
+          });
         });
       });
 
-      test('status', function(done) {
-        assert.isTrue(element.mutable);
+      test('username', done => {
+        element.set('_account.username', '');
+        element._hasUsernameChange = false;
+        assert.isTrue(element.usernameMutable);
+
+        element.set('_username', 'new username');
+
+        assert.isTrue(usernameChangedSpy.called);
+        assert.isFalse(statusChangedSpy.called);
+        assert.isTrue(element.hasUnsavedChanges);
+
+        element.save().then(() => {
+          assert.isTrue(usernameStub.called);
+          assert.isFalse(nameStub.called);
+          assert.isFalse(statusStub.called);
+          usernameStub.lastCall.returnValue.then(() => {
+            assert.equal(usernameStub.lastCall.args[0], 'new username');
+            done();
+          });
+        });
+      });
+
+      test('status', done => {
         assert.isFalse(element.hasUnsavedChanges);
 
         element.set('_account.status', 'new status');
@@ -158,10 +211,11 @@
         assert.isTrue(statusChangedSpy.called);
         assert.isTrue(element.hasUnsavedChanges);
 
-        element.save().then(function() {
+        element.save().then(() => {
+          assert.isFalse(usernameStub.called);
           assert.isTrue(statusStub.called);
           assert.isFalse(nameStub.called);
-          statusStub.lastCall.returnValue.then(function() {
+          statusStub.lastCall.returnValue.then(() => {
             assert.equal(statusStub.lastCall.args[0], 'new status');
             done();
           });
@@ -169,26 +223,28 @@
       });
     });
 
-    suite('edit name and status', function() {
-      var nameChangedSpy;
-      var statusChangedSpy;
-      var nameStub;
-      var statusStub;
+    suite('edit name and status', () => {
+      let nameChangedSpy;
+      let statusChangedSpy;
+      let nameStub;
+      let statusStub;
 
-      setup(function() {
+      setup(() => {
         nameChangedSpy = sandbox.spy(element, '_nameChanged');
         statusChangedSpy = sandbox.spy(element, '_statusChanged');
         element.set('_serverConfig',
-          {auth: {editable_account_fields: ['FULL_NAME']}});
+            {auth: {editable_account_fields: ['FULL_NAME']}});
 
         nameStub = sandbox.stub(element.$.restAPI, 'setAccountName',
-            function(name) { return Promise.resolve(); });
+            name => Promise.resolve());
         statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            function(status) { return Promise.resolve(); });
+            status => Promise.resolve());
+        sandbox.stub(element.$.restAPI, 'setAccountUsername',
+            username => Promise.resolve());
       });
 
-      test('set name and status', function(done) {
-        assert.isTrue(element.mutable);
+      test('set name and status', done => {
+        assert.isTrue(element.nameMutable);
         assert.isFalse(element.hasUnsavedChanges);
 
         element.set('_account.name', 'new name');
@@ -201,7 +257,7 @@
 
         assert.isTrue(element.hasUnsavedChanges);
 
-        element.save().then(function() {
+        element.save().then(() => {
           assert.isTrue(statusStub.called);
           assert.isTrue(nameStub.called);
 
@@ -214,25 +270,25 @@
       });
     });
 
-    suite('set status but read name', function() {
-      var statusChangedSpy;
-      var statusStub;
+    suite('set status but read name', () => {
+      let statusChangedSpy;
+      let statusStub;
 
-      setup(function() {
+      setup(() => {
         statusChangedSpy = sandbox.spy(element, '_statusChanged');
         element.set('_serverConfig',
           {auth: {editable_account_fields: []}});
 
         statusStub = sandbox.stub(element.$.restAPI, 'setAccountStatus',
-            function(status) { return Promise.resolve(); });
+            status => Promise.resolve());
       });
 
-      test('read full name but set status', function(done) {
-        var section = element.$.nameSection;
-        var displaySpan = section.querySelectorAll('.value')[0];
-        var inputSpan = section.querySelectorAll('.value')[1];
+      test('read full name but set status', done => {
+        const section = element.$.nameSection;
+        const displaySpan = section.querySelectorAll('.value')[0];
+        const inputSpan = section.querySelectorAll('.value')[1];
 
-        assert.isFalse(element.mutable);
+        assert.isFalse(element.nameMutable);
 
         assert.isFalse(element.hasUnsavedChanges);
 
@@ -246,14 +302,32 @@
 
         assert.isTrue(element.hasUnsavedChanges);
 
-        element.save().then(function() {
+        element.save().then(() => {
           assert.isTrue(statusStub.called);
-          statusStub.lastCall.returnValue.then(function() {
+          statusStub.lastCall.returnValue.then(() => {
             assert.equal(statusStub.lastCall.args[0], 'new status');
             done();
           });
         });
       });
     });
+
+    test('_usernameChanged compares usernames with loose equality', () => {
+      element._account = {};
+      element._username = '';
+      element._hasUsernameChange = false;
+      element._loading = false;
+      // _usernameChanged is an observer, but call it here after setting
+      // _hasUsernameChange in the test to force recomputation.
+      element._usernameChanged();
+      flushAsynchronousOperations();
+
+      assert.isFalse(element._hasUsernameChange);
+
+      element.set('_username', 'test');
+      flushAsynchronousOperations();
+
+      assert.isTrue(element._hasUsernameChange);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
new file mode 100644
index 0000000..c665df4
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.html
@@ -0,0 +1,62 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-agreements-list">
+  <template>
+    <style include="shared-styles">
+      #agreements .nameColumn {
+        min-width: 15em;
+        width: auto;
+      }
+      #agreements .descriptionColumn {
+        width: auto;
+      }
+    </style>
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
+      <table id="agreements">
+        <thead>
+          <tr>
+            <th class="nameColumn">Name</th>
+            <th class="descriptionColumn">Description</th>
+          </tr>
+        </thead>
+        <tbody>
+          <template is="dom-repeat" items="[[_agreements]]">
+            <tr>
+              <td class="nameColumn">
+                <a href$="[[getUrlBase(item.url)]]" rel="external">
+                  [[item.name]]
+                </a>
+              </td>
+              <td class="descriptionColumn">[[item.description]]</td>
+            </tr>
+          </template>
+        </tbody>
+      </table>
+      <!-- TODO: Renable this when supported in polygerrit -->
+      <!-- <a href$="[[getUrl()]]">New Contributor Agreement</a> -->
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-agreements-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
new file mode 100644
index 0000000..4e25523
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list.js
@@ -0,0 +1,46 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-agreements-list',
+
+    properties: {
+      _agreements: Array,
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+    ],
+
+    attached() {
+      this.loadData();
+    },
+
+    loadData() {
+      return this.$.restAPI.getAccountAgreements().then(agreements => {
+        this._agreements = agreements;
+      });
+    },
+
+    getUrl() {
+      return this.getBaseUrl() + '/settings/new-agreement';
+    },
+
+    getUrlBase(item) {
+      return this.getBaseUrl() + '/' + item;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
new file mode 100644
index 0000000..13b8952
--- /dev/null
+++ b/polygerrit-ui/app/elements/settings/gr-agreements-list/gr-agreements-list_test.html
@@ -0,0 +1,67 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-settings-view</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-agreements-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-agreements-list></gr-agreements-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-agreements-list tests', () => {
+    let element;
+    let agreements;
+
+    setup(done => {
+      agreements = [{
+        url: 'some url',
+        description: 'Agreements 1 description',
+        name: 'Agreements 1',
+      }];
+
+      stub('gr-rest-api-interface', {
+        getAccountGroups() { return Promise.resolve(agreements); },
+      });
+
+      element = fixture('basic');
+
+      element.loadData().then(() => { flush(done); });
+    });
+
+    test('renders', () => {
+      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+
+      assert.equal(rows.length, 3);
+
+      const nameCells = rows.map(row =>
+        row.querySelectorAll('td')[0].textContent
+      );
+
+      assert.equal(nameCells[0], 'Agreements 1');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
index e2488f4..43343de 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.html
@@ -19,37 +19,47 @@
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 
 <dom-module id="gr-change-table-editor">
   <template>
-    <style>
-      table {
-        margin-top: 1em;
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      #changeCols {
+        width: auto;
       }
-      th.nameHeader {
-        width: 11em;
+      #changeCols .visibleHeader {
+        text-align: center;
       }
-      td.checkboxContainer {
-        border: 1px solid #fff;
+      .checkboxContainer {
         cursor: pointer;
         text-align: center;
       }
-      td.checkboxContainer:hover {
-        border: 1px solid #ddd;
+      .checkboxContainer:hover {
+        outline: 1px solid #ddd;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
-      <table>
+    <div class="gr-form-styles">
+      <table id="changeCols">
         <thead>
           <tr>
             <th class="nameHeader">Column</th>
-            <th>Visible</th>
+            <th class="visibleHeader">Visible</th>
           </tr>
         </thead>
         <tbody>
+          <tr>
+            <td>Number</td>
+            <td
+                class="checkboxContainer"
+                on-tap="_handleTargetTap">
+              <input
+                  type="checkbox"
+                  name="number"
+                  checked$="[[showNumber]]">
+            </td>
+          </tr>
           <template is="dom-repeat" items="[[columnNames]]">
             <tr>
               <td>[[item]]</td>
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
index 6a83a46..4d87f9e 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor.js
@@ -22,19 +22,23 @@
         type: Array,
         notify: true,
       },
+      showNumber: {
+        type: Boolean,
+        notify: true,
+      },
     },
 
     behaviors: [
       Gerrit.ChangeTableBehavior,
     ],
 
-    _getButtonText: function(isShown) {
+    _getButtonText(isShown) {
       return isShown ? 'Hide' : 'Show';
     },
 
-    _updateDisplayedColumns: function(displayedColumns, name, checked) {
+    _updateDisplayedColumns(displayedColumns, name, checked) {
       if (!checked) {
-        return displayedColumns.filter(function(column) {
+        return displayedColumns.filter(column => {
           return name.toLowerCase() !== column.toLowerCase();
         });
       } else {
@@ -45,14 +49,20 @@
     /**
      * Handles tap on either the checkbox itself or the surrounding table cell.
      */
-    _handleTargetTap: function(e) {
-      var checkbox = Polymer.dom(e.target).querySelector('input');
+    _handleTargetTap(e) {
+      let checkbox = Polymer.dom(e.target).querySelector('input');
       if (checkbox) {
         checkbox.click();
       } else {
         // The target is the checkbox itself.
         checkbox = Polymer.dom(e).rootTarget;
       }
+
+      if (checkbox.name === 'number') {
+        this.showNumber = checkbox.checked;
+        return;
+      }
+
       this.set('displayedColumns',
           this._updateDisplayedColumns(
               this.displayedColumns, checkbox.name, checkbox.checked));
diff --git a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
index d4443ac..e1753ba 100644
--- a/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-change-table-editor/gr-change-table-editor_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-table-editor.html">
 
 <script>void(0);</script>
@@ -33,12 +32,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-table-editor tests', function() {
-    var element;
-    var columns;
-    var sandbox;
+  suite('gr-change-table-editor tests', () => {
+    let element;
+    let columns;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
 
@@ -46,6 +45,7 @@
         'Subject',
         'Status',
         'Owner',
+        'Assignee',
         'Project',
         'Branch',
         'Updated',
@@ -55,46 +55,48 @@
       flushAsynchronousOperations();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('renders', function() {
-      var rows = element.$$('tbody').querySelectorAll('tr');
-      var tds;
+    test('renders', () => {
+      const rows = element.$$('tbody').querySelectorAll('tr');
+      let tds;
 
-      assert.equal(rows.length, element.columnNames.length);
-      for (var i = 0; i < columns.length; i++) {
-        tds = rows[i].querySelectorAll('td');
+      // The `+ 1` is for the number column, which isn't included in the change
+      // table behavior's list.
+      assert.equal(rows.length, element.columnNames.length + 1);
+      for (let i = 0; i < columns.length; i++) {
+        tds = rows[i + 1].querySelectorAll('td');
         assert.equal(tds[0].textContent, columns[i]);
       }
     });
 
-    test('hide item', function() {
-      var checkbox = element.$$('table input');
-      var isChecked = checkbox.checked;
-      var displayedLength = element.displayedColumns.length;
+    test('hide item', () => {
+      const checkbox = element.$$('table tr:nth-child(2) input');
+      const isChecked = checkbox.checked;
+      const displayedLength = element.displayedColumns.length;
       assert.isTrue(isChecked);
 
       MockInteractions.tap(checkbox);
       flushAsynchronousOperations();
 
-      assert.equal(element.displayedColumns.length,
-          displayedLength - 1);
+      assert.equal(element.displayedColumns.length, displayedLength - 1);
     });
 
-    test('show item', function() {
+    test('show item', () => {
       element.set('displayedColumns', [
         'Status',
         'Owner',
+        'Assignee',
         'Project',
         'Branch',
         'Updated',
       ]);
       flushAsynchronousOperations();
-      var checkbox = element.$$('table input');
-      var isChecked = checkbox.checked;
-      var displayedLength = element.displayedColumns.length;
+      const checkbox = element.$$('table tr:nth-child(2) input');
+      const isChecked = checkbox.checked;
+      const displayedLength = element.displayedColumns.length;
       assert.isFalse(isChecked);
       assert.equal(element.$$('table').style.display, '');
 
@@ -105,11 +107,11 @@
           displayedLength + 1);
     });
 
-    test('_handleTargetTap', function() {
-      var checkbox = element.$$('table input');
-      var originalDisplayedColumns = element.displayedColumns;
-      var td = element.$$('table .checkboxContainer');
-      var displayedColumnStub =
+    test('_handleTargetTap', () => {
+      const checkbox = element.$$('table tr:nth-child(2) input');
+      let originalDisplayedColumns = element.displayedColumns;
+      const td = element.$$('table tr:nth-child(2) .checkboxContainer');
+      const displayedColumnStub =
           sandbox.stub(element, '_updateDisplayedColumns');
 
       MockInteractions.tap(checkbox);
@@ -126,13 +128,28 @@
           checkbox.checked));
     });
 
-    test('_updateDisplayedColumns', function() {
-      var name = 'Subject';
-      var checked = false;
+    test('_handleTargetTap on number', () => {
+      element.showNumber = false;
+      const checkbox = element.$$('table tr:nth-child(1) input');
+      const displayedColumnStub =
+          sandbox.stub(element, '_updateDisplayedColumns');
+
+      MockInteractions.tap(checkbox);
+      assert.isFalse(displayedColumnStub.called);
+      assert.isTrue(element.showNumber);
+
+      MockInteractions.tap(checkbox);
+      assert.isFalse(element.showNumber);
+    });
+
+    test('_updateDisplayedColumns', () => {
+      let name = 'Subject';
+      let checked = false;
       assert.deepEqual(element._updateDisplayedColumns(columns, name, checked),
           [
             'Status',
             'Owner',
+            'Assignee',
             'Project',
             'Branch',
             'Updated',
@@ -144,6 +161,7 @@
             'Subject',
             'Status',
             'Owner',
+            'Assignee',
             'Project',
             'Branch',
             'Updated',
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
index 5339c5e..99a0392 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.html
@@ -18,38 +18,41 @@
 <link rel="import" href="../../../bower_components/iron-input/iron-input.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-email-editor">
   <template>
-    <style>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
       th {
         color: #666;
         text-align: left;
       }
-      th.emailHeader {
-        width: 32.5em;
+      #emailTable .emailColumn {
+        min-width: 32.5em;
+        width: auto;
       }
-      th.preferredHeader {
+      #emailTable .preferredHeader {
         text-align: center;
         width: 6em;
       }
-      tbody tr:nth-child(even) {
-        background-color: #f4f4f4;
-      }
-      td.preferredControl {
+      #emailTable .preferredControl {
         cursor: pointer;
+        height: auto;
         text-align: center;
       }
-      td.preferredControl:hover {
-        border: 1px solid #ddd;
+      #emailTable .preferredControl .preferredRadio {
+        height: auto;
+      }
+      .preferredControl:hover {
+        outline: 1px solid #d1d2d3;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
-      <table>
+    <div class="gr-form-styles">
+      <table id="emailTable">
         <thead>
           <tr>
-            <th class="emailHeader">Email</th>
+            <th class="emailColumn">Email</th>
             <th class="preferredHeader">Preferred</th>
             <th></th>
           </tr>
@@ -57,10 +60,11 @@
         <tbody>
           <template is="dom-repeat" items="[[_emails]]">
             <tr>
-              <td>[[item.email]]</td>
+              <td class="emailColumn">[[item.email]]</td>
               <td class="preferredControl" on-tap="_handlePreferredControlTap">
                 <input
                     is="iron-input"
+                    class="preferredRadio"
                     type="radio"
                     on-change="_handlePreferredChange"
                     name="preferred"
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
index 90dd119c..2d3f1c3 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor.js
@@ -27,26 +27,26 @@
       _emails: Array,
       _emailsToRemove: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
+      /** @type {?string} */
       _newPreferred: {
         type: String,
         value: null,
       },
     },
 
-    loadData: function() {
-      return this.$.restAPI.getAccountEmails().then(function(emails) {
+    loadData() {
+      return this.$.restAPI.getAccountEmails().then(emails => {
         this._emails = emails;
-      }.bind(this));
+      });
     },
 
-    save: function() {
-      var promises = [];
+    save() {
+      const promises = [];
 
-      for (var i = 0; i < this._emailsToRemove.length; i++) {
-        promises.push(this.$.restAPI.deleteAccountEmail(
-            this._emailsToRemove[i].email));
+      for (const emailObj of this._emailsToRemove) {
+        promises.push(this.$.restAPI.deleteAccountEmail(emailObj.email));
       }
 
       if (this._newPreferred) {
@@ -54,30 +54,31 @@
             this._newPreferred));
       }
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         this._emailsToRemove = [];
         this._newPreferred = null;
         this.hasUnsavedChanges = false;
-      }.bind(this));
+      });
     },
 
-    _handleDeleteButton: function(e) {
-      var index = parseInt(e.target.getAttribute('data-index'));
-      var email = this._emails[index];
+    _handleDeleteButton(e) {
+      const index = parseInt(Polymer.dom(e).localTarget
+          .getAttribute('data-index'), 10);
+      const email = this._emails[index];
       this.push('_emailsToRemove', email);
       this.splice('_emails', index, 1);
       this.hasUnsavedChanges = true;
     },
 
-    _handlePreferredControlTap: function(e) {
+    _handlePreferredControlTap(e) {
       if (e.target.classList.contains('preferredControl')) {
         e.target.firstElementChild.click();
       }
     },
 
-    _handlePreferredChange: function(e) {
-      var preferred = e.target.value;
-      for (var i = 0; i < this._emails.length; i++) {
+    _handlePreferredChange(e) {
+      const preferred = e.target.value;
+      for (let i = 0; i < this._emails.length; i++) {
         if (preferred === this._emails[i].email) {
           this.set(['_emails', i, 'preferred'], true);
           this._newPreferred = preferred;
diff --git a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
index b949643..fdbafdb 100644
--- a/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-email-editor/gr-email-editor_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-email-editor.html">
 
 <script>void(0);</script>
@@ -33,18 +32,18 @@
 </test-fixture>
 
 <script>
-  suite('gr-email-editor tests', function() {
-    var element;
+  suite('gr-email-editor tests', () => {
+    let element;
 
-    setup(function(done) {
-      var emails = [
+    setup(done => {
+      const emails = [
         {email: 'email@one.com'},
         {email: 'email@two.com', preferred: true},
         {email: 'email@three.com'},
       ];
 
       stub('gr-rest-api-interface', {
-        getAccountEmails: function() { return Promise.resolve(emails); },
+        getAccountEmails() { return Promise.resolve(emails); },
       });
 
       element = fixture('basic');
@@ -52,8 +51,8 @@
       element.loadData().then(done);
     });
 
-    test('renders', function() {
-      var rows = element.$$('table').querySelectorAll('tbody tr');
+    test('renders', () => {
+      const rows = element.$$('table').querySelectorAll('tbody tr');
 
       assert.equal(rows.length, 3);
 
@@ -69,9 +68,9 @@
       assert.isFalse(element.hasUnsavedChanges);
     });
 
-    test('edit preferred', function() {
-      var preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
-      var radios = element.$$('table').querySelectorAll('input[type=radio]');
+    test('edit preferred', () => {
+      const preferredChangedSpy = sinon.spy(element, '_handlePreferredChange');
+      const radios = element.$$('table').querySelectorAll('input[type=radio]');
 
       assert.isFalse(element.hasUnsavedChanges);
       assert.isNotOk(element._newPreferred);
@@ -92,8 +91,8 @@
       assert.isTrue(preferredChangedSpy.called);
     });
 
-    test('delete email', function() {
-      var buttons = element.$$('table').querySelectorAll('gr-button');
+    test('delete email', () => {
+      const buttons = element.$$('table').querySelectorAll('gr-button');
 
       assert.isFalse(element.hasUnsavedChanges);
       assert.isNotOk(element._newPreferred);
@@ -110,11 +109,12 @@
       assert.equal(element._emailsToRemove[0].email, 'email@three.com');
     });
 
-    test('save changes', function(done) {
-      var deleteEmailStub = sinon.stub(element.$.restAPI, 'deleteAccountEmail');
-      var setPreferredStub = sinon.stub(element.$.restAPI,
+    test('save changes', done => {
+      const deleteEmailStub =
+          sinon.stub(element.$.restAPI, 'deleteAccountEmail');
+      const setPreferredStub = sinon.stub(element.$.restAPI,
           'setPreferredAccountEmail');
-      var rows = element.$$('table').querySelectorAll('tbody tr');
+      const rows = element.$$('table').querySelectorAll('tbody tr');
 
       assert.isFalse(element.hasUnsavedChanges);
       assert.isNotOk(element._newPreferred);
@@ -132,7 +132,7 @@
       assert.equal(element._emails.length, 2);
 
       // Save the changes.
-      element.save().then(function() {
+      element.save().then(() => {
         assert.equal(deleteEmailStub.callCount, 1);
         assert.equal(deleteEmailStub.getCall(0).args[0], 'email@one.com');
 
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
index 303d836..c26ac87 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.html
@@ -15,37 +15,45 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 
-<link rel="import" href="../../../styles/gr-settings-styles.html">
-
 <dom-module id="gr-group-list">
   <template>
-    <style>
-      .nameHeader {
-        width: 15em;
-      }
-      .descriptionHeader {
-        width: 21.5em;
-      }
-      .visibleCell {
-        text-align: center;
-      }
-    </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
-      <table>
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+        #groups .nameColumn {
+          min-width: 11em;
+          width: auto;
+        }
+        .descriptionHeader {
+          min-width: 21.5em;
+        }
+        .visibleCell {
+          text-align: center;
+          width: 6em;
+        }
+      </style>
+    <div class="gr-form-styles">
+      <table id="groups">
         <thead>
           <tr>
             <th class="nameHeader">Name</th>
             <th class="descriptionHeader">Description</th>
-            <th>Visible to all</th>
+            <th class="visibleCell">Visible to all</th>
           </tr>
         </thead>
         <tbody>
           <template is="dom-repeat" items="[[_groups]]">
             <tr>
-              <td>[[item.name]]</td>
+              <td class="nameColumn">
+                <a href$="[[_computeGroupPath(item)]]">
+                  [[item.name]]
+                </a>
+              </td>
               <td>[[item.description]]</td>
               <td class="visibleCell">[[_computeVisibleToAll(item)]]</td>
             </tr>
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
index d14c755..d294a9b 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list.js
@@ -21,16 +21,29 @@
       _groups: Array,
     },
 
-    loadData: function() {
-      return this.$.restAPI.getAccountGroups().then(function(groups) {
-        this._groups = groups.sort(function(a, b) {
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    loadData() {
+      return this.$.restAPI.getAccountGroups().then(groups => {
+        this._groups = groups.sort((a, b) => {
           return a.name.localeCompare(b.name);
         });
-      }.bind(this));
+      });
     },
 
-    _computeVisibleToAll: function(group) {
+    _computeVisibleToAll(group) {
       return group.options.visible_to_all ? 'Yes' : 'No';
     },
+
+    _computeGroupPath(group) {
+      if (!group || !group.id) { return; }
+
+      const encodeGroup = this.encodeURL(group.id, true);
+
+      return `${this.getBaseUrl()}/admin/groups/${encodeGroup}`;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
index 2abf797..582ed6c 100644
--- a/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-group-list/gr-group-list_test.html
@@ -20,7 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-group-list.html">
 
 <script>void(0);</script>
@@ -32,11 +32,13 @@
 </test-fixture>
 
 <script>
-  suite('gr-group-list tests', function() {
-    var element;
-    var groups;
+  suite('gr-group-list tests', () => {
+    let sandbox;
+    let element;
+    let groups;
 
-    setup(function(done) {
+    setup(done => {
+      sandbox = sinon.sandbox.create();
       groups = [{
         url: 'some url',
         options: {},
@@ -46,41 +48,57 @@
         owner_id: '123',
         id: 'abc',
         name: 'Group 1',
-      },{
+      }, {
         options: {visible_to_all: true},
         id: '456',
         name: 'Group 2',
-      },{
+      }, {
         options: {},
         id: '789',
         name: 'Group 3',
       }];
 
       stub('gr-rest-api-interface', {
-        getAccountGroups: function() { return Promise.resolve(groups); },
+        getAccountGroups() { return Promise.resolve(groups); },
       });
 
       element = fixture('basic');
 
-      element.loadData().then(function() { flush(done); });
+      element.loadData().then(() => { flush(done); });
     });
 
-    test('renders', function() {
-      var rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+    teardown(() => { sandbox.restore(); });
+
+    test('renders', () => {
+      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
 
       assert.equal(rows.length, 3);
 
-      var nameCells = rows.map(
-          function(row) { return row.querySelectorAll('td')[0].textContent; });
+      const nameCells = rows.map(row =>
+        row.querySelectorAll('td a')[0].textContent.trim()
+      );
 
       assert.equal(nameCells[0], 'Group 1');
       assert.equal(nameCells[1], 'Group 2');
       assert.equal(nameCells[2], 'Group 3');
     });
 
-    test('_computeVisibleToAll', function() {
+    test('_computeVisibleToAll', () => {
       assert.equal(element._computeVisibleToAll(groups[0]), 'No');
       assert.equal(element._computeVisibleToAll(groups[1]), 'Yes');
     });
+
+    test('_computeGroupPath', () => {
+      let group = {
+        id: 'e2cd66f88a2db4d391ac068a92d987effbe872f5',
+      };
+      assert.equal(element._computeGroupPath(group),
+          '/admin/groups/e2cd66f88a2db4d391ac068a92d987effbe872f5');
+
+      group = {
+        name: 'admin',
+      };
+      assert.isUndefined(element._computeGroupPath(group));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
index e01ab94..515fe4f 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.html
@@ -15,14 +15,15 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-http-password">
   <template>
-    <style>
+    <style include="shared-styles">
       .password {
         font-family: var(--monospace-font-family);
       }
@@ -46,8 +47,8 @@
         right: 2em;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <div hidden$="[[_passwordUrl]]">
         <section>
           <span class="title">Username</span>
@@ -58,7 +59,7 @@
             on-tap="_handleGenerateTap">Generate new password</gr-button>
       </div>
       <span hidden$="[[!_passwordUrl]]">
-        <a href="[[_passwordUrl]]" target="_blank" rel="noopener">
+        <a href$="[[_passwordUrl]]" target="_blank" rel="noopener">
           Obtain password</a>
         (opens in a new tab)
       </span>
@@ -67,7 +68,7 @@
         id="generatedPasswordOverlay"
         on-iron-overlay-closed="_generatedPasswordOverlayClosed"
         with-backdrop>
-      <div class="gr-settings-styles">
+      <div class="gr-form-styles">
         <section id="generatedPasswordDisplay">
           <span class="title">New Password:</span>
           <span class="value">[[_generatedPassword]]</span>
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
index f4894e9..3dc92d1 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password.js
@@ -23,34 +23,34 @@
       _passwordUrl: String,
     },
 
-    loadData: function() {
-      var promises = [];
+    loadData() {
+      const promises = [];
 
-      promises.push(this.$.restAPI.getAccount().then(function(account) {
+      promises.push(this.$.restAPI.getAccount().then(account => {
         this._username = account.username;
-      }.bind(this)));
+      }));
 
-      promises.push(this.$.restAPI.getConfig().then(function(info) {
+      promises.push(this.$.restAPI.getConfig().then(info => {
         this._passwordUrl = info.auth.http_password_url || null;
-      }.bind(this)));
+      }));
 
       return Promise.all(promises);
     },
 
-    _handleGenerateTap: function() {
+    _handleGenerateTap() {
       this._generatedPassword = 'Generating...';
       this.$.generatedPasswordOverlay.open();
-      this.$.restAPI.generateAccountHttpPassword().then(function(newPassword) {
+      this.$.restAPI.generateAccountHttpPassword().then(newPassword => {
         this._generatedPassword = newPassword;
-      }.bind(this));
+      });
     },
 
-    _closeOverlay: function() {
+    _closeOverlay() {
       this.$.generatedPasswordOverlay.close();
     },
 
-    _generatedPasswordOverlayClosed: function() {
-      this._generatedPassword = null;
+    _generatedPasswordOverlayClosed() {
+      this._generatedPassword = '';
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
index 787c2c4..bbe1555 100644
--- a/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-http-password/gr-http-password_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-http-password.html">
 
 <script>void(0);</script>
@@ -33,33 +32,31 @@
 </test-fixture>
 
 <script>
-  suite('gr-http-password tests', function() {
-    var element;
-    var account;
-    var password;
-    var config;
+  suite('gr-http-password tests', () => {
+    let element;
+    let account;
+    let config;
 
-    setup(function(done) {
+    setup(done => {
       account = {username: 'user name'};
       config = {auth: {}};
-      password = 'the password';
 
       stub('gr-rest-api-interface', {
-        getAccount: function() { return Promise.resolve(account); },
-        getConfig: function() { return Promise.resolve(config); },
+        getAccount() { return Promise.resolve(account); },
+        getConfig() { return Promise.resolve(config); },
       });
 
       element = fixture('basic');
-      element.loadData().then(function() { flush(done); });
+      element.loadData().then(() => { flush(done); });
     });
 
-    test('generate password', function() {
-      var button = element.$.generateButton;
-      var nextPassword = 'the new password';
-      var generateResolve;
-      var generateStub = sinon.stub(element.$.restAPI,
-          'generateAccountHttpPassword', function() {
-            return new Promise(function(resolve) {
+    test('generate password', () => {
+      const button = element.$.generateButton;
+      const nextPassword = 'the new password';
+      let generateResolve;
+      const generateStub = sinon.stub(element.$.restAPI,
+          'generateAccountHttpPassword', () => {
+            return new Promise(resolve => {
               generateResolve = resolve;
             });
           });
@@ -73,18 +70,18 @@
 
       generateResolve(nextPassword);
 
-      generateStub.lastCall.returnValue.then(function() {
+      generateStub.lastCall.returnValue.then(() => {
         assert.equal(element._generatedPassword, nextPassword);
       });
     });
 
-    test('without http_password_url', function() {
+    test('without http_password_url', () => {
       assert.isNull(element._passwordUrl);
     });
 
-    test('with http_password_url', function(done) {
+    test('with http_password_url', done => {
       config.auth.http_password_url = 'http://example.com/';
-      element.loadData().then(function() {
+      element.loadData().then(() => {
         assert.isNotNull(element._passwordUrl);
         assert.equal(element._passwordUrl, config.auth.http_password_url);
         done();
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
index e603e8c..6805885 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.html
@@ -19,31 +19,32 @@
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 
 <dom-module id="gr-menu-editor">
   <template>
-    <style>
-      th.nameHeader {
-        width: 11em;
+    <style include="shared-styles">
+      .buttonColumn {
+        width: 2em;
       }
-      tbody tr:first-of-type td .move-up-button,
-      tbody tr:last-of-type td .move-down-button {
+      .moveUpButton,
+      .moveDownButton {
+        width: 100%
+      }
+      tbody tr:first-of-type td .moveUpButton,
+      tbody tr:last-of-type td .moveDownButton {
         display: none;
       }
       td.urlCell {
         word-break: break-word;
       }
-      .newTitleInput {
-        width: 10em;
-      }
       .newUrlInput {
-        width: 23em;
+        min-width: 23em;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
+    <style include="gr-form-styles"></style>
+    <div class="gr-form-styles">
       <table>
         <thead>
           <tr>
@@ -56,20 +57,23 @@
             <tr>
               <td>[[item.name]]</td>
               <td class="urlCell">[[item.url]]</td>
-              <td>
+              <td class="buttonColumn">
                 <gr-button
+                    link
                     data-index="[[index]]"
                     on-tap="_handleMoveUpButton"
-                    class="move-up-button">↑</gr-button>
+                    class="moveUpButton">↑</gr-button>
               </td>
-              <td>
+              <td class="buttonColumn">
                 <gr-button
+                    link
                     data-index="[[index]]"
                     on-tap="_handleMoveDownButton"
-                    class="move-down-button">↓</gr-button>
+                    class="moveDownButton">↓</gr-button>
               </td>
               <td>
                 <gr-button
+                    link
                     data-index="[[index]]"
                     on-tap="_handleDeleteButton"
                     class="remove-button">Delete</gr-button>
@@ -81,7 +85,6 @@
           <tr>
             <th>
               <input
-                  class="newTitleInput"
                   is="iron-input"
                   placeholder="New Title"
                   on-keydown="_handleInputKeydown"
@@ -99,6 +102,7 @@
             <th></th>
             <th>
               <gr-button
+                  link
                   disabled$="[[_computeAddDisabled(_newName, _newUrl)]]"
                   on-tap="_handleAddButton">Add</gr-button>
             </th>
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
index d3a2e2d..26a2470 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor.js
@@ -23,28 +23,28 @@
       _newUrl: String,
     },
 
-    _handleMoveUpButton: function(e) {
-      var index = e.target.dataIndex;
+    _handleMoveUpButton(e) {
+      const index = Polymer.dom(e).localTarget.dataIndex;
       if (index === 0) { return; }
-      var row = this.menuItems[index];
-      var prev = this.menuItems[index - 1];
+      const row = this.menuItems[index];
+      const prev = this.menuItems[index - 1];
       this.splice('menuItems', index - 1, 2, row, prev);
     },
 
-    _handleMoveDownButton: function(e) {
-      var index = e.target.dataIndex;
+    _handleMoveDownButton(e) {
+      const index = Polymer.dom(e).localTarget.dataIndex;
       if (index === this.menuItems.length - 1) { return; }
-      var row = this.menuItems[index];
-      var next = this.menuItems[index + 1];
+      const row = this.menuItems[index];
+      const next = this.menuItems[index + 1];
       this.splice('menuItems', index, 2, next, row);
     },
 
-    _handleDeleteButton: function(e) {
-      var index = e.target.dataIndex;
+    _handleDeleteButton(e) {
+      const index = Polymer.dom(e).localTarget.dataIndex;
       this.splice('menuItems', index, 1);
     },
 
-    _handleAddButton: function() {
+    _handleAddButton() {
       if (this._computeAddDisabled(this._newName, this._newUrl)) { return; }
 
       this.splice('menuItems', this.menuItems.length, 0, {
@@ -57,11 +57,11 @@
       this._newUrl = '';
     },
 
-    _computeAddDisabled: function(newName, newUrl) {
+    _computeAddDisabled(newName, newUrl) {
       return !newName.length || !newUrl.length;
     },
 
-    _handleInputKeydown: function(e) {
+    _handleInputKeydown(e) {
       if (e.keyCode === 13) {
         e.stopPropagation();
         this._handleAddButton();
diff --git a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
index a7078093..c70ae88 100644
--- a/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-menu-editor/gr-menu-editor_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-menu-editor.html">
 
 <script>void(0);</script>
@@ -33,14 +32,14 @@
 </test-fixture>
 
 <script>
-  suite('gr-settings-view tests', function() {
-    var element;
-    var menu;
+  suite('gr-settings-view tests', () => {
+    let element;
+    let menu;
 
     function assertMenuNamesEqual(element, expected) {
-      var names = element.menuItems.map(function(i) { return i.name; });
+      const names = element.menuItems.map(i => { return i.name; });
       assert.equal(names.length, expected.length);
-      for (var i = 0; i < names.length; i++) {
+      for (let i = 0; i < names.length; i++) {
         assert.equal(names[i], expected[i]);
       }
     }
@@ -48,13 +47,14 @@
     // Click the up/down button (according to direction) for the index'th row.
     // The index of the first row is 0, corresponding to the array.
     function move(element, index, direction) {
-      var selector =
-          'tr:nth-child(' + (index + 1) + ') .move-' + direction + '-button';
-      var button = element.$$('tbody').querySelector(selector);
+      const selector = 'tr:nth-child(' + (index + 1) + ') .move' +
+          direction + 'Button';
+      const button =
+          element.$$('tbody').querySelector(selector).$$('paper-button');
       MockInteractions.tap(button);
     }
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       menu = [
         {url: '/first/url', name: 'first name', target: '_blank'},
@@ -65,12 +65,12 @@
       Polymer.dom.flush();
     });
 
-    test('renders', function() {
-      var rows = element.$$('tbody').querySelectorAll('tr');
-      var tds;
+    test('renders', () => {
+      const rows = element.$$('tbody').querySelectorAll('tr');
+      let tds;
 
       assert.equal(rows.length, menu.length);
-      for (var i = 0; i < menu.length; i++) {
+      for (let i = 0; i < menu.length; i++) {
         tds = rows[i].querySelectorAll('td');
         assert.equal(tds[0].textContent, menu[i].name);
         assert.equal(tds[1].textContent, menu[i].url);
@@ -80,23 +80,23 @@
           element._newUrl));
     });
 
-    test('_computeAddDisabled', function() {
+    test('_computeAddDisabled', () => {
       assert.isTrue(element._computeAddDisabled('', ''));
       assert.isTrue(element._computeAddDisabled('name', ''));
       assert.isTrue(element._computeAddDisabled('', 'url'));
       assert.isFalse(element._computeAddDisabled('name', 'url'));
     });
 
-    test('add a new menu item', function() {
-      var newName = 'new name';
-      var newUrl = 'new url';
+    test('add a new menu item', () => {
+      const newName = 'new name';
+      const newUrl = 'new url';
 
       element._newName = newName;
       element._newUrl = newUrl;
       assert.isFalse(element._computeAddDisabled(element._newName,
           element._newUrl));
 
-      var originalMenuLength = element.menuItems.length;
+      const originalMenuLength = element.menuItems.length;
 
       element._handleAddButton();
 
@@ -106,51 +106,51 @@
       assert.equal(element.menuItems[element.menuItems.length - 1].url, newUrl);
     });
 
-    test('move items down', function() {
+    test('move items down', () => {
       assertMenuNamesEqual(element,
           ['first name', 'second name', 'third name']);
 
       // Move the middle item down
-      move(element, 1, 'down');
+      move(element, 1, 'Down');
       assertMenuNamesEqual(element,
           ['first name', 'third name', 'second name']);
 
       // Moving the bottom item down is a no-op.
-      move(element, 2, 'down');
+      move(element, 2, 'Down');
       assertMenuNamesEqual(element,
           ['first name', 'third name', 'second name']);
     });
 
-    test('move items up', function() {
+    test('move items up', () => {
       assertMenuNamesEqual(element,
           ['first name', 'second name', 'third name']);
 
       // Move the last item up twice to be the first.
-      move(element, 2, 'up');
-      move(element, 1, 'up');
+      move(element, 2, 'Up');
+      move(element, 1, 'Up');
       assertMenuNamesEqual(element,
           ['third name', 'first name', 'second name']);
 
       // Moving the top item up is a no-op.
-      move(element, 0, 'up');
+      move(element, 0, 'Up');
       assertMenuNamesEqual(element,
           ['third name', 'first name', 'second name']);
     });
 
-    test('remove item', function() {
+    test('remove item', () => {
       assertMenuNamesEqual(element,
           ['first name', 'second name', 'third name']);
 
       // Tap the delete button for the middle item.
-      MockInteractions.tap(
-          element.$$('tbody').querySelector('tr:nth-child(2) .remove-button'));
+      MockInteractions.tap(element.$$('tbody')
+          .querySelector('tr:nth-child(2) .remove-button').$$('paper-button'));
 
       assertMenuNamesEqual(element, ['first name', 'third name']);
 
       // Delete remaining items.
-      for (var i = 0; i < 2; i++) {
-        MockInteractions.tap(
-            element.$$('tbody').querySelector('tr:first-child .remove-button'));
+      for (let i = 0; i < 2; i++) {
+        MockInteractions.tap(element.$$('tbody')
+            .querySelector('tr:first-child .remove-button').$$('paper-button'));
       }
       assertMenuNamesEqual(element, []);
 
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
index ee358d5..0cbd1f6 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.html
@@ -15,14 +15,16 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-registration-dialog">
   <template>
-    <style include="gr-settings-styles"></style>
-    <style>
+    <style include="gr-form-styles"></style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
@@ -34,20 +36,25 @@
         margin-bottom: 1em;
       }
       header {
-        border-bottom: 1px solid #ddd;
-        font-weight: bold;
+        border-bottom: 1px solid #cdcdcd;
+        font-family: var(--font-family-bold);
+        margin-bottom: 1em;
       }
-      header,
-      main,
-      footer {
-        padding: .5em .65em;
+      .container {
+        padding: .5em 1.5em;
       }
       footer {
         display: flex;
-        justify-content: space-between;
+        justify-content: flex-end;
+      }
+      footer gr-button {
+        margin-left: 1em;
+      }
+      input {
+        width: 20em;
       }
     </style>
-    <main class="gr-settings-styles">
+    <div class="container gr-form-styles">
       <header>Please confirm your contact information</header>
       <main>
         <p>
@@ -63,15 +70,20 @@
               is="iron-input"
               id="name"
               bind-value="{{_account.name}}"
-              disabled="[[_saving]]"
-              on-keydown="_handleNameKeydown">
+              disabled="[[_saving]]">
+        </section>
+        <section>
+          <div class="title">Username</div>
+          <input
+              is="iron-input"
+              id="username"
+              bind-value="{{_account.username}}"
+              disabled="[[_saving]]">
         </section>
         <section>
           <div class="title">Preferred Email</div>
           <select
-              is="gr-select"
               id="email"
-              bind-value="{{_account.email}}"
               disabled="[[_saving]]">
             <option value="[[_account.email]]">[[_account.email]]</option>
             <template is="dom-repeat" items="[[_account.secondary_emails]]">
@@ -79,19 +91,26 @@
             </template>
           </select>
         </section>
+        <hr>
+        <p>
+          More configuration options for Gerrit may be found in the
+          <a on-tap="close" href$="[[_computeSettingsUrl(_account)]]">settings</a>.
+        </p>
       </main>
       <footer>
         <gr-button
-            id="saveButton"
-            primary
-            disabled="[[_saving]]"
-            on-tap="_handleSave">Save</gr-button>
-        <gr-button
             id="closeButton"
+            link
             disabled="[[_saving]]"
             on-tap="_handleClose">Close</gr-button>
+        <gr-button
+            id="saveButton"
+            primary
+            link
+            disabled="[[_computeSaveDisabled(_account.name, _account.username, _account.email, _saving)]]"
+            on-tap="_handleSave">Save</gr-button>
       </footer>
-    </main>
+    </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-registration-dialog.js"></script>
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
index 9acdba9..406d16c 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog.js
@@ -30,50 +30,68 @@
      */
 
     properties: {
-      _account: Object,
-      _saving: Boolean,
+      /** @type {?} */
+      _account: {
+        type: Object,
+        value: () => {
+          // Prepopulate possibly undefined fields with values to trigger
+          // computed bindings.
+          return {email: null, name: null, username: null};
+        },
+      },
+      _saving: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     hostAttributes: {
       role: 'dialog',
     },
 
-    attached: function() {
-      this.$.restAPI.getAccount().then(function(account) {
-        this._account = account;
-      }.bind(this));
+    attached() {
+      this.$.restAPI.getAccount().then(account => {
+        // Using Object.assign here allows preservation of the default values
+        // supplied in the value generating function of this._account, unless
+        // they are overridden by properties in the account from the response.
+        this._account = Object.assign({}, this._account, account);
+      });
     },
 
-    _handleNameKeydown: function(e) {
-      if (e.keyCode === 13) { // Enter
-        e.stopPropagation();
-        this._save();
-      }
-    },
-
-    _save: function() {
+    _save() {
       this._saving = true;
-      var promises = [
+      const promises = [
         this.$.restAPI.setAccountName(this.$.name.value),
-        this.$.restAPI.setPreferredAccountEmail(this.$.email.value),
+        this.$.restAPI.setAccountUsername(this.$.username.value),
+        this.$.restAPI.setPreferredAccountEmail(this.$.email.value || ''),
       ];
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         this._saving = false;
         this.fire('account-detail-update');
-      }.bind(this));
+      });
     },
 
-    _handleSave: function(e) {
+    _handleSave(e) {
       e.preventDefault();
-      this._save().then(function() {
-        this.fire('close');
-      }.bind(this));
+      this._save().then(this.close.bind(this));
     },
 
-    _handleClose: function(e) {
+    _handleClose(e) {
       e.preventDefault();
+      this.close();
+    },
+
+    close() {
       this._saving = true; // disable buttons indefinitely
       this.fire('close');
     },
+
+    _computeSaveDisabled(name, username, email, saving) {
+      return !name || !username || !email || saving;
+    },
+
+    _computeSettingsUrl() {
+      return Gerrit.Nav.getUrlForSettings();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
index ee5a206..15b4fa2 100644
--- a/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-registration-dialog/gr-registration-dialog_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-registration-dialog.html">
 
 <script>void(0);</script>
@@ -39,16 +38,19 @@
 </test-fixture>
 
 <script>
-  suite('gr-registration-dialog tests', function() {
-    var element;
-    var account;
-    var _listeners;
+  suite('gr-registration-dialog tests', () => {
+    let element;
+    let account;
+    let sandbox;
+    let _listeners;
 
-    setup(function(done) {
+    setup(done => {
+      sandbox = sinon.sandbox.create();
       _listeners = {};
 
       account = {
         name: 'name',
+        username: 'username',
         email: 'email',
         secondary_emails: [
           'email2',
@@ -57,16 +59,20 @@
       };
 
       stub('gr-rest-api-interface', {
-        getAccount: function() {
-          // Once the account is resolved, we can let the test proceed.
+        getAccount() {
+        // Once the account is resolved, we can let the test proceed.
           flush(done);
           return Promise.resolve(account);
         },
-        setAccountName: function(name) {
+        setAccountName(name) {
           account.name = name;
           return Promise.resolve();
         },
-        setPreferredAccountEmail: function(email) {
+        setAccountUsername(username) {
+          account.username = username;
+          return Promise.resolve();
+        },
+        setPreferredAccountEmail(email) {
           account.email = email;
           return Promise.resolve();
         },
@@ -75,8 +81,9 @@
       element = fixture('basic');
     });
 
-    teardown(function() {
-      for (var eventType in _listeners) {
+    teardown(() => {
+      sandbox.restore();
+      for (const eventType in _listeners) {
         if (_listeners.hasOwnProperty(eventType)) {
           element.removeEventListener(eventType, _listeners[eventType]);
         }
@@ -84,14 +91,14 @@
     });
 
     function listen(eventType) {
-      return new Promise(function(resolve) {
+      return new Promise(resolve => {
         _listeners[eventType] = function() { resolve(); };
         element.addEventListener(eventType, _listeners[eventType]);
       });
     }
 
     function save(opt_action) {
-      var promise = listen('account-detail-update');
+      const promise = listen('account-detail-update');
       if (opt_action) {
         opt_action();
       } else {
@@ -101,7 +108,7 @@
     }
 
     function close(opt_action) {
-      var promise = listen('close');
+      const promise = listen('close');
       if (opt_action) {
         opt_action();
       } else {
@@ -110,40 +117,52 @@
       return promise;
     }
 
-    test('fires the close event on close', function(done) {
+    test('fires the close event on close', done => {
       close().then(done);
     });
 
-    test('fires the close event on save', function(done) {
-      close(function() {
+    test('fires the close event on save', done => {
+      close(() => {
         MockInteractions.tap(element.$.saveButton);
       }).then(done);
     });
 
-    test('saves name and preferred email', function(done) {
-      flush(function() {
+    test('saves account details', done => {
+      flush(() => {
         element.$.name.value = 'new name';
+        element.$.username.value = 'new username';
         element.$.email.value = 'email3';
 
         // Nothing should be committed yet.
         assert.equal(account.name, 'name');
+        assert.equal(account.username, 'username');
         assert.equal(account.email, 'email');
 
         // Save and verify new values are committed.
-        save().then(function() {
+        save().then(() => {
           assert.equal(account.name, 'new name');
+          assert.equal(account.username, 'new username');
           assert.equal(account.email, 'email3');
         }).then(done);
       });
     });
 
-    test('pressing enter saves name', function(done) {
-      element.$.name.value = 'entered name';
-      save(function() {
-        MockInteractions.pressAndReleaseKeyOn(element.$.name, 13);  // 'enter'
-      }).then(function() {
-        assert.equal(account.name, 'entered name');
-      }).then(done);
+    test('email select properly populated', done => {
+      element._account = {email: 'foo', secondary_emails: ['bar', 'baz']};
+      flush(() => {
+        assert.equal(element.$.email.value, 'foo');
+        done();
+      });
+    });
+
+    test('save btn disabled', () => {
+      const compute = element._computeSaveDisabled;
+      assert.isTrue(compute('', '', '', false));
+      assert.isTrue(compute('', 'test', 'test', false));
+      assert.isTrue(compute('test', '', 'test', false));
+      assert.isTrue(compute('test', 'test', '', false));
+      assert.isTrue(compute('test', 'test', 'test', true));
+      assert.isFalse(compute('test', 'test', 'test', false));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
index f485dd6..49ba141 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.html
@@ -16,85 +16,48 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 
+<link rel="import" href="../../../behaviors/docs-url-behavior/docs-url-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../../styles/gr-menu-page-styles.html">
+<link rel="import" href="../../../styles/gr-page-nav-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../settings/gr-change-table-editor/gr-change-table-editor.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-page-nav/gr-page-nav.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
 <link rel="import" href="../gr-account-info/gr-account-info.html">
+<link rel="import" href="../gr-agreements-list/gr-agreements-list.html">
 <link rel="import" href="../gr-email-editor/gr-email-editor.html">
 <link rel="import" href="../gr-group-list/gr-group-list.html">
 <link rel="import" href="../gr-http-password/gr-http-password.html">
-<link rel="import" href="../gr-change-table-editor/gr-change-table-editor.html">
 <link rel="import" href="../gr-menu-editor/gr-menu-editor.html">
 <link rel="import" href="../gr-ssh-editor/gr-ssh-editor.html">
 <link rel="import" href="../gr-watched-projects-editor/gr-watched-projects-editor.html">
-<link rel="import" href="../../shared/gr-button/gr-button.html">
-<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
-<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../shared/gr-select/gr-select.html">
-
-<link rel="import" href="../../../styles/gr-settings-styles.html">
 
 <dom-module id="gr-settings-view">
   <template>
-    <style>
-      :host {
-        background-color: var(--view-background-color);
-        display: block;
-      }
-      main {
-        margin: 2em auto;
-        max-width: 46em;
-      }
-      h1 {
-        margin-bottom: .1em;
-      }
-      h2.edited:after {
-        color: #444;
-        content: ' *';
-      }
-      .loading {
-        color: #666;
-        padding: 1em var(--default-horizontal-margin);
-      }
+    <style include="shared-styles">
       #newEmailInput {
         width: 20em;
       }
-      nav {
-        border: 1px solid #eee;
-        border-top: none;
-        position: absolute;
-        top: 0;
-        width: 14em;
+      #email {
+        margin-bottom: 1em;
       }
-      nav.pinned {
-        position: fixed;
+      .filters p {
+        margin-bottom: 1em;
       }
-      nav ul {
-        margin: 1em 2em;
-      }
-      nav a {
-        color: black;
-        display: inline-block;
-        margin: .4em 0;
-      }
-      @media only screen and (max-width: 67em) {
-        main {
-          margin: 2em 0 2em 15em;
-        }
-      }
-      @media only screen and (max-width: 53em) {
-        .loading {
-          padding: 0 var(--default-horizontal-margin);
-        }
-        main {
-          margin: 2em 1em;
-        }
-        nav {
-          display: none;
-        }
+      .queryExample em {
+        color: violet;
       }
     </style>
-    <style include="gr-settings-styles"></style>
+    <style include="gr-form-styles"></style>
+    <style include="gr-menu-page-styles"></style>
+    <style include="gr-page-nav-styles"></style>
     <div class="loading" hidden$="[[!_loading]]">Loading...</div>
     <div hidden$="[[_loading]]" hidden>
-      <nav id="settingsNav">
+      <gr-page-nav class="navStyles">
         <ul>
           <li><a href="#Profile">Profile</a></li>
           <li><a href="#Preferences">Preferences</a></li>
@@ -108,9 +71,15 @@
             SSH Keys
           </a></li>
           <li><a href="#Groups">Groups</a></li>
+          <template is="dom-if" if="[[_serverConfig.auth.use_contributor_agreements]]">
+            <li>
+              <a href="#Agreements">Agreements</a>
+            </li>
+          </template>
+          <li><a href="#MailFilters">Mail Filters</a></li>
         </ul>
-      </nav>
-      <main class="gr-settings-styles">
+      </gr-page-nav>
+      <main class="gr-form-styles">
         <h1>User Settings</h1>
         <h2
             id="Profile"
@@ -131,68 +100,74 @@
           <section>
             <span class="title">Changes per page</span>
             <span class="value">
-              <select
-                  is="gr-select"
+              <gr-select
                   bind-value="{{_localPrefs.changes_per_page}}">
-                <option value="10">10 rows per page</option>
-                <option value="25">25 rows per page</option>
-                <option value="50">50 rows per page</option>
-                <option value="100">100 rows per page</option>
-              </select>
+                <select>
+                  <option value="10">10 rows per page</option>
+                  <option value="25">25 rows per page</option>
+                  <option value="50">50 rows per page</option>
+                  <option value="100">100 rows per page</option>
+                </select>
+              </gr-select>
             </span>
           </section>
           <section>
             <span class="title">Date/time format</span>
             <span class="value">
-              <select
-                  is="gr-select"
+              <gr-select
                   bind-value="{{_localPrefs.date_format}}">
-                <option value="STD">Jun 3 ; Jun 3, 2016</option>
-                <option value="US">06/03 ; 06/03/16</option>
-                <option value="ISO">06-03 ; 2016-06-03</option>
-                <option value="EURO">3. Jun ; 03.06.2016</option>
-                <option value="UK">03/06 ; 03/06/2016</option>
-              </select>
-              <select
-                  is="gr-select"
+                <select>
+                  <option value="STD">Jun 3 ; Jun 3, 2016</option>
+                  <option value="US">06/03 ; 06/03/16</option>
+                  <option value="ISO">06-03 ; 2016-06-03</option>
+                  <option value="EURO">3. Jun ; 03.06.2016</option>
+                  <option value="UK">03/06 ; 03/06/2016</option>
+                </select>
+              </gr-select>
+              <gr-select
                   bind-value="{{_localPrefs.time_format}}">
-                <option value="HHMM_12">4:10 PM</option>
-                <option value="HHMM_24">16:10</option>
-              </select>
+                <select>
+                  <option value="HHMM_12">4:10 PM</option>
+                  <option value="HHMM_24">16:10</option>
+                </select>
+              </gr-select>
             </span>
           </section>
           <section>
             <span class="title">Email notifications</span>
             <span class="value">
-              <select
-                  is="gr-select"
+              <gr-select
                   bind-value="{{_localPrefs.email_strategy}}">
-                <option value="CC_ON_OWN_COMMENTS">Every comment</option>
-                <option value="ENABLED">Only comments left by others</option>
-                <option value="DISABLED">None</option>
-              </select>
+                <select>
+                  <option value="CC_ON_OWN_COMMENTS">Every comment</option>
+                  <option value="ENABLED">Only comments left by others</option>
+                  <option value="DISABLED">None</option>
+                </select>
+              </gr-select>
             </span>
           </section>
           <section hidden$="[[!_localPrefs.email_format]]">
             <span class="title">Email format</span>
             <span class="value">
-              <select
-                  is="gr-select"
+              <gr-select
                   bind-value="{{_localPrefs.email_format}}">
-                <option value="HTML_PLAINTEXT">HTML and plaintext</option>
-                <option value="PLAINTEXT">Plaintext only</option>
-              </select>
+                <select>
+                  <option value="HTML_PLAINTEXT">HTML and plaintext</option>
+                  <option value="PLAINTEXT">Plaintext only</option>
+                </select>
+              </gr-select>
             </span>
           </section>
           <section>
             <span class="title">Diff view</span>
             <span class="value">
-              <select
-                  is="gr-select"
+              <gr-select
                   bind-value="{{_localPrefs.diff_view}}">
-                <option value="SIDE_BY_SIDE">Side by side</option>
-                <option value="UNIFIED_DIFF">Unified diff</option>
-              </select>
+                <select>
+                  <option value="SIDE_BY_SIDE">Side by side</option>
+                  <option value="UNIFIED_DIFF">Unified diff</option>
+                </select>
+              </gr-select>
             </span>
           </section>
           <section>
@@ -205,6 +180,38 @@
                   on-change="_handleExpandInlineDiffsChanged">
             </span>
           </section>
+          <section>
+            <span class="title">Publish comments on push</span>
+            <span class="value">
+              <input
+                  id="publishCommentsOnPush"
+                  type="checkbox"
+                  checked$="[[_localPrefs.publish_comments_on_push]]"
+                  on-change="_handlePublishCommentsOnPushChanged">
+            </span>
+          </section>
+          <section>
+            <span class="title">Set new changes to "work in progress" by default</span>
+            <span class="value">
+              <input
+                  id="workInProgressByDefault"
+                  type="checkbox"
+                  checked$="[[_localPrefs.work_in_progress_by_default]]"
+                  on-change="_handleWorkInProgressByDefault">
+            </span>
+          </section>
+          <section>
+            <span class="title">
+              Insert Signed-off-by Footer For Inline Edit Changes
+            </span>
+            <span class="value">
+              <input
+                  id="insertSignedOff"
+                  type="checkbox"
+                  checked$="[[_localPrefs.signed_off_by]]"
+                  on-change="_handleInsertSignedOff">
+            </span>
+          </section>
           <gr-button
               id="savePrefs"
               on-tap="_handleSavePreferences"
@@ -219,17 +226,17 @@
           <section>
             <span class="title">Context</span>
             <span class="value">
-              <select
-                  is="gr-select"
-                  bind-value="{{_diffPrefs.context}}">
-                <option value="3">3 lines</option>
-                <option value="10">10 lines</option>
-                <option value="25">25 lines</option>
-                <option value="50">50 lines</option>
-                <option value="75">75 lines</option>
-                <option value="100">100 lines</option>
-                <option value="-1">Whole file</option>
-              </select>
+              <gr-select bind-value="{{_diffPrefs.context}}">
+                <select>
+                  <option value="3">3 lines</option>
+                  <option value="10">10 lines</option>
+                  <option value="25">25 lines</option>
+                  <option value="50">50 lines</option>
+                  <option value="75">75 lines</option>
+                  <option value="100">100 lines</option>
+                  <option value="-1">Whole file</option>
+                </select>
+              </gr-select>
             </span>
           </section>
           <section>
@@ -312,11 +319,16 @@
         </fieldset>
         <h2 id="Menu" class$="[[_computeHeaderClass(_menuChanged)]]">Menu</h2>
         <fieldset id="menu">
-          <gr-menu-editor menu-items="{{_localMenu}}"></gr-menu-editor>
+          <gr-menu-editor
+              menu-items="{{_localMenu}}"></gr-menu-editor>
           <gr-button
               id="saveMenu"
               on-tap="_handleSaveMenu"
               disabled="[[!_menuChanged]]">Save changes</gr-button>
+          <gr-button
+              id="resetMenu"
+              link
+              on-tap="_handleResetMenuButton">Reset</gr-button>
         </fieldset>
         <h2 id="ChangeTableColumns"
             class$="[[_computeHeaderClass(_changeTableChanged)]]">
@@ -324,6 +336,7 @@
         </h2>
         <fieldset id="changeTableColumns">
           <gr-change-table-editor
+              show-number="{{_showNumber}}"
               displayed-columns="{{_localChangeTableColumns}}">
           </gr-change-table-editor>
           <gr-button
@@ -355,6 +368,7 @@
               id="emailEditor"
               has-unsaved-changes="{{_emailsChanged}}"></gr-email-editor>
           <gr-button
+              link
               on-tap="_handleSaveEmails"
               disabled$="[[!_emailsChanged]]">Save changes</gr-button>
         </fieldset>
@@ -400,6 +414,82 @@
         <fieldset>
           <gr-group-list id="groupList"></gr-group-list>
         </fieldset>
+        <template is="dom-if" if="[[_serverConfig.auth.use_contributor_agreements]]">
+          <h2 id="Agreements">Agreements</h2>
+          <fieldset>
+            <gr-agreements-list id="agreementsList"></gr-agreements-list>
+          </fieldset>
+        </template>
+        <h2 id="MailFilters">Mail Filters</h2>
+        <fieldset class="filters">
+          <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$="[[_getFilterDocsLink(_docsBaseUrl)]]"
+                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 class="queryExample">
+                    "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 class="queryExample">
+                    "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 class="queryExample">
+                    "Gerrit-Branch: <em>branch-name</em>"
+                  </code>
+                </td>
+              </tr>
+              <tr>
+                <td>Changes in a specific project</td>
+                <td>
+                  <code class="queryExample">
+                    "Gerrit-Project: <em>project-name</em>"
+                  </code>
+                </td>
+              </tr>
+              <tr>
+                <td>Messages related to a specific Change ID</td>
+                <td>
+                  <code class="queryExample">
+                    "Gerrit-Change-Id: <em>Change ID</em>"
+                  </code>
+                </td>
+              </tr>
+              <tr>
+                <td>Messages related to a specific change number</td>
+                <td>
+                  <code class="queryExample">
+                    "Gerrit-Change-Number: <em>change number</em>"
+                  </code>
+                </td>
+              </tr>
+            </tbody>
+          </table>
+        </fieldset>
       </main>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
index 4647a2d..3b0f509 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view.js
@@ -14,16 +14,25 @@
 (function() {
   'use strict';
 
-  var PREFS_SECTION_FIELDS = [
+  const PREFS_SECTION_FIELDS = [
     'changes_per_page',
     'date_format',
     'time_format',
     'email_strategy',
     'diff_view',
     'expand_inline_diffs',
+    'publish_comments_on_push',
+    'work_in_progress_by_default',
+    'signed_off_by',
     'email_format',
   ];
 
+  const GERRIT_DOCS_BASE_URL = 'https://gerrit-review.googlesource.com/' +
+      'Documentation';
+  const GERRIT_DOCS_FILTER_PATH = '/user-notify.html';
+  const ABSOLUTE_URL_PATTERN = /^https?:/;
+  const TRAILING_SLASH_PATTERN = /\/$/;
+
   Polymer({
     is: 'gr-settings-view',
 
@@ -42,27 +51,29 @@
     properties: {
       prefs: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       params: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       _accountNameMutable: Boolean,
       _accountInfoChanged: Boolean,
+      /** @type {?} */
       _diffPrefs: Object,
       _changeTableColumnsNotDisplayed: Array,
+      /** @type {?} */
       _localPrefs: {
         type: Object,
-        value: function() { return {}; },
+        value() { return {}; },
       },
       _localChangeTableColumns: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _localMenu: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _loading: {
         type: Boolean,
@@ -101,16 +112,22 @@
         type: String,
         value: null,
       },
+      /** @type {?} */
       _serverConfig: Object,
-      _headerHeight: Number,
+      /** @type {?string} */
+      _docsBaseUrl: String,
+      _emailsChanged: Boolean,
 
       /**
        * For testing purposes.
        */
       _loadingPromise: Object,
+
+      _showNumber: Boolean,
     },
 
     behaviors: [
+      Gerrit.DocsUrlBehavior,
       Gerrit.ChangeTableBehavior,
     ],
 
@@ -118,234 +135,245 @@
       '_handlePrefsChanged(_localPrefs.*)',
       '_handleDiffPrefsChanged(_diffPrefs.*)',
       '_handleMenuChanged(_localMenu.splices)',
-      '_handleChangeTableChanged(_localChangeTableColumns)',
+      '_handleChangeTableChanged(_localChangeTableColumns, _showNumber)',
     ],
 
-    attached: function() {
+    attached() {
       this.fire('title-change', {title: 'Settings'});
 
-      var promises = [
+      const promises = [
         this.$.accountInfo.loadData(),
         this.$.watchedProjectsEditor.loadData(),
         this.$.groupList.loadData(),
         this.$.httpPass.loadData(),
       ];
 
-      promises.push(this.$.restAPI.getPreferences().then(function(prefs) {
+      promises.push(this.$.restAPI.getPreferences().then(prefs => {
         this.prefs = prefs;
+        this._showNumber = !!prefs.legacycid_in_change_table;
         this._copyPrefs('_localPrefs', 'prefs');
-        this._cloneMenu();
+        this._cloneMenu(prefs.my);
         this._cloneChangeTableColumns();
-      }.bind(this)));
+      }));
 
-      promises.push(this.$.restAPI.getDiffPreferences().then(function(prefs) {
+      promises.push(this.$.restAPI.getDiffPreferences().then(prefs => {
         this._diffPrefs = prefs;
-      }.bind(this)));
+      }));
 
-      promises.push(this.$.restAPI.getConfig().then(function(config) {
+      promises.push(this.$.restAPI.getConfig().then(config => {
         this._serverConfig = config;
+        const configPromises = [];
+
         if (this._serverConfig.sshd) {
-          return this.$.sshEditor.loadData();
+          configPromises.push(this.$.sshEditor.loadData());
         }
-      }.bind(this)));
+
+        configPromises.push(
+            this.getDocsBaseUrl(config, this.$.restAPI)
+                .then(baseUrl => { this._docsBaseUrl = baseUrl; }));
+
+        return Promise.all(configPromises);
+      }));
 
       if (this.params.emailToken) {
         promises.push(this.$.restAPI.confirmEmail(this.params.emailToken).then(
-          function(message) {
-            if (message) {
-              this.fire('show-alert', {message: message});
-            }
-            this.$.emailEditor.loadData();
-          }.bind(this)));
+            message => {
+              if (message) {
+                this.fire('show-alert', {message});
+              }
+              this.$.emailEditor.loadData();
+            }));
       } else {
         promises.push(this.$.emailEditor.loadData());
       }
 
-      this._loadingPromise = Promise.all(promises).then(function() {
+      this._loadingPromise = Promise.all(promises).then(() => {
         this._loading = false;
-      }.bind(this));
-
-      this.listen(window, 'scroll', '_handleBodyScroll');
+      });
     },
 
-    detached: function() {
-      this.unlisten(window, 'scroll', '_handleBodyScroll');
-    },
-
-    reloadAccountDetail: function() {
+    reloadAccountDetail() {
       Promise.all([
         this.$.accountInfo.loadData(),
         this.$.emailEditor.loadData(),
       ]);
     },
 
-    _handleBodyScroll: function(e) {
-      if (this._headerHeight === undefined) {
-        var top = this.$.settingsNav.offsetTop;
-        for (var offsetParent = this.$.settingsNav.offsetParent;
-           offsetParent;
-           offsetParent = offsetParent.offsetParent) {
-          top += offsetParent.offsetTop;
-        }
-        this._headerHeight = top;
-      }
-
-      this.$.settingsNav.classList.toggle('pinned',
-          window.scrollY >= this._headerHeight);
-    },
-
-    _isLoading: function() {
+    _isLoading() {
       return this._loading || this._loading === undefined;
     },
 
-    _copyPrefs: function(to, from) {
-      for (var i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
+    _copyPrefs(to, from) {
+      for (let i = 0; i < PREFS_SECTION_FIELDS.length; i++) {
         this.set([to, PREFS_SECTION_FIELDS[i]],
             this[from][PREFS_SECTION_FIELDS[i]]);
       }
     },
 
-    _cloneMenu: function() {
-      var menu = [];
-      this.prefs.my.forEach(function(item) {
+    _cloneMenu(prefs) {
+      const menu = [];
+      for (const item of prefs) {
         menu.push({
           name: item.name,
           url: item.url,
           target: item.target,
         });
-      });
+      }
       this._localMenu = menu;
     },
 
-    _cloneChangeTableColumns: function() {
-      var columns = this.prefs.change_table;
+    _cloneChangeTableColumns() {
+      let columns = this.prefs.change_table;
 
       if (columns.length === 0) {
         columns = this.columnNames;
         this._changeTableColumnsNotDisplayed = [];
       } else {
         this._changeTableColumnsNotDisplayed = this.getComplementColumns(
-          this.prefs.change_table);
+            this.prefs.change_table);
       }
       this._localChangeTableColumns = columns;
     },
 
-    _formatChangeTableColumns: function(changeTableArray) {
-      return changeTableArray.map(function(item) {
+    _formatChangeTableColumns(changeTableArray) {
+      return changeTableArray.map(item => {
         return {column: item};
       });
     },
 
-    _handleChangeTableChanged: function() {
+    _handleChangeTableChanged() {
       if (this._isLoading()) { return; }
       this._changeTableChanged = true;
     },
 
-    _handlePrefsChanged: function(prefs) {
+    _handlePrefsChanged(prefs) {
       if (this._isLoading()) { return; }
       this._prefsChanged = true;
     },
 
-    _handleDiffPrefsChanged: function() {
+    _handleDiffPrefsChanged() {
       if (this._isLoading()) { return; }
       this._diffPrefsChanged = true;
     },
 
-    _handleExpandInlineDiffsChanged: function() {
+    _handleExpandInlineDiffsChanged() {
       this.set('_localPrefs.expand_inline_diffs',
           this.$.expandInlineDiffs.checked);
     },
 
-    _handleMenuChanged: function() {
+    _handlePublishCommentsOnPushChanged() {
+      this.set('_localPrefs.publish_comments_on_push',
+          this.$.publishCommentsOnPush.checked);
+    },
+
+    _handleWorkInProgressByDefault() {
+      this.set('_localPrefs.work_in_progress_by_default',
+          this.$.workInProgressByDefault.checked);
+    },
+
+    _handleInsertSignedOff() {
+      this.set('_localPrefs.signed_off_by', this.$.insertSignedOff.checked);
+    },
+
+    _handleMenuChanged() {
       if (this._isLoading()) { return; }
       this._menuChanged = true;
     },
 
-    _handleSaveAccountInfo: function() {
+    _handleSaveAccountInfo() {
       this.$.accountInfo.save();
     },
 
-    _handleSavePreferences: function() {
+    _handleSavePreferences() {
       this._copyPrefs('prefs', '_localPrefs');
 
-      return this.$.restAPI.savePreferences(this.prefs).then(function() {
+      return this.$.restAPI.savePreferences(this.prefs).then(() => {
         this._prefsChanged = false;
-      }.bind(this));
+      });
     },
 
-    _handleLineWrappingChanged: function() {
+    _handleLineWrappingChanged() {
       this.set('_diffPrefs.line_wrapping', this.$.lineWrapping.checked);
     },
 
-    _handleShowTabsChanged: function() {
+    _handleShowTabsChanged() {
       this.set('_diffPrefs.show_tabs', this.$.showTabs.checked);
     },
 
-    _handleShowTrailingWhitespaceChanged: function() {
+    _handleShowTrailingWhitespaceChanged() {
       this.set('_diffPrefs.show_whitespace_errors',
           this.$.showTrailingWhitespace.checked);
     },
 
-    _handleSyntaxHighlightingChanged: function() {
+    _handleSyntaxHighlightingChanged() {
       this.set('_diffPrefs.syntax_highlighting',
           this.$.syntaxHighlighting.checked);
     },
 
-    _handleSaveChangeTable: function() {
+    _handleSaveChangeTable() {
       this.set('prefs.change_table', this._localChangeTableColumns);
+      this.set('prefs.legacycid_in_change_table', this._showNumber);
       this._cloneChangeTableColumns();
-      return this.$.restAPI.savePreferences(this.prefs).then(function() {
+      return this.$.restAPI.savePreferences(this.prefs).then(() => {
         this._changeTableChanged = false;
-      }.bind(this));
+      });
     },
 
-    _handleSaveDiffPreferences: function() {
+    _handleSaveDiffPreferences() {
       return this.$.restAPI.saveDiffPreferences(this._diffPrefs)
-          .then(function() {
+          .then(() => {
             this._diffPrefsChanged = false;
-          }.bind(this));
+          });
     },
 
-    _handleSaveMenu: function() {
+    _handleSaveMenu() {
       this.set('prefs.my', this._localMenu);
-      this._cloneMenu();
-      return this.$.restAPI.savePreferences(this.prefs).then(function() {
+      this._cloneMenu(this.prefs.my);
+      return this.$.restAPI.savePreferences(this.prefs).then(() => {
         this._menuChanged = false;
-      }.bind(this));
+      });
     },
 
-    _handleSaveWatchedProjects: function() {
+    _handleResetMenuButton() {
+      return this.$.restAPI.getDefaultPreferences().then(data => {
+        if (data && data.my) {
+          this._cloneMenu(data.my);
+        }
+      });
+    },
+
+    _handleSaveWatchedProjects() {
       this.$.watchedProjectsEditor.save();
     },
 
-    _computeHeaderClass: function(changed) {
+    _computeHeaderClass(changed) {
       return changed ? 'edited' : '';
     },
 
-    _handleSaveEmails: function() {
+    _handleSaveEmails() {
       this.$.emailEditor.save();
     },
 
-    _handleNewEmailKeydown: function(e) {
+    _handleNewEmailKeydown(e) {
       if (e.keyCode === 13) { // Enter
         e.stopPropagation();
         this._handleAddEmailButton();
       }
     },
 
-    _isNewEmailValid: function(newEmail) {
-      return newEmail.indexOf('@') !== -1;
+    _isNewEmailValid(newEmail) {
+      return newEmail.includes('@');
     },
 
-    _computeAddEmailButtonEnabled: function(newEmail, addingEmail) {
+    _computeAddEmailButtonEnabled(newEmail, addingEmail) {
       return this._isNewEmailValid(newEmail) && !addingEmail;
     },
 
-    _handleAddEmailButton: function() {
+    _handleAddEmailButton() {
       if (!this._isNewEmailValid(this._newEmail)) { return; }
 
       this._addingEmail = true;
-      this.$.restAPI.addAccountEmail(this._newEmail).then(function(response) {
+      this.$.restAPI.addAccountEmail(this._newEmail).then(response => {
         this._addingEmail = false;
 
         // If it was unsuccessful.
@@ -353,7 +381,19 @@
 
         this._lastSentVerificationEmail = this._newEmail;
         this._newEmail = '';
-      }.bind(this));
+      });
+    },
+
+    _getFilterDocsLink(docsBaseUrl) {
+      let base = docsBaseUrl;
+      if (!base || !ABSOLUTE_URL_PATTERN.test(base)) {
+        base = GERRIT_DOCS_BASE_URL;
+      }
+
+      // Remove any trailing slash, since it is in the GERRIT_DOCS_FILTER_PATH.
+      base = base.replace(TRAILING_SLASH_PATTERN, '');
+
+      return base + GERRIT_DOCS_FILTER_PATH;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
index cb471d4..01a3dc2 100644
--- a/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-settings-view/gr-settings-view_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-settings-view.html">
 
 <script>void(0);</script>
@@ -39,20 +38,20 @@
 </test-fixture>
 
 <script>
-  suite('gr-settings-view tests', function() {
-    var element;
-    var account;
-    var preferences;
-    var diffPreferences;
-    var config;
-    var sandbox;
+  suite('gr-settings-view tests', () => {
+    let element;
+    let account;
+    let preferences;
+    let diffPreferences;
+    let config;
+    let sandbox;
 
     function valueOf(title, fieldsetid) {
-      var sections = element.$[fieldsetid].querySelectorAll('section');
-      var titleEl;
-      for (var i = 0; i < sections.length; i++) {
+      const sections = element.$[fieldsetid].querySelectorAll('section');
+      let titleEl;
+      for (let i = 0; i < sections.length; i++) {
         titleEl = sections[i].querySelector('.title');
-        if (titleEl.textContent === title) {
+        if (titleEl.textContent.trim() === title) {
           return sections[i].querySelector('.value');
         }
       }
@@ -61,7 +60,7 @@
     // Because deepEqual isn't behaving in Safari.
     function assertMenusEqual(actual, expected) {
       assert.equal(actual.length, expected.length);
-      for (var i = 0; i < actual.length; i++) {
+      for (let i = 0; i < actual.length; i++) {
         assert.equal(actual[i].name, expected[i].name);
         assert.equal(actual[i].url, expected[i].url);
       }
@@ -69,10 +68,10 @@
 
     function stubAddAccountEmail(statusCode) {
       return sandbox.stub(element.$.restAPI, 'addAccountEmail',
-          function() { return Promise.resolve({status: statusCode}); });
+          () => { return Promise.resolve({status: statusCode}); });
     }
 
-    setup(function(done) {
+    setup(done => {
       sandbox = sinon.sandbox.create();
       account = {
         _account_id: 123,
@@ -109,23 +108,23 @@
         syntax_highlighting: true,
         auto_hide_diff_table_header: true,
         theme: 'DEFAULT',
-        ignore_whitespace: 'IGNORE_NONE'
+        ignore_whitespace: 'IGNORE_NONE',
       };
       config = {auth: {editable_account_fields: []}};
 
       stub('gr-rest-api-interface', {
-        getLoggedIn: function() { return Promise.resolve(true); },
-        getAccount: function() { return Promise.resolve(account); },
-        getPreferences: function() { return Promise.resolve(preferences); },
-        getDiffPreferences: function() {
+        getLoggedIn() { return Promise.resolve(true); },
+        getAccount() { return Promise.resolve(account); },
+        getPreferences() { return Promise.resolve(preferences); },
+        getDiffPreferences() {
           return Promise.resolve(diffPreferences);
         },
-        getWatchedProjects: function() {
+        getWatchedProjects() {
           return Promise.resolve([]);
         },
-        getAccountEmails: function() { return Promise.resolve(); },
-        getConfig: function() { return Promise.resolve(config); },
-        getAccountGroups: function() { return Promise.resolve([]); },
+        getAccountEmails() { return Promise.resolve(); },
+        getConfig() { return Promise.resolve(config); },
+        getAccountGroups() { return Promise.resolve([]); },
       });
       element = fixture('basic');
 
@@ -133,19 +132,19 @@
       element._loadingPromise.then(done);
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('calls the title-change event', function() {
-      var titleChangedStub = sandbox.stub();
+    test('calls the title-change event', () => {
+      const titleChangedStub = sandbox.stub();
 
       // Create a new view.
-      var newElement = document.createElement('gr-settings-view');
+      const newElement = document.createElement('gr-settings-view');
       newElement.addEventListener('title-change', titleChangedStub);
 
       // Attach it to the fixture.
-      var blank = fixture('blank');
+      const blank = fixture('blank');
       blank.appendChild(newElement);
 
       Polymer.dom.flush();
@@ -155,7 +154,7 @@
           'Settings');
     });
 
-    test('user preferences', function(done) {
+    test('user preferences', done => {
       // Rendered with the expected preferences selected.
       assert.equal(valueOf('Changes per page', 'preferences')
           .firstElementChild.bindValue, preferences.changes_per_page);
@@ -171,15 +170,23 @@
           .firstElementChild.bindValue, preferences.diff_view);
       assert.equal(valueOf('Expand inline diffs', 'preferences')
           .firstElementChild.checked, false);
+      assert.equal(valueOf('Publish comments on push', 'preferences')
+          .firstElementChild.checked, false);
+      assert.equal(valueOf(
+          'Set new changes to "work in progress" by default', 'preferences')
+          .firstElementChild.checked, false);
+      assert.equal(valueOf(
+          'Insert Signed-off-by Footer For Inline Edit Changes', 'preferences')
+          .firstElementChild.checked, false);
 
       assert.isFalse(element._prefsChanged);
       assert.isFalse(element._menuChanged);
 
       // Change the diff view element.
-      var diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
+      const diffSelect = valueOf('Diff view', 'preferences').firstElementChild;
       diffSelect.bindValue = 'SIDE_BY_SIDE';
 
-      var expandInlineDiffs =
+      const expandInlineDiffs =
           valueOf('Expand inline diffs', 'preferences').firstElementChild;
       diffSelect.fire('change');
 
@@ -189,23 +196,70 @@
       assert.isFalse(element._menuChanged);
 
       stub('gr-rest-api-interface', {
-        savePreferences: function(prefs) {
+        savePreferences(prefs) {
           assert.equal(prefs.diff_view, 'SIDE_BY_SIDE');
           assertMenusEqual(prefs.my, preferences.my);
           assert.equal(prefs.expand_inline_diffs, true);
           return Promise.resolve();
-        }
+        },
       });
 
       // Save the change.
-      element._handleSavePreferences().then(function() {
+      element._handleSavePreferences().then(() => {
         assert.isFalse(element._prefsChanged);
         assert.isFalse(element._menuChanged);
         done();
       });
     });
 
-    test('diff preferences', function(done) {
+    test('publish comments on push', done => {
+      const publishCommentsOnPush =
+        valueOf('Publish comments on push', 'preferences').firstElementChild;
+      MockInteractions.tap(publishCommentsOnPush);
+
+      assert.isFalse(element._menuChanged);
+      assert.isTrue(element._prefsChanged);
+
+      stub('gr-rest-api-interface', {
+        savePreferences(prefs) {
+          assert.equal(prefs.publish_comments_on_push, true);
+          return Promise.resolve();
+        },
+      });
+
+      // Save the change.
+      element._handleSavePreferences().then(() => {
+        assert.isFalse(element._prefsChanged);
+        assert.isFalse(element._menuChanged);
+        done();
+      });
+    });
+
+    test('set new changes work-in-progress', done => {
+      const newChangesWorkInProgress =
+        valueOf('Set new changes to "work in progress" by default',
+            'preferences').firstElementChild;
+      MockInteractions.tap(newChangesWorkInProgress);
+
+      assert.isFalse(element._menuChanged);
+      assert.isTrue(element._prefsChanged);
+
+      stub('gr-rest-api-interface', {
+        savePreferences(prefs) {
+          assert.equal(prefs.work_in_progress_by_default, true);
+          return Promise.resolve();
+        },
+      });
+
+      // Save the change.
+      element._handleSavePreferences().then(() => {
+        assert.isFalse(element._prefsChanged);
+        assert.isFalse(element._menuChanged);
+        done();
+      });
+    });
+
+    test('diff preferences', done => {
       // Rendered with the expected preferences selected.
       assert.equal(valueOf('Context', 'diffPreferences')
           .firstElementChild.bindValue, diffPreferences.context);
@@ -224,7 +278,7 @@
 
       assert.isFalse(element._diffPrefsChanged);
 
-      var showTabsCheckbox = valueOf('Show tabs', 'diffPreferences')
+      const showTabsCheckbox = valueOf('Show tabs', 'diffPreferences')
           .firstElementChild;
       showTabsCheckbox.checked = false;
       element._handleShowTabsChanged();
@@ -232,20 +286,20 @@
       assert.isTrue(element._diffPrefsChanged);
 
       stub('gr-rest-api-interface', {
-        saveDiffPreferences: function(prefs) {
+        saveDiffPreferences(prefs) {
           assert.equal(prefs.show_tabs, false);
           return Promise.resolve();
-        }
+        },
       });
 
       // Save the change.
-      element._handleSaveDiffPreferences().then(function() {
+      element._handleSaveDiffPreferences().then(() => {
         assert.isFalse(element._diffPrefsChanged);
         done();
       });
     });
 
-    test('columns input is hidden with fit to scsreen is selected', function() {
+    test('columns input is hidden with fit to scsreen is selected', () => {
       assert.isFalse(element.$.columnsPref.hidden);
 
       MockInteractions.tap(element.$.lineWrapping);
@@ -255,14 +309,14 @@
       assert.isFalse(element.$.columnsPref.hidden);
     });
 
-    test('menu', function(done) {
+    test('menu', done => {
       assert.isFalse(element._menuChanged);
       assert.isFalse(element._prefsChanged);
 
       assertMenusEqual(element._localMenu, preferences.my);
 
-      var menu = element.$.menu.firstElementChild;
-      var tableRows = Polymer.dom(menu.root).querySelectorAll('tbody tr');
+      const menu = element.$.menu.firstElementChild;
+      let tableRows = Polymer.dom(menu.root).querySelectorAll('tbody tr');
       assert.equal(tableRows.length, preferences.my.length);
 
       // Add a menu item:
@@ -276,13 +330,13 @@
       assert.isFalse(element._prefsChanged);
 
       stub('gr-rest-api-interface', {
-        savePreferences: function(prefs) {
+        savePreferences(prefs) {
           assertMenusEqual(prefs.my, element._localMenu);
           return Promise.resolve();
-        }
+        },
       });
 
-      element._handleSaveMenu().then(function() {
+      element._handleSaveMenu().then(() => {
         assert.isFalse(element._menuChanged);
         assert.isFalse(element._prefsChanged);
         assertMenusEqual(element.prefs.my, element._localMenu);
@@ -290,7 +344,7 @@
       });
     });
 
-    test('add email validation', function() {
+    test('add email validation', () => {
       assert.isFalse(element._isNewEmailValid('invalid email'));
       assert.isTrue(element._isNewEmailValid('vaguely@valid.email'));
 
@@ -302,8 +356,8 @@
           element._computeAddEmailButtonEnabled('vaguely@valid.email', false));
     });
 
-    test('add email does not save invalid', function() {
-      var addEmailStub = stubAddAccountEmail(201);
+    test('add email does not save invalid', () => {
+      const addEmailStub = stubAddAccountEmail(201);
 
       assert.isFalse(element._addingEmail);
       assert.isNotOk(element._lastSentVerificationEmail);
@@ -318,8 +372,8 @@
       assert.isFalse(addEmailStub.called);
     });
 
-    test('add email does save valid', function(done) {
-      var addEmailStub = stubAddAccountEmail(201);
+    test('add email does save valid', done => {
+      const addEmailStub = stubAddAccountEmail(201);
 
       assert.isFalse(element._addingEmail);
       assert.isNotOk(element._lastSentVerificationEmail);
@@ -331,14 +385,14 @@
       assert.isTrue(addEmailStub.called);
 
       assert.isTrue(addEmailStub.called);
-      addEmailStub.lastCall.returnValue.then(function() {
+      addEmailStub.lastCall.returnValue.then(() => {
         assert.isOk(element._lastSentVerificationEmail);
         done();
       });
     });
 
-    test('add email does not set last-email if error', function(done) {
-      var addEmailStub = stubAddAccountEmail(500);
+    test('add email does not set last-email if error', done => {
+      const addEmailStub = stubAddAccountEmail(500);
 
       assert.isNotOk(element._lastSentVerificationEmail);
       element._newEmail = 'valid@email.com';
@@ -346,58 +400,145 @@
       element._handleAddEmailButton();
 
       assert.isTrue(addEmailStub.called);
-      addEmailStub.lastCall.returnValue.then(function() {
+      addEmailStub.lastCall.returnValue.then(() => {
         assert.isNotOk(element._lastSentVerificationEmail);
         done();
       });
     });
 
-    test('emails are loaded without emailToken', function() {
+    test('emails are loaded without emailToken', () => {
       sandbox.stub(element.$.emailEditor, 'loadData');
       element.params = {};
       element.attached();
       assert.isTrue(element.$.emailEditor.loadData.calledOnce);
     });
 
-    suite('when email verification token is provided', function() {
-      var resolveConfirm;
+    test('_handleSaveChangeTable', () => {
+      let newColumns = ['Owner', 'Project', 'Branch'];
+      element._localChangeTableColumns = newColumns.slice(0);
+      element._showNumber = false;
+      const cloneStub = sandbox.stub(element, '_cloneChangeTableColumns');
+      element._handleSaveChangeTable();
+      assert.isTrue(cloneStub.calledOnce);
+      assert.deepEqual(element.prefs.change_table, newColumns);
+      assert.isNotOk(element.prefs.legacycid_in_change_table);
 
-      setup(function() {
+      newColumns = ['Size'];
+      element._localChangeTableColumns = newColumns;
+      element._showNumber = true;
+      element._handleSaveChangeTable();
+      assert.isTrue(cloneStub.calledTwice);
+      assert.deepEqual(element.prefs.change_table, newColumns);
+      assert.isTrue(element.prefs.legacycid_in_change_table);
+    });
+
+    test('reset menu item back to default', done => {
+      const originalMenu = {
+        my: [
+          {url: '/first/url', name: 'first name', target: '_blank'},
+          {url: '/second/url', name: 'second name', target: '_blank'},
+          {url: '/third/url', name: 'third name', target: '_blank'},
+        ],
+      };
+
+      stub('gr-rest-api-interface', {
+        getDefaultPreferences() { return Promise.resolve(originalMenu); },
+      });
+
+      const updatedMenu = [
+        {url: '/first/url', name: 'first name', target: '_blank'},
+        {url: '/second/url', name: 'second name', target: '_blank'},
+        {url: '/third/url', name: 'third name', target: '_blank'},
+        {url: '/fourth/url', name: 'fourth name', target: '_blank'},
+      ];
+
+      element.set('_localMenu', updatedMenu);
+
+      element._handleResetMenuButton().then(() => {
+        assertMenusEqual(element._localMenu, originalMenu.my);
+        done();
+      });
+    });
+
+    test('test that reset button is called', () => {
+      const overlayOpen = sandbox.stub(element, '_handleResetMenuButton');
+
+      MockInteractions.tap(element.$.resetMenu);
+
+      assert.isTrue(overlayOpen.called);
+    });
+
+    suite('_getFilterDocsLink', () => {
+      test('with http: docs base URL', () => {
+        const base = 'http://example.com/';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'http://example.com/user-notify.html');
+      });
+
+      test('with http: docs base URL without slash', () => {
+        const base = 'http://example.com';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'http://example.com/user-notify.html');
+      });
+
+      test('with https: docs base URL', () => {
+        const base = 'https://example.com/';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'https://example.com/user-notify.html');
+      });
+
+      test('without docs base URL', () => {
+        const result = element._getFilterDocsLink(null);
+        assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+            'Documentation/user-notify.html');
+      });
+
+      test('ignores non HTTP links', () => {
+        const base = 'javascript://alert("evil");';
+        const result = element._getFilterDocsLink(base);
+        assert.equal(result, 'https://gerrit-review.googlesource.com/' +
+            'Documentation/user-notify.html');
+      });
+    });
+
+    suite('when email verification token is provided', () => {
+      let resolveConfirm;
+
+      setup(() => {
         sandbox.stub(element.$.emailEditor, 'loadData');
-        sandbox.stub(element.$.restAPI, 'confirmEmail', function() {
-          return new Promise(function(resolve) { resolveConfirm = resolve; });
+        sandbox.stub(element.$.restAPI, 'confirmEmail', () => {
+          return new Promise(resolve => { resolveConfirm = resolve; });
         });
         element.params = {emailToken: 'foo'};
         element.attached();
       });
 
-      test('it is used to confirm email via rest API', function() {
+      test('it is used to confirm email via rest API', () => {
         assert.isTrue(element.$.restAPI.confirmEmail.calledOnce);
         assert.isTrue(element.$.restAPI.confirmEmail.calledWith('foo'));
       });
 
-      test('emails are not loaded initially', function() {
+      test('emails are not loaded initially', () => {
         assert.isFalse(element.$.emailEditor.loadData.called);
       });
 
-      test('user emails are loaded after email confirmed', function(done) {
-        element._loadingPromise.then(function() {
+      test('user emails are loaded after email confirmed', done => {
+        element._loadingPromise.then(() => {
           assert.isTrue(element.$.emailEditor.loadData.calledOnce);
           done();
         });
         resolveConfirm();
       });
 
-      test('show-alert is fired when email is confirmed', function(done) {
+      test('show-alert is fired when email is confirmed', done => {
         sandbox.spy(element, 'fire');
-        element._loadingPromise.then(function() {
+        element._loadingPromise.then(() => {
           assert.isTrue(
               element.fire.calledWith('show-alert', {message: 'bar'}));
           done();
         });
         resolveConfirm('bar');
       });
-
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
index 339c7a7..1afd255 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.html
@@ -16,17 +16,16 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-ssh-editor">
   <template>
-    <style>
-      .commentHeader {
-        width: 27em;
-      }
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
       .statusHeader {
         width: 4em;
       }
@@ -48,22 +47,29 @@
         position: absolute;
         right: 2em;
       }
+      #existing {
+        margin-bottom: 1em;
+      }
+      #existing .commentColumn {
+        min-width: 27em;
+        width: auto;
+      }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
-      <fieldset>
+    <div class="gr-form-styles">
+      <fieldset id="existing">
         <table>
           <thead>
             <tr>
-              <th class="commentHeader">Comment</th>
+              <th class="commentColumn">Comment</th>
               <th class="statusHeader">Status</th>
               <th class="keyHeader">Public key</th>
+              <th></th>
             </tr>
           </thead>
           <tbody>
             <template is="dom-repeat" items="[[_keys]]" as="key">
               <tr>
-                <td>[[key.comment]]</td>
+                <td class="commentColumn">[[key.comment]]</td>
                 <td>[[_getStatusLabel(key.valid)]]</td>
                 <td>
                   <gr-button
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
index 2a05033..fb868e8 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor.js
@@ -24,6 +24,7 @@
         notify: true,
       },
       _keys: Array,
+      /** @type {?} */
       _keyToView: Object,
       _newKey: {
         type: String,
@@ -31,64 +32,65 @@
       },
       _keysToRemove: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
     },
 
-    loadData: function() {
-      return this.$.restAPI.getAccountSSHKeys().then(function(keys) {
+    loadData() {
+      return this.$.restAPI.getAccountSSHKeys().then(keys => {
         this._keys = keys;
-      }.bind(this));
+      });
     },
 
-    save: function() {
-      var promises = this._keysToRemove.map(function(key) {
+    save() {
+      const promises = this._keysToRemove.map(key => {
         this.$.restAPI.deleteAccountSSHKey(key.seq);
-      }.bind(this));
+      });
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         this._keysToRemove = [];
         this.hasUnsavedChanges = false;
-      }.bind(this));
+      });
     },
 
-    _getStatusLabel: function(isValid) {
+    _getStatusLabel(isValid) {
       return isValid ? 'Valid' : 'Invalid';
     },
 
-    _showKey: function(e) {
-      var index = parseInt(e.target.getAttribute('data-index'), 10);
+    _showKey(e) {
+      const el = Polymer.dom(e).localTarget;
+      const index = parseInt(el.getAttribute('data-index'), 10);
       this._keyToView = this._keys[index];
       this.$.viewKeyOverlay.open();
     },
 
-    _closeOverlay: function() {
+    _closeOverlay() {
       this.$.viewKeyOverlay.close();
     },
 
-    _handleDeleteKey: function(e) {
-      var index = parseInt(e.target.getAttribute('data-index'), 10);
+    _handleDeleteKey(e) {
+      const el = Polymer.dom(e).localTarget;
+      const index = parseInt(el.getAttribute('data-index'), 10);
       this.push('_keysToRemove', this._keys[index]);
       this.splice('_keys', index, 1);
       this.hasUnsavedChanges = true;
     },
 
-    _handleAddKey: function() {
+    _handleAddKey() {
       this.$.addButton.disabled = true;
       this.$.newKey.disabled = true;
       return this.$.restAPI.addAccountSSHKey(this._newKey.trim())
-          .then(function(key) {
+          .then(key => {
             this.$.newKey.disabled = false;
             this._newKey = '';
             this.push('_keys', key);
-          }.bind(this))
-          .catch(function() {
+          }).catch(() => {
             this.$.addButton.disabled = false;
             this.$.newKey.disabled = false;
-          }.bind(this));
+          });
     },
 
-    _computeAddButtonDisabled: function(newKey) {
+    _computeAddButtonDisabled(newKey) {
       return !newKey.length;
     },
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
index 7bb5528..4273f7a 100644
--- a/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-ssh-editor/gr-ssh-editor_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-ssh-editor.html">
 
 <script>void(0);</script>
@@ -33,11 +32,11 @@
 </test-fixture>
 
 <script>
-  suite('gr-ssh-editor tests', function() {
-    var element;
-    var keys;
+  suite('gr-ssh-editor tests', () => {
+    let element;
+    let keys;
 
-    setup(function(done) {
+    setup(done => {
       keys = [{
         seq: 1,
         ssh_public_key: 'ssh-rsa <key 1> comment-one@machine-one',
@@ -55,37 +54,37 @@
       }];
 
       stub('gr-rest-api-interface', {
-        getAccountSSHKeys: function() { return Promise.resolve(keys); },
+        getAccountSSHKeys() { return Promise.resolve(keys); },
       });
 
       element = fixture('basic');
 
-      element.loadData().then(function() { flush(done); });
+      element.loadData().then(() => { flush(done); });
     });
 
-    test('renders', function() {
-      var rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
+    test('renders', () => {
+      const rows = Polymer.dom(element.root).querySelectorAll('tbody tr');
 
       assert.equal(rows.length, 2);
 
-      var cells = rows[0].querySelectorAll('td');
+      let cells = rows[0].querySelectorAll('td');
       assert.equal(cells[0].textContent, keys[0].comment);
 
       cells = rows[1].querySelectorAll('td');
       assert.equal(cells[0].textContent, keys[1].comment);
     });
 
-    test('remove key', function(done) {
-      var lastKey = keys[1];
+    test('remove key', done => {
+      const lastKey = keys[1];
 
-      var saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
-          function() { return Promise.resolve(); });
+      const saveStub = sinon.stub(element.$.restAPI, 'deleteAccountSSHKey',
+          () => { return Promise.resolve(); });
 
       assert.equal(element._keysToRemove.length, 0);
       assert.isFalse(element.hasUnsavedChanges);
 
       // Get the delete button for the last row.
-      var button = Polymer.dom(element.root).querySelector(
+      const button = Polymer.dom(element.root).querySelector(
           'tbody tr:last-of-type td:nth-child(4) gr-button');
 
       MockInteractions.tap(button);
@@ -96,7 +95,7 @@
       assert.isTrue(element.hasUnsavedChanges);
       assert.isFalse(saveStub.called);
 
-      element.save().then(function() {
+      element.save().then(() => {
         assert.isTrue(saveStub.called);
         assert.equal(saveStub.lastCall.args[0], lastKey.seq);
         assert.equal(element._keysToRemove.length, 0);
@@ -105,11 +104,11 @@
       });
     });
 
-    test('show key', function() {
-      var openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
+    test('show key', () => {
+      const openSpy = sinon.spy(element.$.viewKeyOverlay, 'open');
 
       // Get the show button for the last row.
-      var button = Polymer.dom(element.root).querySelector(
+      const button = Polymer.dom(element.root).querySelector(
           'tbody tr:last-of-type td:nth-child(3) gr-button');
 
       MockInteractions.tap(button);
@@ -118,9 +117,9 @@
       assert.isTrue(openSpy.called);
     });
 
-    test('add key', function(done) {
-      var newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
-      var newKeyObject = {
+    test('add key', done => {
+      const newKeyString = 'ssh-rsa <key 3> comment-three@machine-three';
+      const newKeyObject = {
         seq: 3,
         ssh_public_key: newKeyString,
         encoded_key: '<key 3>',
@@ -129,15 +128,15 @@
         valid: true,
       };
 
-      var addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          function() { return Promise.resolve(newKeyObject); });
+      const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+          () => { return Promise.resolve(newKeyObject); });
 
       element._newKey = newKeyString;
 
       assert.isFalse(element.$.addButton.disabled);
       assert.isFalse(element.$.newKey.disabled);
 
-      element._handleAddKey().then(function() {
+      element._handleAddKey().then(() => {
         assert.isTrue(element.$.addButton.disabled);
         assert.isFalse(element.$.newKey.disabled);
         assert.equal(element._keys.length, 3);
@@ -151,18 +150,18 @@
       assert.equal(addStub.lastCall.args[0], newKeyString);
     });
 
-    test('add invalid key', function(done) {
-      var newKeyString = 'not even close to valid';
+    test('add invalid key', done => {
+      const newKeyString = 'not even close to valid';
 
-      var addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
-          function() { return Promise.reject(); });
+      const addStub = sinon.stub(element.$.restAPI, 'addAccountSSHKey',
+          () => { return Promise.reject(); });
 
       element._newKey = newKeyString;
 
       assert.isFalse(element.$.addButton.disabled);
       assert.isFalse(element.$.newKey.disabled);
 
-      element._handleAddKey().then(function() {
+      element._handleAddKey().then(() => {
         assert.isFalse(element.$.addButton.disabled);
         assert.isFalse(element.$.newKey.disabled);
         assert.equal(element._keys.length, 2);
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
index 61d35f6..cf5e0b1 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.html
@@ -17,46 +17,35 @@
 <link rel="import" href="../../shared/gr-autocomplete/gr-autocomplete.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
-<link rel="import" href="../../../styles/gr-settings-styles.html">
+<link rel="import" href="../../../styles/gr-form-styles.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-watched-projects-editor">
   <template>
-    <style>
-      th.projectHeader {
-        width: 11em;
-      }
-      th.notificationHeader {
-        text-align: center;
-      }
-      th.notifType {
+    <style include="shared-styles"></style>
+    <style include="gr-form-styles">
+      #watchedProjects .notifType {
         text-align: center;
         padding: 0 0.4em;
       }
-      td.notifControl {
+      .notifControl {
         cursor: pointer;
         text-align: center;
       }
-      td.notifControl:hover {
-        border: 1px solid #ddd;
+      .notifControl:hover {
+        outline: 1px solid #ddd;
       }
       .projectFilter {
         color: #777;
         font-style: italic;
         margin-left: 1em;
       }
-      input {
-        font-size: 1em;
-      }
-      .newProjectInput {
-        width: 10em;
-      }
       .newFilterInput {
-        width: 26em;
+        width: 100%;
       }
     </style>
-    <style include="gr-settings-styles"></style>
-    <div class="gr-settings-styles">
-      <table>
+    <div class="gr-form-styles">
+      <table id="watchedProjects">
         <thead>
           <tr>
             <th class="projectHeader">Project</th>
@@ -92,8 +81,9 @@
                       checked$="[[_computeCheckboxChecked(project, type.key)]]">
                 </td>
               </template>
-              <td class="delete-column">
+              <td>
                 <gr-button
+                    link
                     data-index$="[[projectIndex]]"
                     on-tap="_handleRemoveProject">Delete</gr-button>
               </td>
@@ -105,10 +95,8 @@
             <th>
               <gr-autocomplete
                   id="newProject"
-                  class="newProjectInput"
-                  is="iron-input"
                   query="[[_query]]"
-                  threshold="3"
+                  threshold="1"
                   placeholder="Project"></gr-autocomplete>
             </th>
             <th colspan$="[[_getTypeCount()]]">
@@ -119,7 +107,7 @@
                   placeholder="branch:name, or other search expression">
             </th>
             <th>
-              <gr-button on-tap="_handleAddProject">Add</gr-button>
+              <gr-button link on-tap="_handleAddProject">Add</gr-button>
             </th>
           </tr>
         </tfoot>
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
index d65f512..40d1e36 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var NOTIFICATION_TYPES = [
+  const NOTIFICATION_TYPES = [
     {name: 'Changes', key: 'notify_new_changes'},
     {name: 'Patches', key: 'notify_new_patch_sets'},
     {name: 'Comments', key: 'notify_all_comments'},
@@ -35,24 +35,24 @@
       _projects: Array,
       _projectsToRemove: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
       _query: {
         type: Function,
-        value: function() {
+        value() {
           return this._getProjectSuggestions.bind(this);
         },
       },
     },
 
-    loadData: function() {
-      return this.$.restAPI.getWatchedProjects().then(function(projs) {
+    loadData() {
+      return this.$.restAPI.getWatchedProjects().then(projs => {
         this._projects = projs;
-      }.bind(this));
+      });
     },
 
-    save: function() {
-      var deletePromise;
+    save() {
+      let deletePromise;
       if (this._projectsToRemove.length) {
         deletePromise = this.$.restAPI.deleteWatchedProjects(
             this._projectsToRemove);
@@ -61,56 +61,58 @@
       }
 
       return deletePromise
-          .then(function() {
+          .then(() => {
             return this.$.restAPI.saveWatchedProjects(this._projects);
-          }.bind(this))
-          .then(function(projects) {
+          })
+          .then(projects => {
             this._projects = projects;
             this._projectsToRemove = [];
             this.hasUnsavedChanges = false;
-          }.bind(this));
+          });
     },
 
-    _getTypes: function() {
+    _getTypes() {
       return NOTIFICATION_TYPES;
     },
 
-    _getTypeCount: function() {
+    _getTypeCount() {
       return this._getTypes().length;
     },
 
-    _computeCheckboxChecked: function(project, key) {
+    _computeCheckboxChecked(project, key) {
       return project.hasOwnProperty(key);
     },
 
-    _getProjectSuggestions: function(input) {
+    _getProjectSuggestions(input) {
       return this.$.restAPI.getSuggestedProjects(input)
-        .then(function(response) {
-          var projects = [];
-          for (var key in response) {
-            projects.push({
-              name: key,
-              value: response[key],
-            });
-          }
-          return projects;
-        });
+          .then(response => {
+            const projects = [];
+            for (const key in response) {
+              if (!response.hasOwnProperty(key)) { continue; }
+              projects.push({
+                name: key,
+                value: response[key],
+              });
+            }
+            return projects;
+          });
     },
 
-    _handleRemoveProject: function(e) {
-      var index = parseInt(e.target.getAttribute('data-index'), 10);
-      var project = this._projects[index];
+    _handleRemoveProject(e) {
+      const el = Polymer.dom(e).localTarget;
+      const index = parseInt(el.getAttribute('data-index'), 10);
+      const project = this._projects[index];
       this.splice('_projects', index, 1);
       this.push('_projectsToRemove', project);
       this.hasUnsavedChanges = true;
     },
 
-    _canAddProject: function(project, filter) {
+    _canAddProject(project, filter) {
       if (!project || !project.id) { return false; }
 
       // Check if the project with filter is already in the list. Compare
       // filters using == to coalesce null and undefined.
-      for (var i = 0; i < this._projects.length; i++) {
+      for (let i = 0; i < this._projects.length; i++) {
         if (this._projects[i].project === project.id &&
             this._projects[i].filter == filter) {
           return false;
@@ -120,8 +122,9 @@
       return true;
     },
 
-    _getNewProjectIndex: function(name, filter) {
-      for (var i = 0; i < this._projects.length; i++) {
+    _getNewProjectIndex(name, filter) {
+      let i;
+      for (i = 0; i < this._projects.length; i++) {
         if (this._projects[i].project > name ||
             (this._projects[i].project === name &&
                 this._projects[i].filter > filter)) {
@@ -131,18 +134,18 @@
       return i;
     },
 
-    _handleAddProject: function() {
-      var newProject = this.$.newProject.value;
-      var newProjectName = this.$.newProject.text;
-      var filter = this.$.newFilter.value || null;
+    _handleAddProject() {
+      const newProject = this.$.newProject.value;
+      const newProjectName = this.$.newProject.text;
+      const filter = this.$.newFilter.value || null;
 
       if (!this._canAddProject(newProject, filter)) { return; }
 
-      var insertIndex = this._getNewProjectIndex(newProjectName, filter);
+      const insertIndex = this._getNewProjectIndex(newProjectName, filter);
 
       this.splice('_projects', insertIndex, 0, {
         project: newProjectName,
-        filter: filter,
+        filter,
         _is_local: true,
       });
 
@@ -151,16 +154,17 @@
       this.hasUnsavedChanges = true;
     },
 
-    _handleCheckboxChange: function(e) {
-      var index = parseInt(e.target.getAttribute('data-index'), 10);
-      var key = e.target.getAttribute('data-key');
-      var checked = e.target.checked;
+    _handleCheckboxChange(e) {
+      const el = Polymer.dom(e).localTarget;
+      const index = parseInt(el.getAttribute('data-index'), 10);
+      const key = el.getAttribute('data-key');
+      const checked = el.checked;
       this.set(['_projects', index, key], !!checked);
       this.hasUnsavedChanges = true;
     },
 
-    _handleNotifCellTap: function(e) {
-      var checkbox = Polymer.dom(e.target).querySelector('input');
+    _handleNotifCellTap(e) {
+      const checkbox = Polymer.dom(e.target).querySelector('input');
       if (checkbox) { checkbox.click(); }
     },
   });
diff --git a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
index 59e87b0..fbc6217 100644
--- a/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
+++ b/polygerrit-ui/app/elements/settings/gr-watched-projects-editor/gr-watched-projects-editor_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-watched-projects-editor.html">
 
 <script>void(0);</script>
@@ -33,11 +32,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-watched-projects-editor tests', function() {
-    var element;
+  suite('gr-watched-projects-editor tests', () => {
+    let element;
 
-    setup(function(done) {
-      var projects = [{
+    setup(done => {
+      const projects = [
+        {
           project: 'project a',
           notify_submitted_changes: true,
           notify_abandoned_changes: true,
@@ -57,8 +57,8 @@
       ];
 
       stub('gr-rest-api-interface', {
-        getSuggestedProjects: function(input) {
-          if (input.indexOf('the') === 0) {
+        getSuggestedProjects(input) {
+          if (input.startsWith('th')) {
             return Promise.resolve({'the project': {
               id: 'the project',
               state: 'ACTIVE',
@@ -68,27 +68,27 @@
             return Promise.resolve({});
           }
         },
-        getWatchedProjects: function() {
+        getWatchedProjects() {
           return Promise.resolve(projects);
         },
       });
 
       element = fixture('basic');
 
-      element.loadData().then(function() { flush(done); });
+      element.loadData().then(() => { flush(done); });
     });
 
-    test('renders', function() {
-      var rows = element.$$('table').querySelectorAll('tbody tr');
+    test('renders', () => {
+      const rows = element.$$('table').querySelectorAll('tbody tr');
       assert.equal(rows.length, 4);
 
       function getKeysOfRow(row) {
-        var boxes = rows[row].querySelectorAll('input[checked]');
+        const boxes = rows[row].querySelectorAll('input[checked]');
         return Array.prototype.map.call(boxes,
-            function(e) { return e.getAttribute('data-key'); });
+            e => { return e.getAttribute('data-key'); });
       }
 
-      var checkedKeys = getKeysOfRow(0);
+      let checkedKeys = getKeysOfRow(0);
       assert.equal(checkedKeys.length, 2);
       assert.equal(checkedKeys[0], 'notify_submitted_changes');
       assert.equal(checkedKeys[1], 'notify_abandoned_changes');
@@ -107,22 +107,30 @@
       assert.equal(checkedKeys[2], 'notify_all_comments');
     });
 
-    test('_getProjectSuggestions empty', function(done) {
-      element._getProjectSuggestions('nonexistent').then(function(projects) {
+    test('_getProjectSuggestions empty', done => {
+      element._getProjectSuggestions('nonexistent').then(projects => {
         assert.equal(projects.length, 0);
         done();
       });
     });
 
-    test('_getProjectSuggestions non-empty', function(done) {
-      element._getProjectSuggestions('the project').then(function(projects) {
+    test('_getProjectSuggestions non-empty', done => {
+      element._getProjectSuggestions('the project').then(projects => {
         assert.equal(projects.length, 1);
         assert.equal(projects[0].name, 'the project');
         done();
       });
     });
 
-    test('_canAddProject', function() {
+    test('_getProjectSuggestions non-empty with two letter project', done => {
+      element._getProjectSuggestions('th').then(projects => {
+        assert.equal(projects.length, 1);
+        assert.equal(projects[0].name, 'the project');
+        done();
+      });
+    });
+
+    test('_canAddProject', () => {
       assert.isFalse(element._canAddProject(null, null));
       assert.isFalse(element._canAddProject({}, null));
 
@@ -144,7 +152,7 @@
       assert.isTrue(element._canAddProject({id: 'project b'}, 'filter 3'));
     });
 
-    test('_getNewProjectIndex', function() {
+    test('_getNewProjectIndex', () => {
       // Projects are sorted in ASCII order.
       assert.equal(element._getNewProjectIndex('project A', 'filter'), 0);
       assert.equal(element._getNewProjectIndex('project a', 'filter'), 1);
@@ -158,7 +166,7 @@
       assert.equal(element._getNewProjectIndex('project c', 'filter'), 4);
     });
 
-    test('_handleAddProject', function() {
+    test('_handleAddProject', () => {
       element.$.newProject.value = {id: 'project d'};
       element.$.newProject.setText('project d');
       element.$.newFilter.bindValue = '';
@@ -171,7 +179,7 @@
       assert.isTrue(element._projects[4]._is_local);
     });
 
-    test('_handleAddProject with invalid inputs', function() {
+    test('_handleAddProject with invalid inputs', () => {
       element.$.newProject.value = {id: 'project b'};
       element.$.newProject.setText('project b');
       element.$.newFilter.bindValue = 'filter 1';
@@ -181,14 +189,14 @@
       assert.equal(element._projects.length, 4);
     });
 
-    test('_handleRemoveProject', function() {
+    test('_handleRemoveProject', () => {
       assert.equal(element._projectsToRemove, 0);
-      var button = element.$$('table tbody tr:nth-child(2) gr-button');
+      const button = element.$$('table tbody tr:nth-child(2) gr-button');
       MockInteractions.tap(button);
 
       flushAsynchronousOperations();
 
-      var rows = element.$$('table tbody').querySelectorAll('tr');
+      const rows = element.$$('table tbody').querySelectorAll('tr');
       assert.equal(rows.length, 3);
 
       assert.equal(element._projectsToRemove.length, 1);
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
index ddfdae7..b658025 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
@@ -18,10 +18,11 @@
 <link rel="import" href="../gr-account-link/gr-account-link.html">
 <link rel="import" href="../gr-button/gr-button.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-account-chip">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
         overflow: hidden;
@@ -36,24 +37,31 @@
       :host([show-avatar]) .container {
         padding-left: 0;
       }
-      gr-button.remove,
       gr-button.remove:hover,
       gr-button.remove:focus {
-        border-color: transparent;
-        color: #333;
+        --gr-button: {
+          color: #333;
+        }
       }
       gr-button.remove {
-        background: #eee;
-        border: 0;
-        color: #666;
-        font-size: 1.7em;
-        font-weight: normal;
-        height: .6em;
-        line-height: .6em;
-        margin-left: .15em;
-        margin-top: -.05em;
-        padding: 0;
-        text-decoration: none;
+        --gr-button: {
+          border: 0;
+          color: #666;
+          font-size: 1.7em;
+          font-weight: normal;
+          height: .6em;
+          line-height: .6em;
+          margin-left: .15em;
+          margin-top: -.05em;
+          padding: 0;
+          text-decoration: none;
+        }
+        --gr-button-hover-color: {
+          color: #333;
+        }
+        --gr-button-hover-background-color: {
+          color: #333;
+        }
       }
       :host:focus {
         border-color: transparent;
@@ -68,13 +76,19 @@
       gr-button.transparentBackground {
         background-color: transparent;
       }
+      :host([disabled]) {
+        opacity: .6;
+        pointer-events: none;
+      }
     </style>
     <div class$="container [[_getBackgroundClass(transparentBackground)]]">
       <gr-account-link account="[[account]]"></gr-account-link>
       <gr-button
           id="remove"
+          link
           hidden$="[[!removable]]"
           hidden
+          tabindex="-1"
           aria-label="Remove"
           class$="remove [[_getBackgroundClass(transparentBackground)]]"
           on-tap="_handleRemoveTap">×</gr-button>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index 33fc50e..e029ab0 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -33,6 +33,11 @@
 
     properties: {
       account: Object,
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
       removable: {
         type: Boolean,
         value: false,
@@ -47,23 +52,23 @@
       },
     },
 
-    ready: function() {
-      this._getHasAvatars().then(function(hasAvatars) {
+    ready() {
+      this._getHasAvatars().then(hasAvatars => {
         this.showAvatar = hasAvatars;
-      }.bind(this));
+      });
     },
 
-    _getBackgroundClass: function(transparent) {
+    _getBackgroundClass(transparent) {
       return transparent ? 'transparentBackground' : '';
     },
 
-    _handleRemoveTap: function(e) {
+    _handleRemoveTap(e) {
       e.preventDefault();
       this.fire('remove', {account: this.account});
     },
 
-    _getHasAvatars: function() {
-      return this.$.restAPI.getConfig().then(function(cfg) {
+    _getHasAvatars() {
+      return this.$.restAPI.getConfig().then(cfg => {
         return Promise.resolve(!!(cfg && cfg.plugin && cfg.plugin.has_avatars));
       });
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
index 43721fe..21f4c3e 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -14,12 +14,16 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../behaviors/gr-anonymous-name-behavior/gr-anonymous-name-behavior.html">
+<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 <link rel="import" href="../gr-avatar/gr-avatar.html">
+<link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-account-label">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: inline;
       }
@@ -32,15 +36,20 @@
         margin-right: .15em;
         vertical-align: -.25em;
       }
+      .text {
+        @apply(--gr-account-label-text-style);
+      }
       .text:hover {
         @apply(--gr-account-label-text-hover-style);
       }
     </style>
-    <span title$="[[_computeAccountTitle(account)]]">
-      <gr-avatar account="[[account]]"
-          image-size="[[avatarImageSize]]"></gr-avatar>
+    <span>
+      <template is="dom-if" if="[[!hideAvatar]]">
+        <gr-avatar account="[[account]]"
+            image-size="[[avatarImageSize]]"></gr-avatar>
+      </template>
       <span class="text">
-        <span>[[account.name]]</span>
+        <span>[[_computeName(account, _serverConfig)]]</span>
         <span hidden$="[[!_computeShowEmail(showEmail, account)]]">
           [[_computeEmailStr(account)]]
         </span>
@@ -49,6 +58,7 @@
         </template>
       </span>
     </span>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="../../../scripts/util.js"></script>
   <script src="gr-account-label.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
index e9f18df..2dee2f6 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.js
@@ -18,6 +18,9 @@
     is: 'gr-account-label',
 
     properties: {
+      /**
+       * @type {{ name: string, status: string }}
+       */
       account: Object,
       avatarImageSize: {
         type: Number,
@@ -27,13 +30,45 @@
         type: Boolean,
         value: false,
       },
+      title: {
+        type: String,
+        reflectToAttribute: true,
+        computed: '_computeAccountTitle(account)',
+      },
+      hasTooltip: {
+        type: Boolean,
+        reflectToAttribute: true,
+        computed: '_computeHasTooltip(account)',
+      },
+      hideAvatar: {
+        type: Boolean,
+        value: false,
+      },
+      _serverConfig: {
+        type: Object,
+        value: null,
+      },
     },
 
-    _computeAccountTitle: function(account) {
-      if (!account || (!account.name && !account.email)) { return; }
-      var result = '';
-      if (account.name) {
-        result += account.name;
+    behaviors: [
+      Gerrit.AnonymousNameBehavior,
+      Gerrit.TooltipBehavior,
+    ],
+
+    ready() {
+      this.$.restAPI.getConfig()
+          .then(config => { this._serverConfig = config; });
+    },
+
+    _computeName(account, config) {
+      return this.getUserName(config, account, false);
+    },
+
+    _computeAccountTitle(account) {
+      if (!account) { return; }
+      let result = '';
+      if (this._computeName(account, this._serverConfig)) {
+        result += this._computeName(account, this._serverConfig);
       }
       if (account.email) {
         result += ' <' + account.email + '>';
@@ -41,11 +76,11 @@
       return result;
     },
 
-    _computeShowEmail: function(showEmail, account) {
+    _computeShowEmail(showEmail, account) {
       return !!(showEmail && account && account.email);
     },
 
-    _computeEmailStr: function(account) {
+    _computeEmailStr(account) {
       if (!account || !account.email) {
         return '';
       }
@@ -54,5 +89,10 @@
       }
       return account.email;
     },
+
+    _computeHasTooltip(account) {
+      // If an account has loaded to fire this method, then set to true.
+      return !!account;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
index de7d6a3..731c9b7 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label_test.html
@@ -20,6 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="gr-account-label.html">
@@ -33,32 +34,37 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-label tests', function() {
-    var element;
+  suite('gr-account-label tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getLoggedIn: function() { return Promise.resolve(false); },
+        getConfig() { return Promise.resolve({}); },
+        getLoggedIn() { return Promise.resolve(false); },
       });
       element = fixture('basic');
+      element._config = {
+        user: {
+          anonymous_coward_name: 'Anonymous Coward',
+        },
+      };
     });
 
-    test('null guard', function() {
-      assert.doesNotThrow(function() {
+    test('null guard', () => {
+      assert.doesNotThrow(() => {
         element.account = null;
       });
     });
 
-    test('missing email', function() {
+    test('missing email', () => {
       assert.equal('', element._computeEmailStr({name: 'foo'}));
     });
 
-    test('computed fields', function() {
+    test('computed fields', () => {
       assert.equal(element._computeAccountTitle(
           {
             name: 'Andrew Bonventre',
-            email: 'andybons+gerrit@gmail.com'
+            email: 'andybons+gerrit@gmail.com',
           }),
           'Andrew Bonventre <andybons+gerrit@gmail.com>');
 
@@ -66,10 +72,16 @@
           {name: 'Andrew Bonventre'}),
           'Andrew Bonventre');
 
+      assert.equal(element._computeAccountTitle(
+          {
+            email: 'andybons+gerrit@gmail.com',
+          }),
+          'Anonymous <andybons+gerrit@gmail.com>');
+
       assert.equal(element._computeShowEmail(true,
           {
             name: 'Andrew Bonventre',
-            email: 'andybons+gerrit@gmail.com'
+            email: 'andybons+gerrit@gmail.com',
           }), true);
 
       assert.equal(element._computeShowEmail(true,
@@ -89,5 +101,39 @@
       assert.equal(element._computeEmailStr({email: 'test'}, ''), 'test');
     });
 
+    suite('_computeName', () => {
+      test('not showing anonymous', () => {
+        const account = {name: 'Wyatt'};
+        assert.deepEqual(element._computeName(account, null), 'Wyatt');
+      });
+
+      test('showing anonymous but no config', () => {
+        const account = {};
+        assert.deepEqual(element._computeName(account, null),
+            'Anonymous');
+      });
+
+      test('test for Anonymous Coward user and replace with Anonymous', () => {
+        const config = {
+          user: {
+            anonymous_coward_name: 'Anonymous Coward',
+          },
+        };
+        const account = {};
+        assert.deepEqual(element._computeName(account, config),
+            'Anonymous');
+      });
+
+      test('test for anonymous_coward_name', () => {
+        const config = {
+          user: {
+            anonymous_coward_name: 'TestAnon',
+          },
+        };
+        const account = {};
+        assert.deepEqual(element._computeName(account, config),
+            'TestAnon');
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
index 20b6e3f..79747ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
@@ -16,11 +16,13 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../gr-account-label/gr-account-label.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-account-link">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: inline-block;
       }
@@ -35,7 +37,7 @@
       }
     </style>
     <span>
-      <a href$="[[_computeOwnerLink(account)]]">
+      <a href$="[[_computeOwnerLink(account)]]" tabindex="-1">
         <gr-account-label account="[[account]]"
             avatar-image-size="[[avatarImageSize]]"
             show-email="[[_computeShowEmail(account)]]"></gr-account-label>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index 69beb78..7a120c0 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -29,13 +29,14 @@
       Gerrit.BaseUrlBehavior,
     ],
 
-    _computeOwnerLink: function(account) {
+    _computeOwnerLink(account) {
       if (!account) { return; }
-      var accountID = account.email || account._account_id;
-      return this.getBaseUrl() + '/q/owner:' + encodeURIComponent(accountID);
+      return Gerrit.Nav.getUrlForOwner(
+          account.email || account.username || account.name ||
+          account._account_id);
     },
 
-    _computeShowEmail: function(account) {
+    _computeShowEmail(account) {
       return !!(account && !account.name);
     },
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
index 5cc0600..11b099b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
@@ -20,7 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-account-link.html">
 
 <script>void(0);</script>
@@ -32,31 +32,19 @@
 </test-fixture>
 
 <script>
-  suite('gr-account-link tests', function() {
-    var element;
+  suite('gr-account-link tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
     });
 
-    test('computed fields', function() {
-      assert.equal(element._computeOwnerLink(
-          {
-            _account_id: 123,
-            email: 'andybons+gerrit@gmail.com'
-          }),
-          '/q/owner:andybons%2Bgerrit%40gmail.com');
-
-      assert.equal(element._computeOwnerLink({_account_id: 42}),
-          '/q/owner:42');
-
+    test('computed fields', () => {
       assert.equal(element._computeShowEmail({name: 'asd'}), false);
-
       assert.equal(element._computeShowEmail({}), true);
     });
-
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
index 10eadf9..51fa616 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.html
@@ -16,10 +16,13 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-button/gr-button.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<script src="../../../scripts/rootElement.js"></script>
 
 <dom-module id="gr-alert">
   <template>
-    <style>
+    <style include="shared-styles">
       /**
        * ALERT: DO NOT ADD TRANSITION PROPERTIES WITHOUT PROPERLY UNDERSTANDING
        * HOW THEY ARE USED IN THE CODE.
@@ -50,7 +53,7 @@
       }
       .action {
         color: #a1c2fa;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         margin-left: 1em;
         text-decoration: none;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
index 84846fb..bfe7c25 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert.js
@@ -41,50 +41,52 @@
       _hideActionButton: Boolean,
       _boundTransitionEndHandler: {
         type: Function,
-        value: function() { return this._handleTransitionEnd.bind(this); },
+        value() { return this._handleTransitionEnd.bind(this); },
       },
+      _actionCallback: Function,
     },
 
-    attached: function() {
+    attached() {
       this.addEventListener('transitionend', this._boundTransitionEndHandler);
     },
 
-    detached: function() {
+    detached() {
       this.removeEventListener('transitionend',
           this._boundTransitionEndHandler);
     },
 
-    show: function(text, opt_actionText) {
+    show(text, opt_actionText, opt_actionCallback) {
       this.text = text;
       this.actionText = opt_actionText;
       this._hideActionButton = !opt_actionText;
-      document.body.appendChild(this);
+      this._actionCallback = opt_actionCallback;
+      Gerrit.getRootElement().appendChild(this);
       this._setShown(true);
     },
 
-    hide: function() {
+    hide() {
       this._setShown(false);
       if (this._hasZeroTransitionDuration()) {
-        document.body.removeChild(this);
+        Gerrit.getRootElement().removeChild(this);
       }
     },
 
-    _hasZeroTransitionDuration: function() {
-      var style = window.getComputedStyle(this);
+    _hasZeroTransitionDuration() {
+      const style = window.getComputedStyle(this);
       // transitionDuration is always given in seconds.
-      var duration = Math.round(parseFloat(style.transitionDuration) * 100);
+      const duration = Math.round(parseFloat(style.transitionDuration) * 100);
       return duration === 0;
     },
 
-    _handleTransitionEnd: function(e) {
+    _handleTransitionEnd(e) {
       if (this.shown) { return; }
 
-      document.body.removeChild(this);
+      Gerrit.getRootElement().removeChild(this);
     },
 
-    _handleActionTap: function(e) {
+    _handleActionTap(e) {
       e.preventDefault();
-      this.fire('action', null, {bubbles: false});
+      if (this._actionCallback) { this._actionCallback(); }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
index 067ac5b..b8dcb8d 100644
--- a/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-alert/gr-alert_test.html
@@ -20,25 +20,24 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-alert.html">
 
 <script>
-  suite('gr-alert tests', function() {
-    var element;
+  suite('gr-alert tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = document.createElement('gr-alert');
     });
 
-    teardown(function() {
+    teardown(() => {
       if (element.parentNode) {
         element.parentNode.removeChild(element);
       }
     });
 
-    test('show/hide', function() {
+    test('show/hide', () => {
       assert.isNull(element.parentNode);
       element.show();
       assert.equal(element.parentNode, document.body);
@@ -48,13 +47,10 @@
       assert.isNull(element.parentNode);
     });
 
-    test('action event', function(done) {
+    test('action event', done => {
       element.show();
-      element.addEventListener('action', function() {
-        done();
-      });
+      element._actionCallback = done;
       MockInteractions.tap(element.$$('.action'));
     });
-
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
new file mode 100644
index 0000000..83fb7cb
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+<script src="../../../scripts/rootElement.js"></script>
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-autocomplete-dropdown">
+  <template>
+    <style include="shared-styles">
+      :host {
+        z-index: 100;
+      }
+      :host([is-hidden]) {
+        display: none;
+      }
+      ul {
+        list-style: none;
+      }
+      li {
+        cursor: pointer;
+        padding: .5em .75em;
+      }
+      li:focus {
+        outline: none;
+      }
+      li.selected {
+        background-color: #eee;
+      }
+      .dropdown-content {
+        background: #fff;
+        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
+      }
+    </style>
+    <div
+        class="dropdown-content"
+        slot="dropdown-content"
+        id="suggestions"
+        role="listbox">
+      <ul>
+        <template is="dom-repeat" items="[[suggestions]]">
+          <li data-index$="[[index]]"
+              data-value$="[[item.dataValue]]"
+              tabindex="-1"
+              aria-label$="[[item.name]]"
+              role="option"
+              on-tap="_handleTapItem">[[item.text]]</li>
+        </template>
+      </ul>
+    </div>
+    <gr-cursor-manager
+        id="cursor"
+        index="{{index}}"
+        cursor-target-class="selected"
+        scroll-behavior="never"
+        focus-on-move
+        stops="[[_suggestionEls]]"></gr-cursor-manager>
+  </template>
+  <script src="gr-autocomplete-dropdown.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
new file mode 100644
index 0000000..c0449be
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.js
@@ -0,0 +1,162 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-autocomplete-dropdown',
+
+    /**
+     * Fired when the dropdown is closed.
+     *
+     * @event dropdown-closed
+     */
+
+    /**
+     * Fired when item is selected.
+     *
+     * @event item-selected
+     */
+
+    properties: {
+      index: Number,
+      isHidden: {
+        type: Boolean,
+        value: true,
+        reflectToAttribute: true,
+      },
+      verticalOffset: {
+        type: Number,
+        value: null,
+      },
+      horizontalOffset: {
+        type: Number,
+        value: null,
+      },
+      suggestions: {
+        type: Array,
+        observer: '_resetCursorStops',
+      },
+      _suggestionEls: {
+        type: Array,
+        observer: '_resetCursorIndex',
+      },
+    },
+
+    behaviors: [
+      Polymer.IronFitBehavior,
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    keyBindings: {
+      up: '_handleUp',
+      down: '_handleDown',
+      enter: '_handleEnter',
+      esc: '_handleEscape',
+      tab: '_handleTab',
+    },
+
+    close() {
+      this.isHidden = true;
+    },
+
+    open() {
+      this.isHidden = false;
+      this.refit();
+      this._resetCursorStops();
+      this._resetCursorIndex();
+    },
+
+    getCurrentText() {
+      return this.getCursorTarget().dataset.value;
+    },
+
+    _handleUp(e) {
+      if (!this.isHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.cursorUp();
+      }
+    },
+
+    _handleDown(e) {
+      if (!this.isHidden) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.cursorDown();
+      }
+    },
+
+    cursorDown() {
+      if (!this.isHidden) {
+        this.$.cursor.next();
+      }
+    },
+
+    cursorUp() {
+      if (!this.isHidden) {
+        this.$.cursor.previous();
+      }
+    },
+
+    _handleTab(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.fire('item-selected', {
+        trigger: 'tab',
+        selected: this.$.cursor.target,
+      });
+    },
+
+    _handleEnter(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.fire('item-selected', {
+        trigger: 'enter',
+        selected: this.$.cursor.target,
+      });
+    },
+
+    _handleEscape() {
+      this._fireClose();
+      this.close();
+    },
+
+    _handleTapItem(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      this.fire('item-selected', {
+        trigger: 'tap',
+        selected: e.target,
+      });
+    },
+
+    _fireClose() {
+      this.fire('dropdown-closed');
+    },
+
+    getCursorTarget() {
+      return this.$.cursor.target;
+    },
+
+    _resetCursorStops() {
+      Polymer.dom.flush();
+      this._suggestionEls = this.$.suggestions.querySelectorAll('li');
+    },
+
+    _resetCursorIndex() {
+      this.$.cursor.setCursorAtIndex(0);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
new file mode 100644
index 0000000..1995688
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html
@@ -0,0 +1,134 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-autocomplete-dropdown</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-autocomplete-dropdown.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-autocomplete-dropdown></gr-autocomplete-dropdown>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-autocomplete-dropdown', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.open();
+      element.suggestions = [
+        {dataValue: 'test value 1', name: 'test name 1', text: 1},
+        {dataValue: 'test value 2', name: 'test name 2', text: 2}];
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+      if (element.isOpen) element.close();
+    });
+
+    test('escape key', done => {
+      const closeSpy = sandbox.spy(element, 'close');
+      MockInteractions.pressAndReleaseKeyOn(element, 27);
+      flushAsynchronousOperations();
+      assert.isTrue(closeSpy.called);
+      done();
+    });
+
+    test('tab key', () => {
+      const handleTabSpy = sandbox.spy(element, '_handleTab');
+      const itemSelectedStub = sandbox.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      MockInteractions.pressAndReleaseKeyOn(element, 9);
+      assert.isTrue(handleTabSpy.called);
+      assert.equal(element.$.cursor.index, 0);
+      assert.isTrue(itemSelectedStub.called);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'tab',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('enter key', () => {
+      const handleEnterSpy = sandbox.spy(element, '_handleEnter');
+      const itemSelectedStub = sandbox.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+      MockInteractions.pressAndReleaseKeyOn(element, 13);
+      assert.isTrue(handleEnterSpy.called);
+      assert.equal(element.$.cursor.index, 0);
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'enter',
+        selected: element.getCursorTarget(),
+      });
+    });
+
+    test('down key', () => {
+      element.isHidden = true;
+      const nextSpy = sandbox.spy(element.$.cursor, 'next');
+      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      assert.isFalse(nextSpy.called);
+      assert.equal(element.$.cursor.index, 0);
+      element.isHidden = false;
+      MockInteractions.pressAndReleaseKeyOn(element, 40);
+      assert.isTrue(nextSpy.called);
+      assert.equal(element.$.cursor.index, 1);
+    });
+
+    test('up key', () => {
+      element.isHidden = true;
+      const prevSpy = sandbox.spy(element.$.cursor, 'previous');
+      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      assert.isFalse(prevSpy.called);
+      assert.equal(element.$.cursor.index, 0);
+      element.isHidden = false;
+      element.$.cursor.setCursorAtIndex(1);
+      assert.equal(element.$.cursor.index, 1);
+      MockInteractions.pressAndReleaseKeyOn(element, 38);
+      assert.isTrue(prevSpy.called);
+      assert.equal(element.$.cursor.index, 0);
+    });
+
+    test('tapping selects item', () => {
+      const itemSelectedStub = sandbox.stub();
+      element.addEventListener('item-selected', itemSelectedStub);
+
+      MockInteractions.tap(element.$.suggestions.querySelectorAll('li')[1]);
+      flushAsynchronousOperations();
+      assert.deepEqual(itemSelectedStub.lastCall.args[0].detail, {
+        trigger: 'tap',
+        selected: element.$.suggestions.querySelectorAll('li')[1],
+      });
+    });
+
+    test('updated suggestions resets cursor stops', () => {
+      resetStopsSpy = sandbox.spy(element, '_resetCursorStops');
+      element.suggestions = [];
+      assert.isTrue(resetStopsSpy.called);
+    });
+  });
+
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
index aeb7e5f..02fab8f 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.html
@@ -15,75 +15,87 @@
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../../bower_components/paper-input/paper-input.html">
+<link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
 <link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+<link rel="import" href="../../shared/gr-icons/gr-icons.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-autocomplete">
   <template>
-    <style>
-      input {
-        font-size: 1em;
+    <style include="shared-styles">
+      .searchIcon {
+        display: none;
+      }
+      .searchIcon.showSearchIcon {
+        display: initial;
+      }
+      iron-icon {
+        margin: 0 .25em;
+      }
+      paper-input:not(.borderless) {
+        border: 1px solid #ddd;
+      }
+      paper-input {
         height: 100%;
         width: 100%;
+        @apply --gr-autocomplete;
+        --paper-input-container: {
+          padding: 0;
+        }
+        --paper-input-container-input: {
+          font-size: 1em;
+        }
+        --paper-input-container-underline: {
+          display: none;
+        };
+        --paper-input-container-underline-focus: {
+          display: none;
+        };
+        --paper-input-container-underline-disabled: {
+          display: none;
+        };
       }
-      input.borderless,
-      input.borderless:focus {
-        border: none;
-        outline: none;
-      }
-      #suggestions {
-        background-color: #fff;
-        box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
-        position: absolute;
-        z-index: 10;
-      }
-      ul {
-        list-style: none;
-      }
-      li {
-        cursor: pointer;
-        padding: .5em .75em;
-      }
-      li:focus {
-        outline: none;
-      }
-      li.selected {
-        background-color: #eee;
+      paper-input.warnUncommitted {
+        --paper-input-container-input: {
+          color: #ff0000;
+          font-size: 1em;
+        }
       }
     </style>
-    <input
+    <paper-input
+        no-label-float
         id="input"
         class$="[[_computeClass(borderless)]]"
         is="iron-input"
         disabled$="[[disabled]]"
-        bind-value="{{text}}"
+        value="{{text}}"
         placeholder="[[placeholder]]"
         on-keydown="_handleKeydown"
         on-focus="_onInputFocus"
-        autocomplete="off" />
-    <div
+        on-blur="_onInputBlur"
+        autocomplete="off">
+      <!-- slot is for future use (2.x) while prefix attribute is for 1.x
+        (current) -->
+      <iron-icon
+          icon="gr-icons:search"
+          slot="prefix"
+          prefix
+          class$="searchIcon [[_computeShowSearchIconClass(showSearchIcon)]]">
+      </iron-icon>
+    </paper-input>
+    <gr-autocomplete-dropdown
+        vertical-align="top"
+        vertical-offset="[[verticalOffset]]"
+        horizontal-align="left"
         id="suggestions"
+        on-item-selected="_handleItemSelect"
+        on-keydown="_handleKeydown"
+        suggestions="[[_suggestions]]"
         role="listbox"
-        hidden$="[[_computeSuggestionsHidden(_suggestions, _focused)]]">
-      <ul>
-        <template is="dom-repeat" items="[[_suggestions]]">
-          <li
-              data-index$="[[index]]"
-              tabindex="-1"
-              aria-label$="[[item.name]]"
-              on-keydown="_handleKeydown"
-              role="option"
-              on-tap="_handleSuggestionTap">[[item.name]]</li>
-        </template>
-      </ul>
-    </div>
-    <gr-cursor-manager
-        id="cursor"
-        index="{{_index}}"
-        cursor-target-class="selected"
-        scroll-behavior="keep-visible"
-        focus-on-move
-        stops="[[_getSuggestionElems(_suggestions)]]"></gr-cursor-manager>
+        index="[[_index]]"
+        position-target="[[_inputElement]]">
+    </gr-autocomplete-dropdown>
   </template>
   <script src="gr-autocomplete.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
index 9ebb794..6c01262 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
+  const TOKENIZE_REGEX = /(?:[^\s"]+|"[^"]*")+/g;
 
   Polymer({
     is: 'gr-autocomplete',
@@ -48,11 +48,11 @@
        * suggestion entry. The "value" property will be emitted if that
        * suggestion is selected.
        *
-       * @type {function(String): Promise<Array<Object>>}
+       * @type {function(string): Promise<?>}
        */
       query: {
         type: Function,
-        value: function() {
+        value() {
           return function() {
             return Promise.resolve([]);
           };
@@ -61,15 +61,26 @@
 
       /**
        * The number of characters that must be typed before suggestions are
-       * made.
+       * made. If threshold is zero, default suggestions are enabled.
        */
       threshold: {
         type: Number,
         value: 1,
       },
 
+      allowNonSuggestedValues: Boolean,
       borderless: Boolean,
       disabled: Boolean,
+      showSearchIcon: {
+        type: Boolean,
+        value: false,
+      },
+      // Vertical offset needed for a 1em font-size with no vertical padding.
+      // Inputs with additional padding will need to increase vertical offset.
+      verticalOffset: {
+        type: Number,
+        value: 20,
+      },
 
       text: {
         type: String,
@@ -87,14 +98,18 @@
 
       /**
        * When true, tab key autocompletes but does not fire the commit event.
-       * See Issue 4556.
+       * When false, tab key not caught, and focus is removed from the element.
+       * See Issue 4556, Issue 6645.
        */
-      tabCompleteWithoutCommit: {
+      tabComplete: {
         type: Boolean,
         value: false,
       },
 
-      value: Object,
+      value: {
+        type: String,
+        notify: true,
+      },
 
       /**
        * Multi mode appends autocompleted entries to the value.
@@ -105,30 +120,49 @@
         value: false,
       },
 
-      _suggestions: {
-        type: Array,
-        value: function() { return []; },
-      },
-
-      _index: Number,
-
-      _disableSuggestions: {
+      /**
+       * When true and uncommitted text is left in the autocomplete input after
+       * blurring, the text will appear red.
+       */
+      warnUncommitted: {
         type: Boolean,
         value: false,
       },
 
+      /** @type {?} */
+      _suggestions: {
+        type: Array,
+        value() { return []; },
+      },
+
+      _suggestionEls: {
+        type: Array,
+        value() { return []; },
+      },
+
+      _index: Number,
+      _disableSuggestions: {
+        type: Boolean,
+        value: false,
+      },
       _focused: {
         type: Boolean,
         value: false,
       },
 
+      /** The DOM element of the selected suggestion. */
+      _selected: Object,
     },
 
-    attached: function() {
+    observers: [
+      '_maybeOpenDropdown(_suggestions, _focused)',
+    ],
+
+    attached() {
       this.listen(document.body, 'tap', '_handleBodyTap');
     },
 
-    detached: function() {
+    detached() {
       this.unlisten(document.body, 'tap', '_handleBodyTap');
     },
 
@@ -136,117 +170,164 @@
       return this.$.input;
     },
 
-    focus: function() {
+    focus() {
       this.$.input.focus();
     },
 
-    selectAll: function() {
-      this.$.input.setSelectionRange(0, this.$.input.value.length);
+    selectAll() {
+      const nativeInputElement = this.$.input.inputElement;
+      nativeInputElement.setSelectionRange(0, this.$.input.value.length);
     },
 
-    clear: function() {
+    clear() {
       this.text = '';
     },
 
+    _handleItemSelect(e) {
+      // Let _handleKeydown deal with keyboard interaction.
+      if (e.detail.trigger !== 'tap') { return; }
+      this._selected = e.detail.selected;
+      this._commit();
+    },
+
+    get _inputElement() {
+      return this.$.input;
+    },
+
     /**
      * Set the text of the input without triggering the suggestion dropdown.
-     * @param {String} text The new text for the input.
+     * @param {string} text The new text for the input.
      */
-    setText: function(text) {
+    setText(text) {
       this._disableSuggestions = true;
       this.text = text;
       this._disableSuggestions = false;
     },
 
-    _onInputFocus: function() {
+    _onInputFocus() {
       this._focused = true;
       this._updateSuggestions();
+      this.$.input.classList.remove('warnUncommitted');
+      // Needed so that --paper-input-container-input updated style is applied.
+      this.updateStyles();
     },
 
-    _updateSuggestions: function() {
-      if (!this.text || this._disableSuggestions) { return; }
-      if (this.text.length < this.threshold) {
+    _onInputBlur() {
+      this.$.input.classList.toggle('warnUncommitted',
+          this.warnUncommitted && this.text.length && !this._focused);
+      // Needed so that --paper-input-container-input updated style is applied.
+      this.updateStyles();
+    },
+
+    _updateSuggestions() {
+      if (this._disableSuggestions) { return; }
+      if (this.text === undefined || this.text.length < this.threshold) {
         this._suggestions = [];
-        this.value = null;
+        this.value = '';
         return;
       }
-      var text = this.text;
+      const text = this.text;
 
-      this.query(text).then(function(suggestions) {
+      this.query(text).then(suggestions => {
         if (text !== this.text) {
           // Late response.
           return;
         }
-        this._suggestions = suggestions;
-        this.$.cursor.moveToStart();
-        if (this._index === -1) {
-          this.value = null;
+        for (const suggestion of suggestions) {
+          suggestion.text = suggestion.name;
         }
-      }.bind(this));
+        this._suggestions = suggestions;
+        Polymer.dom.flush();
+        if (this._index === -1) {
+          this.value = '';
+        }
+      });
     },
 
-    _computeSuggestionsHidden: function(suggestions, focused) {
-      return !(suggestions.length && focused);
+    _maybeOpenDropdown(suggestions, focused) {
+      if (suggestions.length > 0 && focused) {
+        return this.$.suggestions.open();
+      }
+      return this.$.suggestions.close();
     },
 
-    _computeClass: function(borderless) {
+    _computeClass(borderless) {
       return borderless ? 'borderless' : '';
     },
 
-    _getSuggestionElems: function() {
-      Polymer.dom.flush();
-      return this.$.suggestions.querySelectorAll('li');
-    },
-
     /**
      * _handleKeydown used for key handling in the this.$.input AND all child
      * autocomplete options.
      */
-    _handleKeydown: function(e) {
+    _handleKeydown(e) {
       this._focused = true;
       switch (e.keyCode) {
         case 38: // Up
           e.preventDefault();
-          this.$.cursor.previous();
+          this.$.suggestions.cursorUp();
           break;
         case 40: // Down
           e.preventDefault();
-          this.$.cursor.next();
+          this.$.suggestions.cursorDown();
           break;
         case 27: // Escape
           e.preventDefault();
           this._cancel();
           break;
         case 9: // Tab
-          if (this._suggestions.length > 0) {
+          if (this._suggestions.length > 0 && this.tabComplete) {
             e.preventDefault();
-            this._commit(this.tabCompleteWithoutCommit);
+            this._handleInputCommit(true);
+            this.focus();
+          } else {
+            this._focused = false;
           }
           break;
         case 13: // Enter
           e.preventDefault();
-          this._commit();
+          this._handleInputCommit();
           break;
         default:
           // For any normal keypress, return focus to the input to allow for
           // unbroken user input.
-          this.$.input.focus();
+          this.$.input.inputElement.focus();
+
+          // Since this has been a normal keypress, the suggestions will have
+          // been based on a previous input. Clear them. This prevents an
+          // outdated suggestion from being used if the input keystroke is
+          // immediately followed by a commit keystroke. @see Issue 8655
+          this._suggestions = [];
       }
       this.fire('input-keydown', {keyCode: e.keyCode, input: this.$.input});
     },
 
-    _cancel: function() {
-      this._suggestions = [];
-      this.fire('cancel');
+    _cancel() {
+      if (this._suggestions.length) {
+        this.set('_suggestions', []);
+      } else {
+        this.fire('cancel');
+      }
     },
 
-    _updateValue: function(suggestions, index) {
-      if (!suggestions.length || index === -1) { return; }
-      var completed = suggestions[index].value;
+    /**
+     * @param {boolean=} opt_tabComplete
+     */
+    _handleInputCommit(opt_tabComplete) {
+      // Nothing to do if the dropdown is not open.
+      if (!this.allowNonSuggestedValues
+          && this.$.suggestions.isHidden) { return; }
+
+      this._selected = this.$.suggestions.getCursorTarget();
+      this._commit(opt_tabComplete);
+    },
+
+    _updateValue(suggestion, suggestions) {
+      if (!suggestion) { return; }
+      const completed = suggestions[suggestion.dataset.index].value;
       if (this.multi) {
         // Append the completed text to the end of the string.
         // Allow spaces within quoted terms.
-        var tokens = this.text.match(TOKENIZE_REGEX);
+        const tokens = this.text.match(TOKENIZE_REGEX);
         tokens[tokens.length - 1] = completed;
         this.value = tokens.join(' ');
       } else {
@@ -254,9 +335,9 @@
       }
     },
 
-    _handleBodyTap: function(e) {
-      var eventPath = Polymer.dom(e).path;
-      for (var i = 0; i < eventPath.length; i++) {
+    _handleBodyTap(e) {
+      const eventPath = Polymer.dom(e).path;
+      for (let i = 0; i < eventPath.length; i++) {
         if (eventPath[i] === this) {
           return;
         }
@@ -264,45 +345,50 @@
       this._focused = false;
     },
 
-    _handleSuggestionTap: function(e) {
+    _handleSuggestionTap(e) {
       e.stopPropagation();
       this.$.cursor.setCursor(e.target);
       this._commit();
-      this.focus();
     },
 
     /**
      * Commits the suggestion, optionally firing the commit event.
      *
-     * @param {Boolean} silent Allows for silent committing of an autocomplete
-     *     suggestion in order to handle cases like tab-to-complete without
-     *     firing the commit event.
+     * @param {boolean=} opt_silent Allows for silent committing of an
+     *     autocomplete suggestion in order to handle cases like tab-to-complete
+     *     without firing the commit event.
      */
-    _commit: function(silent) {
+    _commit(opt_silent) {
       // Allow values that are not in suggestion list iff suggestions are empty.
       if (this._suggestions.length > 0) {
-        this._updateValue(this._suggestions, this._index);
+        this._updateValue(this._selected, this._suggestions);
       } else {
         this.value = this.text || '';
       }
 
-      var value = this.value;
+      const value = this.value;
 
       // Value and text are mirrors of each other in multi mode.
       if (this.multi) {
         this.setText(this.value);
       } else {
-        if (!this.clearOnCommit && this._suggestions[this._index]) {
-          this.setText(this._suggestions[this._index].name);
+        if (!this.clearOnCommit && this._selected) {
+          this.setText(this._suggestions[this._selected.dataset.index].name);
         } else {
           this.clear();
         }
       }
 
       this._suggestions = [];
-      if (!silent) {
-        this.fire('commit', {value: value});
+      if (!opt_silent) {
+        this.fire('commit', {value});
       }
+
+      this._textChangedSinceCommit = false;
+    },
+
+    _computeShowSearchIconClass(showSearchIcon) {
+      return showSearchIcon ? 'showSearchIcon' : '';
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
index 4234388..04825b0 100644
--- a/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-autocomplete/gr-autocomplete_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-autocomplete.html">
 
 <script>void(0);</script>
@@ -33,16 +32,22 @@
 </test-fixture>
 
 <script>
-  suite('gr-autocomplete tests', function() {
-    var element;
+  suite('gr-autocomplete tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
     });
 
-    test('renders', function(done) {
-      var promise;
-      var queryStub = sinon.spy(function(input) {
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('renders', done => {
+      let promise;
+      const queryStub = sandbox.spy(input => {
         return promise = Promise.resolve([
           {name: input + ' 0', value: 0},
           {name: input + ' 1', value: 1},
@@ -52,61 +57,63 @@
         ]);
       });
       element.query = queryStub;
-
-      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
-      assert.equal(element.$.cursor.index, -1);
+      assert.isTrue(element.$.suggestions.isHidden);
+      assert.equal(element.$.suggestions.$.cursor.index, -1);
 
       element.text = 'blah';
 
       assert.isTrue(queryStub.called);
       element._focused = true;
 
-      promise.then(function() {
-        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
-
-        var suggestions = element.$.suggestions.querySelectorAll('li');
+      promise.then(() => {
+        assert.isFalse(element.$.suggestions.isHidden);
+        const suggestions =
+            Polymer.dom(element.$.suggestions.root).querySelectorAll('li');
         assert.equal(suggestions.length, 5);
 
-        for (var i = 0; i < 5; i++) {
+        for (let i = 0; i < 5; i++) {
           assert.equal(suggestions[i].textContent, 'blah ' + i);
         }
 
-        assert.notEqual(element.$.cursor.index, -1);
+        assert.notEqual(element.$.suggestions.$.cursor.index, -1);
         done();
       });
     });
 
-    test('emits cancel', function(done) {
-      var promise;
-      var queryStub = sinon.spy(function() {
+    test('esc key behavior', done => {
+      let promise;
+      const queryStub = sandbox.spy(() => {
         return promise = Promise.resolve([
           {name: 'blah', value: 123},
         ]);
       });
       element.query = queryStub;
 
-      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+      assert.isTrue(element.$.suggestions.isHidden);
 
       element._focused = true;
       element.text = 'blah';
 
-      promise.then(function() {
-        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+      promise.then(() => {
+        assert.isFalse(element.$.suggestions.isHidden);
 
-        var cancelHandler = sinon.spy();
+        const cancelHandler = sandbox.spy();
         element.addEventListener('cancel', cancelHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
-        assert.isTrue(cancelHandler.called);
-        assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
+        assert.isFalse(cancelHandler.called);
+        assert.isTrue(element.$.suggestions.isHidden);
+        assert.equal(element._suggestions.length, 0);
 
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 27, null, 'esc');
+        assert.isTrue(cancelHandler.called);
         done();
       });
     });
 
-    test('emits commit and handles cursor movement', function(done) {
-      var promise;
-      var queryStub = sinon.spy(function(input) {
+    test('emits commit and handles cursor movement', done => {
+      let promise;
+      const queryStub = sandbox.spy(input => {
         return promise = Promise.resolve([
           {name: input + ' 0', value: 0},
           {name: input + ' 1', value: 1},
@@ -117,32 +124,32 @@
       });
       element.query = queryStub;
 
-      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
-      assert.equal(element.$.cursor.index, -1);
+      assert.isTrue(element.$.suggestions.isHidden);
+      assert.equal(element.$.suggestions.$.cursor.index, -1);
       element._focused = true;
       element.text = 'blah';
 
-      promise.then(function() {
-        assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
+      promise.then(() => {
+        assert.isFalse(element.$.suggestions.isHidden);
 
-        var commitHandler = sinon.spy();
+        const commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
-        assert.equal(element.$.cursor.index, 0);
+        assert.equal(element.$.suggestions.$.cursor.index, 0);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
             'down');
 
-        assert.equal(element.$.cursor.index, 1);
+        assert.equal(element.$.suggestions.$.cursor.index, 1);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 40, null,
             'down');
 
-        assert.equal(element.$.cursor.index, 2);
+        assert.equal(element.$.suggestions.$.cursor.index, 2);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 38, null, 'up');
 
-        assert.equal(element.$.cursor.index, 1);
+        assert.equal(element.$.suggestions.$.cursor.index, 1);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
             'enter');
@@ -150,22 +157,22 @@
         assert.equal(element.value, 1);
         assert.isTrue(commitHandler.called);
         assert.equal(commitHandler.getCall(0).args[0].detail.value, 1);
-        assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
-
+        assert.isTrue(element.$.suggestions.isHidden);
+        assert.isTrue(element._focused);
         done();
       });
     });
 
-    test('clear-on-commit behavior (off)', function(done) {
-      var promise;
-      var queryStub = sinon.spy(function() {
+    test('clear-on-commit behavior (off)', done => {
+      let promise;
+      const queryStub = sandbox.spy(() => {
         return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
       });
       element.query = queryStub;
       element.text = 'blah';
 
-      promise.then(function() {
-        var commitHandler = sinon.spy();
+      promise.then(() => {
+        const commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
@@ -177,17 +184,17 @@
       });
     });
 
-    test('clear-on-commit behavior (on)', function(done) {
-      var promise;
-      var queryStub = sinon.spy(function() {
+    test('clear-on-commit behavior (on)', done => {
+      let promise;
+      const queryStub = sandbox.spy(() => {
         return promise = Promise.resolve([{name: 'suggestion', value: 0}]);
       });
       element.query = queryStub;
       element.text = 'blah';
       element.clearOnCommit = true;
 
-      promise.then(function() {
-        var commitHandler = sinon.spy();
+      promise.then(() => {
+        const commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
@@ -199,8 +206,8 @@
       });
     });
 
-    test('threshold guards the query', function() {
-      var queryStub = sinon.spy(function() {
+    test('threshold guards the query', () => {
+      const queryStub = sandbox.spy(() => {
         return Promise.resolve([]);
       });
       element.query = queryStub;
@@ -216,29 +223,29 @@
       assert.isTrue(queryStub.called);
     });
 
-    test('_computeClass respects border property', function() {
+    test('_computeClass respects border property', () => {
       assert.equal(element._computeClass(), '');
       assert.equal(element._computeClass(false), '');
       assert.equal(element._computeClass(true), 'borderless');
     });
 
-    test('undefined or empty text results in no suggestions', function() {
-      sinon.spy(element, '_updateSuggestions');
+    test('undefined or empty text results in no suggestions', () => {
+      sandbox.spy(element, '_updateSuggestions');
       element.text = undefined;
       assert(element._updateSuggestions.calledOnce);
       assert.equal(element._suggestions.length, 0);
     });
 
-    test('multi completes only the last part of the query', function(done) {
-      var promise;
-      var queryStub = sinon.stub()
+    test('multi completes only the last part of the query', done => {
+      let promise;
+      const queryStub = sandbox.stub()
           .returns(promise = Promise.resolve([{name: 'suggestion', value: 0}]));
       element.query = queryStub;
       element.text = 'blah blah';
       element.multi = true;
 
-      promise.then(function() {
-        var commitHandler = sinon.spy();
+      promise.then(() => {
+        const commitHandler = sandbox.spy();
         element.addEventListener('commit', commitHandler);
 
         MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
@@ -250,68 +257,248 @@
       });
     });
 
-    test('tab key completes only when suggestions exist', function() {
-      var commitStub = sinon.stub(element, '_commit');
-      element._suggestions = [];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      assert.isFalse(commitStub.called);
-      element._suggestions = ['tunnel snakes rule!'];
-      MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      assert.isTrue(commitStub.called);
-      commitStub.restore();
-    });
-
-    test('tabCompleteWithoutCommit flag functions', function() {
-      var commitHandler = sinon.spy();
+    test('tabComplete flag functions', () => {
+      // commitHandler checks for the commit event, whereas commitSpy checks for
+      // the _commit function of the element.
+      const commitHandler = sandbox.spy();
       element.addEventListener('commit', commitHandler);
+      const commitSpy = sandbox.spy(element, '_commit');
+      element._focused = true;
+
       element._suggestions = ['tunnel snakes rule!'];
-      element.tabCompleteWithoutCommit = true;
+      element.tabComplete = false;
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       assert.isFalse(commitHandler.called);
-      element.tabCompleteWithoutCommit = false;
-      element._suggestions = ['tunnel snakes rule!'];
+      assert.isFalse(commitSpy.called);
+      assert.isFalse(element._focused);
+
+      element.tabComplete = true;
+      element._focused = true;
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
-      assert.isTrue(commitHandler.called);
+      assert.isFalse(commitHandler.called);
+      assert.isTrue(commitSpy.called);
+      assert.isTrue(element._focused);
     });
 
-    test('_focused flag properly triggered', function(done) {
-      flush(function() {
+    test('_focused flag properly triggered', done => {
+      flush(() => {
         assert.isFalse(element._focused);
-        var input = element.$$('input');
+        const input = element.$$('paper-input').inputElement;
         MockInteractions.focus(input);
         assert.isTrue(element._focused);
         done();
       });
     });
 
-    test('_focused flag shows/hides the suggestions', function() {
-      var suggestions = ['hello', 'its me'];
-      assert.isTrue(element._computeSuggestionsHidden(suggestions, false));
-      assert.isFalse(element._computeSuggestionsHidden(suggestions, true));
+    test('search icon shows with showSearchIcon property', () => {
+      assert.equal(getComputedStyle(element.$$('iron-icon')).display,
+          'none');
+      element.showSearchIcon = true;
+      assert.notEqual(getComputedStyle(element.$$('iron-icon')).display,
+          'none');
     });
 
-    test('tap on suggestion commits and refocuses on input', function() {
-      var focusSpy = sinon.spy(element, 'focus');
-      var commitSpy = sinon.spy(element, '_commit');
+    test('vertical offset overridden by param if it exists', () => {
+      assert.equal(element.$.suggestions.verticalOffset, 20);
+      element.verticalOffset = 30;
+      assert.equal(element.$.suggestions.verticalOffset, 30);
+    });
+
+    test('_focused flag shows/hides the suggestions', () => {
+      const openStub = sandbox.stub(element.$.suggestions, 'open');
+      const closedStub = sandbox.stub(element.$.suggestions, 'close');
+      element._suggestions = ['hello', 'its me'];
+      assert.isFalse(openStub.called);
+      assert.isTrue(closedStub.calledOnce);
       element._focused = true;
-      element._suggestions = [{name: 'first suggestion'}];
-      assert.isFalse(element.$.suggestions.hasAttribute('hidden'));
-      MockInteractions.tap(element.$$('#suggestions li:first-child'));
-      flushAsynchronousOperations();
-      assert.isTrue(focusSpy.called);
-      assert.isTrue(commitSpy.called);
-      assert.isTrue(element.$.suggestions.hasAttribute('hidden'));
-      assert.isTrue(element._focused);
-      focusSpy.restore();
-      commitSpy.restore();
+      assert.isTrue(openStub.calledOnce);
+      element._suggestions = [];
+      assert.isTrue(closedStub.calledTwice);
+      assert.isTrue(openStub.calledOnce);
     });
 
-    test('input-keydown event fired', function() {
-      var listener = sinon.spy();
+    test('_handleInputCommit with autocomplete hidden does nothing without' +
+          'without allowNonSuggestedValues', () => {
+      const commitStub = sandbox.stub(element, '_commit');
+      element.$.suggestions.isHidden = true;
+      element._handleInputCommit();
+      assert.isFalse(commitStub.called);
+    });
+
+    test('_handleInputCommit with autocomplete hidden with' +
+          'allowNonSuggestedValues', () => {
+      const commitStub = sandbox.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      element.$.suggestions.isHidden = true;
+      element._handleInputCommit();
+      assert.isTrue(commitStub.called);
+    });
+
+    test('_handleInputCommit with autocomplete open calls commit', () => {
+      const commitStub = sandbox.stub(element, '_commit');
+      element.$.suggestions.isHidden = false;
+      element._handleInputCommit();
+      assert.isTrue(commitStub.calledOnce);
+    });
+
+    test('_handleInputCommit with autocomplete open calls commit' +
+          'with allowNonSuggestedValues', () => {
+      const commitStub = sandbox.stub(element, '_commit');
+      element.allowNonSuggestedValues = true;
+      element.$.suggestions.isHidden = false;
+      element._handleInputCommit();
+      assert.isTrue(commitStub.calledOnce);
+    });
+
+    test('issue 8655', () => {
+      function makeSuggestion(s) { return {name: s, text: s, value: s}; }
+      const keydownSpy = sandbox.spy(element, '_handleKeydown');
+      element.setText('file:');
+      element._suggestions =
+          [makeSuggestion('file:'), makeSuggestion('-file:')];
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 88, null, 'x');
+      // Must set the value, because the MockInteraction does not.
+      element.$.input.value = 'file:x';
+      assert.isTrue(keydownSpy.calledOnce);
+      MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null, 'enter');
+      assert.isTrue(keydownSpy.calledTwice);
+      assert.equal(element.text, 'file:x');
+    });
+
+    suite('focus', () => {
+      let commitSpy;
+      let focusSpy;
+
+      setup(() => {
+        commitSpy = sandbox.spy(element, '_commit');
+      });
+
+      test('enter does not call focus', () => {
+        element._suggestions = ['sugar bombs'];
+        focusSpy = sandbox.spy(element, 'focus');
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 13, null,
+            'enter');
+        flushAsynchronousOperations();
+
+        assert.isTrue(commitSpy.called);
+        assert.isFalse(focusSpy.called);
+        assert.equal(element._suggestions.length, 0);
+      });
+
+      test('tab in input, tabComplete = true', () => {
+        focusSpy = sandbox.spy(element, 'focus');
+        const commitHandler = sandbox.stub();
+        element.addEventListener('commit', commitHandler);
+        element.tabComplete = true;
+        element._suggestions = ['tunnel snakes drool'];
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+        flushAsynchronousOperations();
+
+        assert.isTrue(commitSpy.called);
+        assert.isTrue(focusSpy.called);
+        assert.isFalse(commitHandler.called);
+        assert.equal(element._suggestions.length, 0);
+      });
+
+      test('tab in input, tabComplete = false', () => {
+        element._suggestions = ['sugar bombs'];
+        focusSpy = sandbox.spy(element, 'focus');
+        MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
+        flushAsynchronousOperations();
+
+        assert.isFalse(commitSpy.called);
+        assert.isFalse(focusSpy.called);
+        assert.equal(element._suggestions.length, 1);
+      });
+
+      test('tab on suggestion, tabComplete = false', () => {
+        element._suggestions = [{name: 'sugar bombs'}];
+        element._focused = true;
+        // When tabComplete is false, do not focus.
+        element.tabComplete = false;
+        focusSpy = sandbox.spy(element, 'focus');
+        Polymer.dom.flush();
+        assert.isFalse(element.$.suggestions.isHidden);
+
+        MockInteractions.pressAndReleaseKeyOn(
+            element.$.suggestions.$$('li:first-child'), 9, null, 'tab');
+        flushAsynchronousOperations();
+        assert.isFalse(commitSpy.called);
+        assert.isFalse(element._focused);
+      });
+
+      test('tab on suggestion, tabComplete = true', () => {
+        element._suggestions = [{name: 'sugar bombs'}];
+        element._focused = true;
+        // When tabComplete is true, focus.
+        element.tabComplete = true;
+        focusSpy = sandbox.spy(element, 'focus');
+        Polymer.dom.flush();
+        assert.isFalse(element.$.suggestions.isHidden);
+
+        MockInteractions.pressAndReleaseKeyOn(
+            element.$.suggestions.$$('li:first-child'), 9, null, 'tab');
+        flushAsynchronousOperations();
+
+        assert.isTrue(commitSpy.called);
+        assert.isTrue(element._focused);
+      });
+
+      test('tap on suggestion commits, does not call focus', () => {
+        focusSpy = sandbox.spy(element, 'focus');
+        element._focused = true;
+        element._suggestions = [{name: 'first suggestion'}];
+        Polymer.dom.flush();
+        assert.isFalse(element.$.suggestions.isHidden);
+        MockInteractions.tap(element.$.suggestions.$$('li:first-child'));
+        flushAsynchronousOperations();
+
+        assert.isFalse(focusSpy.called);
+        assert.isTrue(commitSpy.called);
+        assert.isTrue(element.$.suggestions.isHidden);
+      });
+    });
+
+    test('input-keydown event fired', () => {
+      const listener = sandbox.spy();
       element.addEventListener('input-keydown', listener);
       MockInteractions.pressAndReleaseKeyOn(element.$.input, 9, null, 'tab');
       flushAsynchronousOperations();
       assert.isTrue(listener.called);
     });
+
+    suite('warnUncommitted', () => {
+      let inputClassList;
+      setup(() => {
+        inputClassList = element.$.input.classList;
+      });
+
+      test('enabled', () => {
+        element.warnUncommitted = true;
+        element.text = 'blah blah blah';
+        MockInteractions.blur(element.$.input);
+        assert.isTrue(inputClassList.contains('warnUncommitted'));
+        assert.equal(getComputedStyle(element.$.input.inputElement).color,
+            'rgb(255, 0, 0)');
+        MockInteractions.focus(element.$.input);
+        assert.isFalse(inputClassList.contains('warnUncommitted'));
+        assert.notEqual(getComputedStyle(element.$.input.inputElement).color,
+            'rgb(255, 0, 0)ed');
+      });
+
+      test('disabled', () => {
+        element.warnUncommitted = false;
+        element.text = 'blah blah blah';
+        MockInteractions.blur(element.$.input);
+        assert.isFalse(inputClassList.contains('warnUncommitted'));
+      });
+
+      test('no text', () => {
+        element.warnUncommitted = true;
+        element.text = '';
+        MockInteractions.blur(element.$.input);
+        assert.isFalse(inputClassList.contains('warnUncommitted'));
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
index 9e5accd..4095d3e 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.html
@@ -14,13 +14,14 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-avatar">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: inline-block;
         border-radius: 50%;
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
index 88f7dfe..1252f7d 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar.js
@@ -32,44 +32,50 @@
       Gerrit.BaseUrlBehavior,
     ],
 
-    created: function() {
+    created() {
       this.hidden = true;
     },
 
-    attached: function() {
-      this.$.restAPI.getConfig().then(function(cfg) {
-        var hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
+    attached() {
+      this.$.restAPI.getConfig().then(cfg => {
+        const hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
         if (hasAvatars) {
           this.hidden = false;
           // src needs to be set if avatar becomes visible
           this._updateAvatarURL(this.account);
         }
-      }.bind(this));
+      });
     },
 
-    _accountChanged: function(account) {
+    _accountChanged(account) {
       this._updateAvatarURL(account);
     },
 
-    _updateAvatarURL: function(account) {
+    _updateAvatarURL(account) {
       if (!this.hidden && account) {
-        var url = this._buildAvatarURL(this.account);
+        const url = this._buildAvatarURL(this.account);
         if (url) {
           this.style.backgroundImage = 'url("' + url + '")';
         }
       }
     },
 
-    _buildAvatarURL: function(account) {
+    _getAccounts(account) {
+      return account._account_id || account.email || account.username ||
+          account.name;
+    },
+
+    _buildAvatarURL(account) {
       if (!account) { return ''; }
-      var avatars = account.avatars || [];
-      for (var i = 0; i < avatars.length; i++) {
+      const avatars = account.avatars || [];
+      for (let i = 0; i < avatars.length; i++) {
         if (avatars[i].height === this.imageSize) {
           return avatars[i].url;
         }
       }
-      return this.getBaseUrl() + '/accounts/' + account._account_id +
-          '/avatar?s=' + this.imageSize;
+      return this.getBaseUrl() + '/accounts/' +
+        encodeURIComponent(this._getAccounts(account)) +
+        '/avatar?s=' + this.imageSize;
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
index fd05d62..8187471 100644
--- a/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-avatar/gr-avatar_test.html
@@ -20,7 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-avatar.html">
 
 <script>void(0);</script>
@@ -32,37 +32,52 @@
 </test-fixture>
 
 <script>
-  suite('gr-avatar tests', function() {
-    var element;
+  suite('gr-avatar tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
     });
 
-    test('methods', function() {
+    test('methods', () => {
       assert.equal(element._buildAvatarURL(
           {
-            _account_id: 123
+            _account_id: 123,
           }),
           '/accounts/123/avatar?s=16');
       assert.equal(element._buildAvatarURL(
           {
+            email: 'test@example.com',
+          }),
+          '/accounts/test%40example.com/avatar?s=16');
+      assert.equal(element._buildAvatarURL(
+          {
+            name: 'John Doe',
+          }),
+          '/accounts/John%20Doe/avatar?s=16');
+      assert.equal(element._buildAvatarURL(
+          {
+            username: 'John_Doe',
+          }),
+          '/accounts/John_Doe/avatar?s=16');
+      assert.equal(element._buildAvatarURL(
+          {
             _account_id: 123,
             avatars: [
               {
                 url: 'https://cdn.example.com/s12-p/photo.jpg',
-                height: 12
+                height: 12,
               },
               {
                 url: 'https://cdn.example.com/s16-p/photo.jpg',
-                height: 16
+                height: 16,
               },
               {
                 url: 'https://cdn.example.com/s100-p/photo.jpg',
-                height: 100
+                height: 100,
               },
             ],
           }),
@@ -73,32 +88,31 @@
             avatars: [
               {
                 url: 'https://cdn.example.com/s95-p/photo.jpg',
-                height: 95
+                height: 95,
               },
             ],
           }),
           '/accounts/123/avatar?s=16');
     });
 
-    test('dom for existing account', function() {
+    test('dom for existing account', () => {
       assert.isTrue(element.hasAttribute('hidden'),
           'element not hidden initially');
       element.hidden = false;
       element.imageSize = 64;
       element.account = {
-        _account_id: 123
+        _account_id: 123,
       };
       assert.isFalse(element.hasAttribute('hidden'), 'element hidden');
-      assert.isTrue(element.style.backgroundImage.indexOf(
-          '/accounts/123/avatar?s=64') > -1);
+      assert.isTrue(
+          element.style.backgroundImage.includes('/accounts/123/avatar?s=64'));
     });
 
-    test('dom for non available account', function() {
+    test('dom for non available account', () => {
       assert.isTrue(element.hasAttribute('hidden'),
           'element not hidden initially');
       element.account = undefined;
       assert.isTrue(element.hasAttribute('hidden'), 'element not hidden');
     });
-
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
index 2ec32d0..483882f 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.html
@@ -15,124 +15,116 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../bower_components/paper-button/paper-button.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-button">
   <template strip-whitespace>
-    <style>
+    <style include="shared-styles">
+      /* general styles for all buttons */
       :host {
-        background-color: #f5f5f5;
-        border: 1px solid #d1d2d3;
-        border-radius: 2px;
-        box-sizing: border-box;
-        color: #333;
-        cursor: pointer;
         display: inline-block;
-        font-family: var(--font-family);
+        font-family: var(--font-family-bold);
         font-size: 12px;
-        font-weight: bold;
-        outline-width: 0;
-        padding: .4em .85em;
         position: relative;
-        text-align: center;
-        -moz-user-select: none;
-        -ms-user-select: none;
-        -webkit-user-select: none;
-        user-select: none;
       }
       :host([hidden]) {
         display: none;
       }
-      :host([primary]),
-      :host([secondary]) {
+      :host([no-uppercase]) paper-button {
+        text-transform: none;
+      }
+      paper-button {
+        /* Some of these are overridden for link style buttons since buttons
+         without the link attribute are raised */
+        background-color: var(--gr-button-background, #fff);
+        color: var(--gr-button-color, var(--color-link));
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        margin: 0;
+        min-width: 0;
+        padding: .4em .85em;
+        @apply --gr-button;
+      }
+      paper-button:hover,
+      paper-button:focus {
+        color: var(--gr-button-hover-color, var(--color-button-hover));
+      }
+      :host([disabled]) paper-button {
+        color: #a8a8a8;
+        cursor: wait;
+      }
+      /* styles for the optional down arrow */
+      :host:not([down-arrow]) .downArrow {display: none; }
+      :host([down-arrow]) .downArrow {
+        border-top: .36em solid var(--gr-button-arrow-color, #ccc);
+        border-left: .36em solid transparent;
+        border-right: .36em solid transparent;
+        margin-bottom: .05em;
+        margin-left: .5em;
+        transition: border-top-color 200ms;
+      }
+      :host([down-arrow]) paper-button:hover .downArrow {
+        border-top-color: var(--gr-button-arrow-hover-color, #666);
+      }
+
+      /* styles for raised buttons specifically*/
+      :host([primary]) paper-button[raised],
+      :host([secondary]) paper-button[raised] {
+        background-color: var(--color-link);
         color: #fff;
       }
-      :host([primary]) {
-        background-color: #4d90fe;
-        border-color: #3079ed;
+      :host([primary]) paper-button[raised]:hover,
+      :host([primary]) paper-button[raised]:focus,
+      :host([secondary]) paper-button[raised]:hover,
+      :host([secondary]) paper-button[raised]:focus {
+        background-color: var(--gr-button-hover-background-color, var(--color-button-hover));
+        color: var(--gr-button-color, #fff);
       }
-      :host([secondary]) {
-        background-color: #d14836;
-        border-color: transparent;
+      :host([disabled]) paper-button[raised] {
+        background-color: #eaeaea;
+        color: #a8a8a8;
       }
-      :host([small]) {
-        font-size: 12px;
-      }
+      /* styles for link buttons specifically */
       :host([link]) {
         background-color: transparent;
         border: none;
-        color: #00f;
+        color: var(--color-link);
         font-size: inherit;
-        font-weight: normal;
+        font-family: var(--font-family-bold);
+        text-transform: none;
+      }
+      :host([link][tertiary]) {
+        color: var(--color-link-tertiary);
+      }
+      :host([link]) paper-button {
+        background-color: transparent;
+        margin: 0;
         padding: 0;
-        text-decoration: underline;
+        --paper-button: {
+          padding: 0;
+        }
+        @apply --gr-button;
       }
-      :host([loading]),
-      :host([disabled]) {
-        background-color: #efefef;
-        color: #aaa;
+      :host([disabled][link]) paper-button {
+        background-color: transparent;
       }
-      :host([disabled]) {
-        cursor: default;
-      }
-      :host([loading]),
-      :host([loading][disabled]) {
-        cursor: wait;
-      }
-      :host:focus:not([link]),
-      :host:hover:not([link]) {
-        background-color: #f8f8f8;
-        border-color: #aaa;
-      }
-      :host(:active) {
-        border-color: #d1d2d3;
-        color: #aaa;
-      }
-      :host([primary]:focus),
-      :host([secondary]:focus),
-      :host([primary]:active),
-      :host([secondary]:active) {
-        color: #fff;
-      }
-      :host([primary]:focus) {
-        box-shadow: 0 0 1px #00f;
-        background-color: #4d90fe;
-      }
-      :host([primary]:not([disabled]):hover) {
-        background-color: #4d90fe;
-        border-color: #00F;
-      }
-      :host([primary]:active),
-      :host([secondary]:active) {
-        box-shadow: none;
-      }
-      :host([primary]:active) {
-        border-color: #0c2188;
-      }
-      :host([secondary]:focus) {
-        box-shadow: 0 0 1px #f00;
-        background-color: #d14836;
-      }
-      :host([secondary]:not([disabled]):hover) {
-        background-color: #c53727;
-        border: 1px solid #b0281a;
-      }
-      :host([secondary]:active) {
-        border-color: #941c0c;
-      }
-      :host([primary][loading]) {
-        background-color: #7caeff;
-        border-color: transparent;
-        color: #fff;
-      }
-      :host([primary][disabled]) {
-        background-color: #4d90fe;
-        color: #fff;
-        opacity: .5;
+      :host([link]) paper-button:hover,
+      :host([link]) paper-button:focus {
+        color: var(--gr-button-hover-color, var(--color-button-hover));
       }
     </style>
-    <content></content>
+    <paper-button
+        raised="[[!link]]"
+        disabled="[[_computeDisabled(disabled, loading)]]"
+        tabindex="-1">
+      <content></content>
+      <i class="downArrow"></i>
+    </paper-button>
   </template>
   <script src="gr-button.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
index 800b1df..b5a1d94 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button.js
@@ -18,8 +18,23 @@
     is: 'gr-button',
 
     properties: {
+      downArrow: {
+        type: Boolean,
+        reflectToAttribute: true,
+      },
       link: {
         type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      loading: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
+      tertiary: {
+        type: Boolean,
+        value: false,
         reflectToAttribute: true,
       },
       disabled: {
@@ -27,6 +42,10 @@
         observer: '_disabledChanged',
         reflectToAttribute: true,
       },
+      noUppercase: {
+        type: Boolean,
+        value: false,
+      },
       _enabledTabindex: {
         type: String,
         value: '0',
@@ -34,10 +53,15 @@
     },
 
     listeners: {
-      'tap': '_handleAction',
-      'click': '_handleAction',
+      tap: '_handleAction',
+      click: '_handleAction',
+      keydown: '_handleKeydown',
     },
 
+    observers: [
+      '_computeDisabled(disabled, loading)',
+    ],
+
     behaviors: [
       Gerrit.KeyboardShortcutBehavior,
       Gerrit.TooltipBehavior,
@@ -48,27 +72,33 @@
       tabindex: '0',
     },
 
-    keyBindings: {
-      'space enter': '_handleCommitKey',
-    },
-
-    _handleAction: function(e) {
+    _handleAction(e) {
       if (this.disabled) {
         e.preventDefault();
         e.stopImmediatePropagation();
       }
     },
 
-    _disabledChanged: function(disabled) {
+    _disabledChanged(disabled) {
       if (disabled) {
         this._enabledTabindex = this.getAttribute('tabindex');
       }
       this.setAttribute('tabindex', disabled ? '-1' : this._enabledTabindex);
     },
 
-    _handleCommitKey: function(e) {
-      e.preventDefault();
-      this.click();
+    _computeDisabled(disabled, loading) {
+      return disabled || loading;
+    },
+
+    _handleKeydown(e) {
+      if (this.modifierPressed(e)) { return; }
+      e = this.getKeyboardEvent(e);
+      // Handle `enter`, `space`.
+      if (e.keyCode === 13 || e.keyCode === 32) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.click();
+      }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
index c269cb5..c0ceb33 100644
--- a/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-button/gr-button_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-button.html">
 
 <script>void(0);</script>
@@ -33,63 +32,85 @@
 </test-fixture>
 
 <script>
-  suite('gr-select tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-select tests', () => {
+    let element;
+    let sandbox;
 
-    var addSpyOn = function(eventName) {
-      var spy = sandbox.spy();
+    const addSpyOn = function(eventName) {
+      const spy = sandbox.spy();
       element.addEventListener(eventName, spy);
       return spy;
     };
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    ['tap', 'click'].forEach(function(eventName) {
-      test('dispatches ' + eventName + ' event', function() {
-        var spy = addSpyOn(eventName);
+    test('disabled is set by disabled or loading', () => {
+      assert.isFalse(element.$$('paper-button').disabled);
+      element.disabled = true;
+      assert.isTrue(element.$$('paper-button').disabled);
+      element.disabled = false;
+      assert.isFalse(element.$$('paper-button').disabled);
+      element.loading = true;
+      assert.isTrue(element.$$('paper-button').disabled);
+    });
+
+    for (const eventName of ['tap', 'click']) {
+      test('dispatches ' + eventName + ' event', () => {
+        const spy = addSpyOn(eventName);
         MockInteractions.tap(element);
         assert.isTrue(spy.calledOnce);
       });
-    });
+    }
 
     // Keycodes: 32 for Space, 13 for Enter.
-    [32, 13].forEach(function(key) {
-      test('dispatches tap event on keycode ' + key, function() {
-        var tapSpy = sandbox.spy();
+    for (const key of [32, 13]) {
+      test('dispatches tap event on keycode ' + key, () => {
+        const tapSpy = sandbox.spy();
         element.addEventListener('tap', tapSpy);
         MockInteractions.pressAndReleaseKeyOn(element, key);
         assert.isTrue(tapSpy.calledOnce);
-      })});
+      });
 
-    suite('disabled', function() {
-      setup(function() {
+      test('dispatches no tap event with modifier on keycode ' + key, () => {
+        const tapSpy = sandbox.spy();
+        element.addEventListener('tap', tapSpy);
+        MockInteractions.pressAndReleaseKeyOn(element, key, 'shift');
+        MockInteractions.pressAndReleaseKeyOn(element, key, 'ctrl');
+        MockInteractions.pressAndReleaseKeyOn(element, key, 'meta');
+        MockInteractions.pressAndReleaseKeyOn(element, key, 'alt');
+        assert.isFalse(tapSpy.calledOnce);
+      });
+    }
+
+    suite('disabled', () => {
+      setup(() => {
         element.disabled = true;
       });
 
-      ['tap', 'click'].forEach(function(eventName) {
-        test('stops ' + eventName + ' event', function() {
-          var spy = addSpyOn(eventName);
+      for (const eventName of ['tap', 'click']) {
+        test('stops ' + eventName + ' event', () => {
+          const spy = addSpyOn(eventName);
           MockInteractions.tap(element);
           assert.isFalse(spy.called);
         });
-      });
+      }
 
       // Keycodes: 32 for Space, 13 for Enter.
-      [32, 13].forEach(function(key) {
-        test('stops tap event on keycode ' + key, function() {
-          var tapSpy = sandbox.spy();
+      for (const key of [32, 13]) {
+        test('stops tap event on keycode ' + key, () => {
+          const tapSpy = sandbox.spy();
           element.addEventListener('tap', tapSpy);
           MockInteractions.pressAndReleaseKeyOn(element, key);
           assert.isFalse(tapSpy.called);
-        })});
+        });
+      }
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
index 03be4bb..08bc6eb 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.html
@@ -16,10 +16,11 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-change-star">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: inline-block;
         overflow: hidden;
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
index beb0ff1..5009a6f 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star.js
@@ -18,24 +18,25 @@
     is: 'gr-change-star',
 
     properties: {
+      /** @type {?} */
       change: {
         type: Object,
         notify: true,
       },
 
-      _xhrPromise: Object,  // Used for testing.
+      _xhrPromise: Object, // Used for testing.
     },
 
-    _computeStarClass: function(starred) {
-      var classes = ['starButton'];
+    _computeStarClass(starred) {
+      const classes = ['starButton'];
       if (starred) {
         classes.push('starButton-active');
       }
       return classes.join(' ');
     },
 
-    toggleStar: function() {
-      var newVal = !this.change.starred;
+    toggleStar() {
+      const newVal = !this.change.starred;
       this.set('change.starred', newVal);
       this._xhrPromise = this.$.restAPI.saveChangeStarred(this.change._number,
           newVal);
diff --git a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
index 460d860..6c15a46 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-change-star/gr-change-star_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-change-star.html">
 
 <script>void(0);</script>
@@ -33,12 +32,12 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-star tests', function() {
-    var element;
+  suite('gr-change-star tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        saveChangeStarred: function() { return Promise.resolve({ok: true}); },
+        saveChangeStarred() { return Promise.resolve({ok: true}); },
       });
       element = fixture('basic');
       element.change = {
@@ -47,7 +46,7 @@
       };
     });
 
-    test('star visibility states', function() {
+    test('star visibility states', () => {
       element.set('change.starred', true);
       assert.isTrue(element.$$('button').classList.contains('starButton'));
       assert.isTrue(
@@ -59,21 +58,21 @@
           element.$$('button').classList.contains('starButton-active'));
     });
 
-    test('starring', function(done) {
+    test('starring', done => {
       element.set('change.starred', false);
       MockInteractions.tap(element.$$('button'));
 
-      element._xhrPromise.then(function(req) {
+      element._xhrPromise.then(req => {
         assert.equal(element.change.starred, true);
         done();
       });
     });
 
-    test('unstarring', function(done) {
+    test('unstarring', done => {
       element.set('change.starred', true);
       MockInteractions.tap(element.$$('button'));
 
-      element._xhrPromise.then(function(req) {
+      element._xhrPromise.then(req => {
         assert.equal(element.change.starred, false);
         done();
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
index bec75ee..efa2b35 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.html
@@ -16,10 +16,11 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-button/gr-button.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-confirm-dialog">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
         max-height: 90vh;
@@ -30,9 +31,9 @@
         max-height: 90vh;
       }
       header {
-        border-bottom: 1px solid #ddd;
+        border-bottom: 1px solid #cdcdcd;
         flex-shrink: 0;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       main {
         display: flex;
@@ -42,22 +43,25 @@
       header,
       main,
       footer {
-        padding: .5em .65em;
+        padding: .5em 1.5em;
+      }
+      gr-button {
+        margin-left: 1em;
       }
       footer {
         display: flex;
         flex-shrink: 0;
-        justify-content: space-between;
+        justify-content: flex-end;
       }
     </style>
     <div class="container">
       <header><content select=".header"></content></header>
       <main><content select=".main"></content></main>
       <footer>
-        <gr-button primary on-tap="_handleConfirmTap" disabled="[[disabled]]">
+        <gr-button link on-tap="_handleCancelTap">Cancel</gr-button>
+        <gr-button link primary on-tap="_handleConfirmTap" disabled="[[disabled]]">
           [[confirmLabel]]
         </gr-button>
-        <gr-button on-tap="_handleCancelTap">Cancel</gr-button>
       </footer>
     </div>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
index dbddb04..3d5e781 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog.js
@@ -44,12 +44,12 @@
       role: 'dialog',
     },
 
-    _handleConfirmTap: function(e) {
+    _handleConfirmTap(e) {
       e.preventDefault();
       this.fire('confirm', null, {bubbles: false});
     },
 
-    _handleCancelTap: function(e) {
+    _handleCancelTap(e) {
       e.preventDefault();
       this.fire('cancel', null, {bubbles: false});
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
index 3eec979..309e15c 100644
--- a/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-confirm-dialog/gr-confirm-dialog_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-confirm-dialog.html">
 
 <script>void(0);</script>
@@ -33,15 +32,15 @@
 </test-fixture>
 
 <script>
-  suite('gr-confirm-dialog tests', function() {
-    var element;
+  suite('gr-confirm-dialog tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('events', function(done) {
-      var numEvents = 0;
+    test('events', done => {
+      let numEvents = 0;
       function handler() { if (++numEvents == 2) { done(); } }
 
       element.addEventListener('confirm', handler);
@@ -50,6 +49,5 @@
       MockInteractions.tap(element.$$('gr-button[primary]'));
       MockInteractions.tap(element.$$('gr-button:not([primary])'));
     });
-
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
new file mode 100644
index 0000000..932db89
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.html
@@ -0,0 +1,66 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-copy-clipboard">
+  <template>
+    <style include="shared-styles">
+      .text {
+        align-items: center;
+        display: flex;
+        flex-wrap: wrap;
+        margin-bottom: .5em;
+        width: 60em;
+      }
+      .text label {
+        flex: 0 0 100%;
+      }
+      .copyText {
+        flex-grow: 1;
+        margin-right: .3em;
+      }
+      .hideInput {
+        display: none;
+      }
+      input {
+        font-family: var(--monospace-font-family);
+        font-size: inherit;
+      }
+    </style>
+    <div class="text">
+        <label>[[title]]</label>
+        <input id="input" is="iron-input"
+            class$="copyText [[_computeInputClass(hideInput)]]"
+            type="text"
+            bind-value="[[text]]"
+            on-tap="_handleInputTap"
+            readonly>
+        <gr-button id="button"
+            link
+            class="copyToClipboard"
+            on-tap="_copyToClipboard">
+          copy
+        </gr-button>
+      </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-copy-clipboard.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
new file mode 100644
index 0000000..a371374
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard.js
@@ -0,0 +1,52 @@
+// 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.
+(function() {
+  'use strict';
+
+  const COPY_TIMEOUT_MS = 1000;
+
+  Polymer({
+    is: 'gr-copy-clipboard',
+
+    properties: {
+      text: String,
+      title: String,
+      hideInput: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    focusOnCopy() {
+      this.$.button.focus();
+    },
+
+    _computeInputClass(hideInput) {
+      return hideInput ? 'hideInput' : '';
+    },
+
+    _handleInputTap(e) {
+      e.preventDefault();
+      Polymer.dom(e).rootTarget.select();
+    },
+
+    _copyToClipboard(e) {
+      this.$.input.select();
+      document.execCommand('copy');
+      window.getSelection().removeAllRanges();
+      e.target.textContent = 'done';
+      this.async(() => { e.target.textContent = 'copy'; }, COPY_TIMEOUT_MS);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
new file mode 100644
index 0000000..7310629
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-copy-clipboard/gr-copy-clipboard_test.html
@@ -0,0 +1,82 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-copy-clipboard</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-copy-clipboard.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-copy-clipboard></gr-copy-clipboard>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-copy-clipboard tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        saveChangeStarred() { return Promise.resolve({ok: true}); },
+      });
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.title = 'Checkout';
+      element.text = `git fetch http://gerrit@localhost:8080/a/test-project
+          refs/changes/05/5/1 && git checkout FETCH_HEAD`;
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('copy to clipboard', () => {
+      const clipboardSpy = sandbox.spy(element, '_copyToClipboard');
+      const copyBtn = element.$$('.copyToClipboard');
+      MockInteractions.tap(copyBtn);
+      assert.isTrue(clipboardSpy.called);
+    });
+
+    test('focusOnCopy', () => {
+      element.focusOnCopy();
+      assert.deepEqual(Polymer.dom(element.root).activeElement,
+          element.$$('.copyToClipboard'));
+    });
+
+    test('_handleInputTap', () => {
+      const inputElement = element.$$('input');
+      MockInteractions.tap(inputElement);
+      assert.equal(inputElement.selectionStart, 0);
+      assert.equal(inputElement.selectionEnd, element.text.length - 1);
+    });
+
+    test('hideInput', () => {
+      assert.notEqual(getComputedStyle(element.$.input).display, 'none');
+      element.hideInput = true;
+      flushAsynchronousOperations();
+      assert.equal(getComputedStyle(element.$.input).display, 'none');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
index 9bfdcfb..c0ea5a8 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var ScrollBehavior = {
+  const ScrollBehavior = {
     NEVER: 'never',
     KEEP_VISIBLE: 'keep-visible',
   };
@@ -25,11 +25,14 @@
     properties: {
       stops: {
         type: Array,
-        value: function() {
+        value() {
           return [];
         },
         observer: '_updateIndex',
       },
+      /**
+       * @type (?Object)
+       */
       target: {
         type: Object,
         notify: true,
@@ -37,6 +40,7 @@
       },
       /**
        * The height of content intended to be included with the target.
+       * @type (?number)
        */
       _targetHeight: Number,
 
@@ -61,6 +65,8 @@
        * The scroll behavior for the cursor. Values are 'never' and
        * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
        * the viewport.
+       * TODO (beckysiegel) figure out why it can be undefined
+       * @type (string|undefined)
        */
       scrollBehavior: {
         type: String,
@@ -76,26 +82,26 @@
       },
     },
 
-    detached: function() {
+    detached() {
       this.unsetCursor();
     },
 
-    next: function(opt_condition, opt_getTargetHeight) {
+    next(opt_condition, opt_getTargetHeight) {
       this._moveCursor(1, opt_condition, opt_getTargetHeight);
     },
 
-    previous: function(opt_condition) {
+    previous(opt_condition) {
       this._moveCursor(-1, opt_condition);
     },
 
     /**
      * Set the cursor to an arbitrary element.
-     * @param {DOMElement} element
-     * @param {boolean} opt_noScroll prevent any potential scrolling in response
+     * @param {!HTMLElement} element
+     * @param {boolean=} opt_noScroll prevent any potential scrolling in response
      *   setting the cursor.
      */
-    setCursor: function(element, opt_noScroll) {
-      var behavior;
+    setCursor(element, opt_noScroll) {
+      let behavior;
       if (opt_noScroll) {
         behavior = this.scrollBehavior;
         this.scrollBehavior = ScrollBehavior.NEVER;
@@ -109,44 +115,44 @@
       if (opt_noScroll) { this.scrollBehavior = behavior; }
     },
 
-    unsetCursor: function() {
+    unsetCursor() {
       this._unDecorateTarget();
       this.index = -1;
       this.target = null;
       this._targetHeight = null;
     },
 
-    isAtStart: function() {
+    isAtStart() {
       return this.index === 0;
     },
 
-    isAtEnd: function() {
+    isAtEnd() {
       return this.index === this.stops.length - 1;
     },
 
-    moveToStart: function() {
+    moveToStart() {
       if (this.stops.length) {
         this.setCursor(this.stops[0]);
       }
     },
 
-    setCursorAtIndex: function(index, opt_noScroll) {
+    setCursorAtIndex(index, opt_noScroll) {
       this.setCursor(this.stops[index], opt_noScroll);
     },
 
     /**
      * Move the cursor forward or backward by delta. Noop if moving past either
      * end of the stop list.
-     * @param {Number} delta either -1 or 1.
-     * @param {Function} opt_condition Optional stop condition. If a condition
+     * @param {number} delta either -1 or 1.
+     * @param {!Function=} opt_condition Optional stop condition. If a condition
      *    is passed the cursor will continue to move in the specified direction
      *    until the condition is met.
-     * @param {Function} opt_getTargetHeight Optional function to calculate the
+     * @param {!Function=} opt_getTargetHeight Optional function to calculate the
      *    height of the target's 'section'. The height of the target itself is
      *    sometimes different, used by the diff cursor.
      * @private
      */
-    _moveCursor: function(delta, opt_condition, opt_getTargetHeight) {
+    _moveCursor(delta, opt_condition, opt_getTargetHeight) {
       if (!this.stops.length) {
         this.unsetCursor();
         return;
@@ -154,34 +160,36 @@
 
       this._unDecorateTarget();
 
-      var newIndex = this._getNextindex(delta, opt_condition);
+      const newIndex = this._getNextindex(delta, opt_condition);
 
-      var newTarget = null;
-      if (newIndex != -1) {
+      let newTarget = null;
+      if (newIndex !== -1) {
         newTarget = this.stops[newIndex];
       }
 
+      this.index = newIndex;
+      this.target = newTarget;
+
+      if (!this.target) { return; }
+
       if (opt_getTargetHeight) {
         this._targetHeight = opt_getTargetHeight(newTarget);
       } else {
         this._targetHeight = newTarget.scrollHeight;
       }
 
-      this.index = newIndex;
-      this.target = newTarget;
-
       if (this.focusOnMove) { this.target.focus(); }
 
       this._decorateTarget();
     },
 
-    _decorateTarget: function() {
+    _decorateTarget() {
       if (this.target && this.cursorTargetClass) {
         this.target.classList.add(this.cursorTargetClass);
       }
     },
 
-    _unDecorateTarget: function() {
+    _unDecorateTarget() {
       if (this.target && this.cursorTargetClass) {
         this.target.classList.remove(this.cursorTargetClass);
       }
@@ -189,17 +197,17 @@
 
     /**
      * Get the next stop index indicated by the delta direction.
-     * @param {Number} delta either -1 or 1.
-     * @param {Function} opt_condition Optional stop condition.
-     * @return {Number} the new index.
+     * @param {number} delta either -1 or 1.
+     * @param {!Function=} opt_condition Optional stop condition.
+     * @return {number} the new index.
      * @private
      */
-    _getNextindex: function(delta, opt_condition) {
+    _getNextindex(delta, opt_condition) {
       if (!this.stops.length || this.index === -1) {
         return -1;
       }
 
-      var newIndex = this.index;
+      let newIndex = this.index;
       do {
         newIndex = newIndex + delta;
       } while (newIndex > 0 &&
@@ -221,13 +229,13 @@
       return newIndex;
     },
 
-    _updateIndex: function() {
+    _updateIndex() {
       if (!this.target) {
         this.index = -1;
         return;
       }
 
-      var newIndex = Array.prototype.indexOf.call(this.stops, this.target);
+      const newIndex = Array.prototype.indexOf.call(this.stops, this.target);
       if (newIndex === -1) {
         this.unsetCursor();
       } else {
@@ -237,12 +245,12 @@
 
     /**
      * Calculate where the element is relative to the window.
-     * @param {object} target Target to scroll to.
+     * @param {!Object} target Target to scroll to.
      * @return {number} Distance to top of the target.
      */
-    _getTop: function(target) {
-      var top = target.offsetTop;
-      for (var offsetParent = target.offsetParent;
+    _getTop(target) {
+      let top = target.offsetTop;
+      for (let offsetParent = target.offsetParent;
            offsetParent;
            offsetParent = offsetParent.offsetParent) {
         top += offsetParent.offsetTop;
@@ -253,25 +261,25 @@
     /**
      * @return {boolean}
      */
-    _targetIsVisible: function(top) {
+    _targetIsVisible(top) {
       return this.scrollBehavior === ScrollBehavior.KEEP_VISIBLE &&
           top > window.pageYOffset &&
           top < window.pageYOffset + window.innerHeight;
     },
 
-    _calculateScrollToValue: function(top, target) {
+    _calculateScrollToValue(top, target) {
       return top - (window.innerHeight / 3) + (target.offsetHeight / 2);
     },
 
-    _scrollToTarget: function() {
+    _scrollToTarget() {
       if (!this.target || this.scrollBehavior === ScrollBehavior.NEVER) {
         return;
       }
 
-      var top = this._getTop(this.target);
-      var bottomIsVisible = this._targetHeight ?
+      const top = this._getTop(this.target);
+      const bottomIsVisible = this._targetHeight ?
           this._targetIsVisible(top + this._targetHeight) : true;
-      var scrollToValue = this._calculateScrollToValue(top, this.target);
+      const scrollToValue = this._calculateScrollToValue(top, this.target);
 
       if (this._targetIsVisible(top)) {
         // Don't scroll if either the bottom is visible or if the position that
diff --git a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
index 5d9af80..5a4b8ba 100644
--- a/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-cursor-manager/gr-cursor-manager_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-cursor-manager.html">
 
 <script>void(0);</script>
@@ -39,23 +38,23 @@
 </test-fixture>
 
 <script>
-  suite('gr-cursor-manager tests', function() {
-    var sandbox;
-    var element;
-    var list;
+  suite('gr-cursor-manager tests', () => {
+    let sandbox;
+    let element;
+    let list;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
-      var fixtureElements = fixture('basic');
+      const fixtureElements = fixture('basic');
       element = fixtureElements[0];
       list = fixtureElements[1];
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('core cursor functionality', function() {
+    test('core cursor functionality', () => {
       // The element is initialized into the proper state.
       assert.isArray(element.stops);
       assert.equal(element.stops.length, 0);
@@ -111,7 +110,7 @@
       assert.isTrue(element.isAtStart());
       assert.isTrue(list.children[0].classList.contains('targeted'));
 
-      var newLi = document.createElement('li');
+      const newLi = document.createElement('li');
       newLi.textContent = 'Z';
       list.insertBefore(newLi, list.children[0]);
       element.stops = list.querySelectorAll('li');
@@ -128,12 +127,12 @@
     });
 
 
-    test('_moveCursor', function() {
+    test('_moveCursor', () => {
       // Initialize the cursor with its stops.
       element.stops = list.querySelectorAll('li');
       // Select the first stop.
       element.setCursor(list.children[0]);
-      var getTargetHeight = sinon.stub();
+      const getTargetHeight = sinon.stub();
 
       // Move the cursor without an optional get target height function.
       element._moveCursor(1);
@@ -144,9 +143,16 @@
       assert.isTrue(getTargetHeight.called);
     });
 
-    test('opt_noScroll', function() {
-      sandbox.stub(element, '_targetIsVisible', function() { return false; });
-      var scrollStub = sandbox.stub(window, 'scrollTo');
+    test('_moveCursor from -1 does not check height', () => {
+      element.stops = list.querySelectorAll('li');
+      const getTargetHeight = sinon.stub();
+      element._moveCursor(1, () => false, getTargetHeight);
+      assert.isFalse(getTargetHeight.called);
+    });
+
+    test('opt_noScroll', () => {
+      sandbox.stub(element, '_targetIsVisible', () => false);
+      const scrollStub = sandbox.stub(window, 'scrollTo');
       element.stops = list.querySelectorAll('li');
       element.scrollBehavior = 'keep-visible';
 
@@ -157,8 +163,8 @@
       assert.isTrue(scrollStub.called);
     });
 
-    test('_getNextindex', function() {
-      var isLetterB = function(row) {
+    test('_getNextindex', () => {
+      const isLetterB = function(row) {
         return row.textContent === 'B';
       };
       element.stops = list.querySelectorAll('li');
@@ -185,9 +191,9 @@
       assert.equal(element._getNextindex(-1, isLetterB), 0);
     });
 
-    test('focusOnMove prop', function() {
-      var listEls = list.querySelectorAll('li');
-      for (var i = 0; i < listEls.length; i++) {
+    test('focusOnMove prop', () => {
+      const listEls = list.querySelectorAll('li');
+      for (let i = 0; i < listEls.length; i++) {
         sandbox.spy(listEls[i], 'focus');
       }
       element.stops = listEls;
@@ -202,9 +208,9 @@
       assert.isTrue(element.target.focus.called);
     });
 
-    suite('_scrollToTarget', function() {
-      var scrollStub;
-      setup(function() {
+    suite('_scrollToTarget', () => {
+      let scrollStub;
+      setup(() => {
         element.stops = list.querySelectorAll('li');
         element.scrollBehavior = 'keep-visible';
 
@@ -215,29 +221,27 @@
         window.innerHeight = 60;
       });
 
-      test('Called when top and bottom not visible', function() {
-        sandbox.stub(element, '_targetIsVisible', function() {
+      test('Called when top and bottom not visible', () => {
+        sandbox.stub(element, '_targetIsVisible', () => {
           return false;
         });
         element._scrollToTarget();
         assert.isTrue(scrollStub.called);
       });
 
-      test('Not called when top and bottom visible', function() {
-        sandbox.stub(element, '_targetIsVisible', function() {
+      test('Not called when top and bottom visible', () => {
+        sandbox.stub(element, '_targetIsVisible', () => {
           return true;
         });
         element._scrollToTarget();
         assert.isFalse(scrollStub.called);
       });
 
-      test('Called when top is visible, bottom is not, and scroll is lower',
-          function() {
-        var visibleStub = sandbox.stub(element, '_targetIsVisible', function() {
-          return visibleStub.callCount == 2;
-        });
+      test('Called when top is visible, bottom is not, scroll is lower', () => {
+        const visibleStub = sandbox.stub(element, '_targetIsVisible',
+            () => visibleStub.callCount === 2);
         window.scrollY = 15;
-        sandbox.stub(element, '_calculateScrollToValue', function() {
+        sandbox.stub(element, '_calculateScrollToValue', () => {
           return 20;
         });
         element._scrollToTarget();
@@ -245,13 +249,11 @@
         assert.equal(visibleStub.callCount, 2);
       });
 
-      test('Called when top is visible, bottom is not, and scroll is higher',
-          function() {
-        var visibleStub = sandbox.stub(element, '_targetIsVisible', function() {
-          return visibleStub.callCount == 2;
-        });
+      test('Called when top is visible, bottom not, scroll is higher', () => {
+        const visibleStub = sandbox.stub(element, '_targetIsVisible',
+            () => visibleStub.callCount === 2);
         window.scrollY = 25;
-        sandbox.stub(element, '_calculateScrollToValue', function() {
+        sandbox.stub(element, '_calculateScrollToValue', () => {
           return 20;
         });
         element._scrollToTarget();
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
index 4d31241..21552d9 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.html
@@ -17,12 +17,14 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <script src="../../../bower_components/moment/moment.js"></script>
+<script src="../../../scripts/util.js"></script>
 
 <dom-module id="gr-date-formatter">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: inline;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
index 6f4cd3d..65a4c68 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter.js
@@ -14,12 +14,12 @@
 (function() {
   'use strict';
 
-  var Duration = {
+  const Duration = {
     HOUR: 1000 * 60 * 60,
     DAY: 1000 * 60 * 60 * 24,
   };
 
-  var TimeFormats = {
+  const TimeFormats = {
     TIME_12: 'h:mm A', // 2:14 PM
     TIME_12_WITH_SEC: 'h:mm:ss A', // 2:14:00 PM
     TIME_24: 'HH:mm', // 14:14
@@ -61,16 +61,16 @@
       Gerrit.TooltipBehavior,
     ],
 
-    attached: function() {
+    attached() {
       this._loadPreferences();
     },
 
-    _getUtcOffsetString: function() {
+    _getUtcOffsetString() {
       return ' UTC' + moment().format('Z');
     },
 
-    _loadPreferences: function() {
-      return this._getLoggedIn().then(function(loggedIn) {
+    _loadPreferences() {
+      return this._getLoggedIn().then(loggedIn => {
         if (!loggedIn) {
           this._timeFormat = TimeFormats.TIME_24;
           this._relative = false;
@@ -80,12 +80,12 @@
           this._loadTimeFormat(),
           this._loadRelative(),
         ]);
-      }.bind(this));
+      });
     },
 
-    _loadTimeFormat: function() {
-      return this._getPreferences().then(function(preferences) {
-        var timeFormat = preferences && preferences.time_format;
+    _loadTimeFormat() {
+      return this._getPreferences().then(preferences => {
+        const timeFormat = preferences && preferences.time_format;
         switch (timeFormat) {
           case 'HHMM_12':
             this._timeFormat = TimeFormats.TIME_12;
@@ -96,55 +96,55 @@
           default:
             throw Error('Invalid time format: ' + timeFormat);
         }
-      }.bind(this));
+      });
     },
 
-    _loadRelative: function() {
-      return this._getPreferences().then(function(prefs) {
+    _loadRelative() {
+      return this._getPreferences().then(prefs => {
         // prefs.relative_date_in_change_table is not set when false.
         this._relative = !!(prefs && prefs.relative_date_in_change_table);
-      }.bind(this));
+      });
     },
 
-    _getLoggedIn: function() {
+    _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
 
-    _getPreferences: function() {
+    _getPreferences() {
       return this.$.restAPI.getPreferences();
     },
 
     /**
      * Return true if date is within 24 hours and on the same day.
      */
-    _isWithinDay: function(now, date) {
-      var diff = -date.diff(now);
+    _isWithinDay(now, date) {
+      const diff = -date.diff(now);
       return diff < Duration.DAY && date.day() === now.getDay();
     },
 
     /**
      * Returns true if date is from one to six months.
      */
-    _isWithinHalfYear: function(now, date) {
-      var diff = -date.diff(now);
+    _isWithinHalfYear(now, date) {
+      const diff = -date.diff(now);
       return (date.day() !== now.getDay() || diff >= Duration.DAY) &&
           diff < 180 * Duration.DAY;
     },
 
-    _computeDateStr: function(dateStr, timeFormat, relative) {
+    _computeDateStr(dateStr, timeFormat, relative) {
       if (!dateStr) { return ''; }
-      var date = moment(util.parseDate(dateStr));
+      const date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
       if (relative) {
-        var dateFromNow = date.fromNow();
+        const dateFromNow = date.fromNow();
         if (dateFromNow === 'a few seconds ago') {
           return 'just now';
         } else {
           return dateFromNow;
         }
       }
-      var now = new Date();
-      var format = TimeFormats.MONTH_DAY_YEAR;
+      const now = new Date();
+      let format = TimeFormats.MONTH_DAY_YEAR;
       if (this._isWithinDay(now, date)) {
         format = timeFormat;
       } else if (this._isWithinHalfYear(now, date)) {
@@ -153,17 +153,17 @@
       return date.format(format);
     },
 
-    _timeToSecondsFormat: function(timeFormat) {
+    _timeToSecondsFormat(timeFormat) {
       return timeFormat === TimeFormats.TIME_12 ?
           TimeFormats.TIME_12_WITH_SEC :
           TimeFormats.TIME_24_WITH_SEC;
     },
 
-    _computeFullDateStr: function(dateStr, timeFormat) {
+    _computeFullDateStr(dateStr, timeFormat) {
       if (!dateStr) { return ''; }
-      var date = moment(util.parseDate(dateStr));
+      const date = moment(util.parseDate(dateStr));
       if (!date.isValid()) { return ''; }
-      var format = TimeFormats.MONTH_DAY_YEAR + ', ';
+      let format = TimeFormats.MONTH_DAY_YEAR + ', ';
       format += this._timeToSecondsFormat(timeFormat);
       return date.format(format) + this._getUtcOffsetString();
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
index 4c6dfdf..2c15ef6 100644
--- a/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-date-formatter/gr-date-formatter_test.html
@@ -20,6 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="gr-date-formatter.html">
@@ -33,15 +34,15 @@
 </test-fixture>
 
 <script>
-  suite('gr-date-formatter tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-date-formatter tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
@@ -49,7 +50,7 @@
      * Parse server-formatter date and normalize into current timezone.
      */
     function normalizedDate(dateStr) {
-      var d = util.parseDate(dateStr);
+      const d = util.parseDate(dateStr);
       d.setMinutes(d.getMinutes() + d.getTimezoneOffset());
       return d;
     }
@@ -60,8 +61,8 @@
           .toJSON().replace('T', ' ').slice(0, -1);
       sandbox.useFakeTimers(normalizedDate(nowStr).getTime());
       element.dateStr = dateStr;
-      flush(function() {
-        var span = element.$$('span');
+      flush(() => {
+        const span = element.$$('span');
         assert.equal(span.textContent.trim(), expected);
         assert.equal(element.title, expectedTooltip);
         done();
@@ -69,8 +70,8 @@
     }
 
     function stubRestAPI(preferences) {
-      var loggedInPromise = Promise.resolve(preferences !== null);
-      var preferencesPromise = Promise.resolve(preferences);
+      const loggedInPromise = Promise.resolve(preferences !== null);
+      const preferencesPromise = Promise.resolve(preferences);
       stub('gr-rest-api-interface', {
         getLoggedIn: sinon.stub().returns(loggedInPromise),
         getPreferences: sinon.stub().returns(preferencesPromise),
@@ -78,115 +79,115 @@
       return Promise.all([loggedInPromise, preferencesPromise]);
     }
 
-    suite('24 hours time format preference', function() {
-      setup(function() {
+    suite('24 hours time format preference', () => {
+      setup(() => {
         return stubRestAPI(
           {time_format: 'HHMM_24', relative_date_in_change_table: false}
-        ).then(function() {
+        ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
           return element._loadPreferences();
         });
       });
 
-      test('invalid dates are quietly rejected', function() {
+      test('invalid dates are quietly rejected', () => {
         assert.notOk((new Date('foo')).valueOf());
         assert.equal(element._computeDateStr('foo', 'h:mm A'), '');
       });
 
-      test('Within 24 hours on same day', function(done) {
+      test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-07-29 15:34:14.985000000',
             '15:34', 'Jul 29, 2015, 15:34:14', done);
       });
 
-      test('Within 24 hours on different days', function(done) {
+      test('Within 24 hours on different days', done => {
         testDates('2015-07-29 03:34:14.985000000',
             '2015-07-28 20:25:14.985000000',
             'Jul 28', 'Jul 28, 2015, 20:25:14', done);
       });
 
-      test('More than 24 hours but less than six months', function(done) {
+      test('More than 24 hours but less than six months', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-06-15 03:25:14.985000000',
             'Jun 15', 'Jun 15, 2015, 03:25:14', done);
       });
 
-      test('More than six months', function(done) {
+      test('More than six months', done => {
         testDates('2015-09-15 20:34:00.000000000',
             '2015-01-15 03:25:00.000000000',
             'Jan 15, 2015', 'Jan 15, 2015, 03:25:00', done);
       });
     });
 
-    suite('12 hours time format preference', function() {
-      setup(function() {
+    suite('12 hours time format preference', () => {
+      setup(() => {
         // relative_date_in_change_table is not set when false.
         return stubRestAPI(
           {time_format: 'HHMM_12'}
-        ).then(function() {
+        ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
           return element._loadPreferences();
         });
       });
 
-      test('Within 24 hours on same day', function(done) {
+      test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-07-29 15:34:14.985000000',
             '3:34 PM', 'Jul 29, 2015, 3:34:14 PM', done);
       });
     });
 
-    suite('relative date preference', function() {
-      setup(function() {
+    suite('relative date preference', () => {
+      setup(() => {
         return stubRestAPI(
           {time_format: 'HHMM_12', relative_date_in_change_table: true}
-        ).then(function() {
+        ).then(() => {
           element = fixture('basic');
           sandbox.stub(element, '_getUtcOffsetString').returns('');
           return element._loadPreferences();
         });
       });
 
-      test('Within 24 hours on same day', function(done) {
+      test('Within 24 hours on same day', done => {
         testDates('2015-07-29 20:34:14.985000000',
             '2015-07-29 15:34:14.985000000',
             '5 hours ago', 'Jul 29, 2015, 3:34:14 PM', done);
       });
 
-      test('More than six months', function(done) {
+      test('More than six months', done => {
         testDates('2015-09-15 20:34:00.000000000',
             '2015-01-15 03:25:00.000000000',
             '8 months ago', 'Jan 15, 2015, 3:25:00 AM', done);
       });
     });
 
-    suite('logged in', function() {
-      setup(function() {
+    suite('logged in', () => {
+      setup(() => {
         return stubRestAPI(
           {time_format: 'HHMM_12', relative_date_in_change_table: true}
-        ).then(function() {
+        ).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('Preferences are respected', function() {
+      test('Preferences are respected', () => {
         assert.equal(element._timeFormat, 'h:mm A');
         assert.isTrue(element._relative);
       });
     });
 
-    suite('logged out', function() {
-      setup(function() {
-        return stubRestAPI(null).then(function() {
+    suite('logged out', () => {
+      setup(() => {
+        return stubRestAPI(null).then(() => {
           element = fixture('basic');
           return element._loadPreferences();
         });
       });
 
-      test('Default preferences are respected', function() {
+      test('Default preferences are respected', () => {
         assert.equal(element._timeFormat, 'HH:mm');
         assert.isFalse(element._relative);
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
new file mode 100644
index 0000000..68c3848
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.html
@@ -0,0 +1,83 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-copy-clipboard/gr-copy-clipboard.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-download-commands">
+  <template>
+    <style include="shared-styles">
+      ul {
+        list-style: none;
+        margin-bottom: .5em;
+      }
+      li {
+        display: inline-block;
+        margin: 0;
+        padding: 0;
+      }
+      li gr-button {
+        margin-right: 1em;
+      }
+      label,
+      input {
+        display: block;
+      }
+      label {
+        font-family: var(--font-family-bold);
+      }
+      li[selected] gr-button {
+        color: #000;
+        font-family: var(--font-family-bold);
+        text-decoration: none;
+      }
+      .schemes {
+        display: flex;
+        justify-content: space-between;
+      }
+      .commands {
+        border-bottom: 1px solid #ddd;
+        border-top: 1px solid #ddd;
+        padding: .5em;
+      }
+    </style>
+    <div class="schemes">
+      <ul hidden$="[[!schemes.length]]" hidden>
+        <template is="dom-repeat" items="[[schemes]]" as="scheme">
+          <li selected$="[[_computeSelected(scheme, selectedScheme)]]">
+            <gr-button link data-scheme$="[[scheme]]" on-tap="_handleSchemeTap">
+              [[scheme]]
+            </gr-button>
+          </li>
+        </template>
+      </ul>
+    </div>
+    <div class="commands" hidden$="[[!schemes.length]]" hidden>
+      <template is="dom-repeat"
+          items="[[commands]]"
+          as="command">
+        <gr-copy-clipboard
+            title=[[command.title]]
+            text=[[command.command]]></gr-copy-clipboard>
+      </template>
+    </div>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-download-commands.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
new file mode 100644
index 0000000..f2f218b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands.js
@@ -0,0 +1,74 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-download-commands',
+    properties: {
+      commands: Array,
+      _loggedIn: {
+        type: Boolean,
+        value: false,
+        observer: '_loggedInChanged',
+      },
+      schemes: Array,
+      selectedScheme: {
+        type: String,
+        notify: true,
+      },
+    },
+
+    behaviors: [
+      Gerrit.RESTClientBehavior,
+    ],
+
+    attached() {
+      this._getLoggedIn().then(loggedIn => {
+        this._loggedIn = loggedIn;
+      });
+    },
+
+    focusOnCopy() {
+      this.$$('gr-copy-clipboard').focusOnCopy();
+    },
+
+    _getLoggedIn() {
+      return this.$.restAPI.getLoggedIn();
+    },
+
+    _loggedInChanged(loggedIn) {
+      if (!loggedIn) { return; }
+      return this.$.restAPI.getPreferences().then(prefs => {
+        if (prefs.download_scheme) {
+          // Note (issue 5180): normalize the download scheme with lower-case.
+          this.selectedScheme = prefs.download_scheme.toLowerCase();
+        }
+      });
+    },
+
+    _computeSelected(item, selectedItem) {
+      return item === selectedItem;
+    },
+
+    _handleSchemeTap(e) {
+      e.preventDefault();
+      const el = Polymer.dom(e).localTarget;
+      this.selectedScheme = el.getAttribute('data-scheme');
+      if (this._loggedIn) {
+        this.$.restAPI.savePreferences({download_scheme: this.selectedScheme});
+      }
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
new file mode 100644
index 0000000..41ef8f3
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-download-commands/gr-download-commands_test.html
@@ -0,0 +1,153 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-download-commands</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-download-commands.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-download-commands></gr-download-commands>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-download-commands', () => {
+    let element;
+    let sandbox;
+    const SCHEMES = ['http', 'repo', 'ssh'];
+    const COMMANDS = [{
+      title: 'Checkout',
+      command: `git fetch http://andybons@localhost:8080/a/test-project
+          refs/changes/05/5/1 && git checkout FETCH_HEAD`,
+    }, {
+      title: 'Cherry Pick',
+      command: `git fetch http://andybons@localhost:8080/a/test-project
+          refs/changes/05/5/1 && git cherry-pick FETCH_HEAD`,
+    }, {
+      title: 'Format Patch',
+      command: `git fetch http://andybons@localhost:8080/a/test-project
+          refs/changes/05/5/1 && git format-patch -1 --stdout FETCH_HEAD`,
+    }, {
+      title: 'Pull',
+      command: `git pull http://andybons@localhost:8080/a/test-project
+          refs/changes/05/5/1`,
+    }];
+    const SELECTED_SCHEME = 'http';
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('unauthenticated', () => {
+      setup(() => {
+        element = fixture('basic');
+        element.schemes = SCHEMES;
+        element.commands = COMMANDS;
+        element.selectedScheme = SELECTED_SCHEME;
+        flushAsynchronousOperations();
+      });
+
+      test('focusOnCopy', () => {
+        const focusStub = sandbox.stub(element.$$('gr-copy-clipboard'),
+            'focusOnCopy');
+        element.focusOnCopy();
+        assert.isTrue(focusStub.called);
+      });
+
+      test('element visibility', () => {
+        assert.isFalse(element.$$('ul').hasAttribute('hidden'));
+        assert.isFalse(element.$$('.commands').hasAttribute('hidden'));
+
+        element.schemes = [];
+        assert.isTrue(element.$$('ul').hasAttribute('hidden'));
+        assert.isTrue(element.$$('.commands').hasAttribute('hidden'));
+      });
+
+      test('tab selection', () => {
+        flushAsynchronousOperations();
+        let el = element.$$('[data-scheme="http"]').parentElement;
+        assert.isTrue(el.hasAttribute('selected'));
+        for (const scheme of ['repo', 'ssh']) {
+          const el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
+          assert.isFalse(el.hasAttribute('selected'));
+        }
+
+        MockInteractions.tap(element.$$('[data-scheme="ssh"]'));
+        el = element.$$('[data-scheme="ssh"]').parentElement;
+        assert.isTrue(el.hasAttribute('selected'));
+        for (const scheme of ['http', 'repo']) {
+          const el = element.$$('[data-scheme="' + scheme + '"]').parentElement;
+          assert.isFalse(el.hasAttribute('selected'));
+        }
+      });
+
+      test('loads scheme from preferences', done => {
+        stub('gr-rest-api-interface', {
+          getPreferences() {
+            return Promise.resolve({download_scheme: 'repo'});
+          },
+        });
+        element._loggedIn = true;
+        assert.isTrue(element.$.restAPI.getPreferences.called);
+        element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+          assert.equal(element.selectedScheme, 'repo');
+          done();
+        });
+      });
+
+      test('normalize scheme from preferences', done => {
+        stub('gr-rest-api-interface', {
+          getPreferences() {
+            return Promise.resolve({download_scheme: 'REPO'});
+          },
+        });
+        element._loggedIn = true;
+        element.$.restAPI.getPreferences.lastCall.returnValue.then(() => {
+          assert.equal(element.selectedScheme, 'repo');
+          done();
+        });
+      });
+
+      test('saves scheme to preferences', () => {
+        element._loggedIn = true;
+        const savePrefsStub = sinon.stub(element.$.restAPI, 'savePreferences',
+            () => { return Promise.resolve(); });
+
+        flushAsynchronousOperations();
+
+        const firstSchemeButton = element.$$('li gr-button[data-scheme]');
+
+        MockInteractions.tap(firstSchemeButton);
+
+        assert.isTrue(savePrefsStub.called);
+        assert.equal(savePrefsStub.lastCall.args[0].download_scheme,
+            firstSchemeButton.getAttribute('data-scheme'));
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
new file mode 100644
index 0000000..0916a89
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.html
@@ -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.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../../bower_components/paper-item/paper-item.html">
+<link rel="import" href="../../../bower_components/paper-listbox/paper-listbox.html">
+
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
+<link rel="import" href="../../shared/gr-select/gr-select.html">
+
+
+<dom-module id="gr-dropdown-list">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: inline-block;
+      }
+      #trigger {
+        -moz-user-select: text;
+        -ms-user-select: text;
+        -webkit-user-select: text;
+        user-select: text;
+      }
+      .dropdown-trigger {
+        cursor: pointer;
+        padding: 0;
+      }
+      .dropdown-content {
+        background-color: #fff;
+        box-shadow: 0 1px 5px rgba(0, 0, 0, .3);
+        max-height: 70vh;
+        margin-top: 1.5em;
+        min-width: 266px;
+        max-width: 300px;
+      }
+      paper-listbox {
+        --paper-listbox: {
+          padding: 0;
+        }
+      }
+      paper-item {
+        cursor: pointer;
+        flex-direction: column;
+        font-size: 1em;
+        --paper-item: {
+          min-height: 0;
+          padding: 10px 16px;
+        }
+        --paper-item-selected: {
+          background-color: rgba(161,194,250,.12);
+        }
+        --paper-item-focused-before: {
+          background-color: #f2f2f2;
+        }
+        --paper-item-focused: {
+          background-color: #f2f2f2;
+        }
+      }
+      paper-item:not(:last-of-type) {
+        border-bottom: 1px solid #ddd;
+      }
+      #trigger {
+        padding: .3em 0;
+      }
+      .bottomContent {
+        color: rgba(0,0,0,.54);
+        font-size: .9em;
+        line-height: 16px;
+      }
+      .bottomContent,
+      .topContent {
+        display: flex;
+        line-height: 16px;
+        justify-content: space-between;
+        flex-direction: row;
+        width: 100%;
+      }
+       gr-button {
+        --gr-button: {
+          @apply --trigger-style;
+        }
+        --gr-button-hover-color: var(--trigger-hover-color);
+      }
+      gr-date-formatter {
+        color: rgba(0,0,0,.54);
+        margin-left: 2em;
+      }
+      gr-select {
+        display: none;
+      }
+      /* Because the iron dropdown 'area' includes the trigger, and the entire
+       width of the dropdown, we want to treat tapping the area above the
+       dropdown content as if it is tapping whatever content is underneath it.
+       The next two styles allow this to happen. */
+      iron-dropdown {
+        pointer-events: none;
+      }
+      paper-listbox {
+        pointer-events: auto;
+      }
+      @media only screen and (max-width: 50em) {
+        gr-select {
+          display: inline;
+        }
+        gr-button,
+        iron-dropdown {
+          display: none;
+        }
+        select {
+          max-width: 5.25em;
+        }
+      }
+    </style>
+    <gr-button
+        down-arrow
+        link
+        id="trigger"
+        class="dropdown-trigger"
+        on-tap="_showDropdownTapHandler"
+        slot="dropdown-trigger">
+      <span>[[text]]</span>
+    </gr-button>
+    <iron-dropdown
+        id="dropdown"
+        vertical-align="top"
+        allow-outside-scroll="true"
+        on-tap="_handleDropdownTap">
+      <paper-listbox
+          class="dropdown-content"
+          slot="dropdown-content"
+          attr-for-selected="value"
+          selected="{{value}}"
+          on-tap="_handleDropdownTap">
+        <template is="dom-repeat" items="[[items]]">
+            <paper-item
+                disabled="[[item.disabled]]"
+                value="[[item.value]]">
+              <div class="topContent">
+                <div>[[item.text]]</div>
+                <template is="dom-if" if="[[item.date]]">
+                    <gr-date-formatter
+                        date-str="[[item.date]]"></gr-date-formatter>
+                </template>
+              </div>
+              <template is="dom-if" if="[[item.bottomText]]">
+                <div class="bottomContent">
+                  <div>[[item.bottomText]]</div>
+                </div>
+              </template>
+          </paper-item>
+          </template>
+      </paper-listbox>
+    </iron-dropdown>
+    <gr-select bind-value="{{value}}">
+      <select>
+        <template is="dom-repeat" items="[[items]]">
+          <option
+              disabled$="[[item.disabled]]"
+              value="[[item.value]]">
+            [[_computeMobileText(item)]]
+          </option>
+        </template>
+      </select>
+    </gr-select>
+  </template>
+  <script src="gr-dropdown-list.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
new file mode 100644
index 0000000..8f6a763
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list.js
@@ -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.
+(function() {
+  'use strict';
+
+  /**
+   * fired when the selected value of the dropdown changes
+   *
+   * @event {change}
+   */
+
+  const Defs = {};
+
+  /**
+   * Requred values are text and value. mobileText and triggerText will
+   * fall back to text if not provided.
+   *
+   * If bottomText is not provided, nothing will display on the second
+   * line.
+   *
+   * If date is not provided, nothing will be displayed in its place.
+   *
+   * @typedef {{
+   *    text: string,
+   *    value: (string|number),
+   *    bottomText: (string|undefined),
+   *    triggerText: (string|undefined),
+   *    mobileText: (string|undefined),
+   *    date: (!Date|undefined),
+   * }}
+   */
+  Defs.item;
+
+  Polymer({
+    is: 'gr-dropdown-list',
+
+    /**
+     * Fired when the selected value changes
+     *
+     * @event value-change
+     *
+     * @property {string|number} value
+     */
+
+    properties: {
+      /** @type {!Array<!Defs.item>} */
+      items: Object,
+      text: String,
+      value: {
+        type: String,
+        notify: true,
+      },
+    },
+
+    observers: [
+      '_handleValueChange(value, items)',
+    ],
+
+    /**
+     * Handle a click on the iron-dropdown element.
+     * @param {!Event} e
+     */
+    _handleDropdownTap(e) {
+      // async is needed so that that the click event is fired before the
+      // dropdown closes (This was a bug for touch devices).
+      this.async(() => {
+        this.$.dropdown.close();
+      }, 1);
+    },
+
+    /**
+     * Handle a click on the button to open the dropdown.
+     * @param {!Event} e
+     */
+    _showDropdownTapHandler(e) {
+      this._open();
+    },
+
+    /**
+     * Open the dropdown.
+     */
+    _open() {
+      this.$.dropdown.open();
+    },
+
+    _computeMobileText(item) {
+      return item.mobileText ? item.mobileText : item.text;
+    },
+
+    _handleValueChange(value, items) {
+      if (!value) { return; }
+      const selectedObj = items.find(item => {
+        return item.value + '' === value + '';
+      });
+      if (!selectedObj) { return; }
+      this.text = selectedObj.triggerText? selectedObj.triggerText :
+          selectedObj.text;
+      this.dispatchEvent(new CustomEvent('value-change', {
+        detail: {value},
+        bubbles: false,
+      }));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
new file mode 100644
index 0000000..d3c6d83
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown-list/gr-dropdown-list_test.html
@@ -0,0 +1,167 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-dropdown-list</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-dropdown-list.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-dropdown-list></gr-dropdown-list>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-dropdown-list tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      stub('gr-rest-api-interface', {
+        getConfig() { return Promise.resolve({}); },
+      });
+      element = fixture('basic');
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('tap on trigger opens menu', () => {
+      sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.tap(element.$.trigger);
+      assert.isTrue(element.$.dropdown.opened);
+    });
+
+    test('_computeMobileText', () => {
+      const item = {
+        value: 1,
+        text: 'text',
+      };
+      assert.equal(element._computeMobileText(item), item.text);
+      item.mobileText = 'mobile text';
+      assert.equal(element._computeMobileText(item), item.mobileText);
+    });
+
+    test('options are selected and laid out correctly', () => {
+      element.value = 2;
+      element.items = [
+        {
+          value: 1,
+          text: 'Top Text 1',
+        },
+        {
+          value: 2,
+          bottomText: 'Bottom Text 2',
+          triggerText: 'Button Text 2',
+          text: 'Top Text 2',
+          mobileText: 'Mobile Text 2',
+        },
+        {
+          value: 3,
+          disabled: true,
+          bottomText: 'Bottom Text 3',
+          triggerText: 'Button Text 3',
+          date: '2017-08-18 23:11:42.569000000',
+          text: 'Top Text 3',
+          mobileText: 'Mobile Text 3',
+        },
+      ];
+      assert.equal(element.$$('paper-listbox').selected, element.value);
+      assert.equal(element.text, 'Button Text 2');
+      flushAsynchronousOperations();
+      const items = Polymer.dom(element.root).querySelectorAll('paper-item');
+      const mobileItems = Polymer.dom(element.root).querySelectorAll('option');
+      assert.equal(items.length, 3);
+      assert.equal(mobileItems.length, 3);
+
+      // First Item
+      // The first item should be disabled, has no bottom text, and no date.
+      assert.isFalse(!!items[0].disabled);
+      assert.isFalse(mobileItems[0].disabled);
+      assert.isFalse(items[0].classList.contains('iron-selected'));
+      assert.isFalse(mobileItems[0].selected);
+
+      assert.isNotOk(Polymer.dom(items[0]).querySelector('gr-date-formatter'));
+      assert.isNotOk(Polymer.dom(items[0]).querySelector('.bottomContent'));
+      assert.equal(items[0].value, element.items[0].value);
+      assert.equal(mobileItems[0].value, element.items[0].value);
+      assert.equal(Polymer.dom(items[0]).querySelector('.topContent div')
+          .innerText, element.items[0].text);
+
+      // Since no mobile specific text, it should fall back to text.
+      assert.equal(mobileItems[0].text, element.items[0].text);
+
+
+      // Second Item
+      // The second item should have top text, bottom text, and no date.
+      assert.isFalse(!!items[1].disabled);
+      assert.isFalse(mobileItems[1].disabled);
+      assert.isTrue(items[1].classList.contains('iron-selected'));
+      assert.isTrue(mobileItems[1].selected);
+
+      assert.isNotOk(Polymer.dom(items[1]).querySelector('gr-date-formatter'));
+      assert.isOk(Polymer.dom(items[1]).querySelector('.bottomContent'));
+      assert.equal(items[1].value, element.items[1].value);
+      assert.equal(mobileItems[1].value, element.items[1].value);
+      assert.equal(Polymer.dom(items[1]).querySelector('.topContent div')
+          .innerText, element.items[1].text);
+
+      // Since there is mobile specific text, it should that.
+      assert.equal(mobileItems[1].text, element.items[1].mobileText);
+
+      // Since this item is selected, and it has triggerText defined, that
+      // should be used.
+      assert.equal(element.text, element.items[1].triggerText);
+
+      // Third item
+      // The third item should be disabled, and have a date, and bottom content.
+      assert.isTrue(!!items[2].disabled);
+      assert.isTrue(mobileItems[2].disabled);
+      assert.isFalse(items[2].classList.contains('iron-selected'));
+      assert.isFalse(mobileItems[2].selected);
+
+      assert.isOk(Polymer.dom(items[2]).querySelector('gr-date-formatter'));
+      assert.isOk(Polymer.dom(items[2]).querySelector('.bottomContent'));
+      assert.equal(items[2].value, element.items[2].value);
+      assert.equal(mobileItems[2].value, element.items[2].value);
+      assert.equal(Polymer.dom(items[2]).querySelector('.topContent div')
+          .innerText, element.items[2].text);
+
+      // Since there is mobile specific text, it should that.
+      assert.equal(mobileItems[2].text, element.items[2].mobileText);
+
+      // Select a new item.
+      MockInteractions.tap(items[0]);
+      flushAsynchronousOperations();
+      assert.equal(element.value, 1);
+      assert.isTrue(items[0].classList.contains('iron-selected'));
+      assert.isTrue(mobileItems[0].selected);
+
+      // Since no triggerText, the fallback is used.
+      assert.equal(element.text, element.items[0].text);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
index e89bf05..b821909 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.html
@@ -15,14 +15,17 @@
 -->
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-dropdown">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: inline-block;
       }
@@ -40,22 +43,22 @@
         font: inherit;
         padding: .3em 0;
       }
-      :host[down-arrow] .dropdown-trigger {
-        padding-right: 1.4em;
-      }
       gr-avatar {
         height: 2em;
         width: 2em;
         vertical-align: middle;
       }
       gr-button[link] {
-        padding: 1em 0;
+        padding: 0.5em;
+      }
+      gr-button[link]:focus {
+        outline: 5px auto -webkit-focus-ring-color;
       }
       ul {
         list-style: none;
       }
       ul .accountName {
-        font-weight: bold;
+        font-family: var(--font-family-bold);
       }
       li .accountInfo,
       li .itemAction {
@@ -69,40 +72,31 @@
       }
       li .itemAction:link,
       li .itemAction:visited {
-        color: #00e;
         text-decoration: none;
       }
       li .itemAction:not(.disabled):hover {
         background-color: #6B82D6;
         color: #fff;
       }
+      li:focus,
+      li.selected {
+        background-color: #EBF5FB;
+        outline: none;
+      }
       .topContent {
         display: block;
         padding: .85em 1em;
       }
       .bold-text {
-        font-weight: bold;
-      }
-      :host:not([down-arrow]) .downArrow { display: none; }
-      :host[down-arrow] .downArrow {
-        border-left: .36em solid transparent;
-        border-right: .36em solid transparent;
-        border-top: .36em solid #ccc;
-        height: 0;
-        position: absolute;
-        right: .3em;
-        top: calc(50% - .05em);
-        transition: border-top-color 200ms;
-        width: 0;
-      }
-      .dropdown-trigger:hover .downArrow {
-        border-top-color: #666;
+        font-family: var(--font-family-bold);
       }
     </style>
-    <gr-button link="[[link]]" class="dropdown-trigger" id="trigger"
+    <gr-button
+        link="[[link]]"
+        class="dropdown-trigger" id="trigger"
+        down-arrow="[[downArrow]]"
         on-tap="_showDropdownTapHandler">
       <content></content>
-       <i class="downArrow"></i>
     </gr-button>
     <iron-dropdown id="dropdown"
         vertical-align="top"
@@ -110,7 +104,7 @@
         allow-outside-scroll="true"
         horizontal-align="[[horizontalAlign]]"
         on-tap="_handleDropdownTap">
-      <div class="dropdown-content">
+      <div class="dropdown-content" slot="dropdown-content">
         <ul>
           <template is="dom-if" if="[[topContent]]">
             <div class="topContent">
@@ -119,7 +113,9 @@
                   items="[[topContent]]"
                   as="item"
                   initial-count="75">
-                <div class$="[[_getClassIfBold(item.bold)]] top-item">
+                <div
+                    class$="[[_getClassIfBold(item.bold)]] top-item"
+                    tabindex="-1">
                   [[item.text]]
                 </div>
               </template>
@@ -130,23 +126,31 @@
               items="[[items]]"
               as="link"
               initial-count="75">
-            <li>
+            <li tabindex="-1">
               <span
                   class$="itemAction [[_computeDisabledClass(link.id, disabledIds.*)]]"
                   data-id$="[[link.id]]"
                   on-tap="_handleItemTap"
-                  hidden$="[[link.url]]">[[link.name]]</span>
+                  hidden$="[[link.url]]"
+                  tabindex="-1">[[link.name]]</span>
               <a
                   class="itemAction"
                   href$="[[_computeLinkURL(link)]]"
                   rel$="[[_computeLinkRel(link)]]"
                   target$="[[link.target]]"
-                  hidden$="[[!link.url]]">[[link.name]]</a>
+                  hidden$="[[!link.url]]"
+                  tabindex="-1">[[link.name]]</a>
             </li>
           </template>
         </ul>
       </div>
     </iron-dropdown>
+    <gr-cursor-manager
+        id="cursor"
+        cursor-target-class="selected"
+        scroll-behavior="never"
+        focus-on-move
+        stops="[[_listElements]]"></gr-cursor-manager>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
   <script src="gr-dropdown.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
index bf587d7..49e5a5e 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown.js
@@ -14,6 +14,9 @@
 (function() {
   'use strict';
 
+  const REL_NOOPENER = 'noopener';
+  const REL_EXTERNAL = 'external';
+
   Polymer({
     is: 'gr-dropdown',
 
@@ -23,8 +26,18 @@
      * @event tap-item-<id>
      */
 
+    /**
+     * Fired when a non-link dropdown item is tapped.
+     *
+     * @event tap-item
+     */
+
     properties: {
-      items: Array,
+      items: {
+        type: Array,
+        observer: '_resetCursorStops',
+      },
+      downArrow: Boolean,
       topContent: Object,
       horizontalAlign: {
         type: String,
@@ -50,44 +63,156 @@
        */
       disabledIds: {
         type: Array,
-        value: function() { return []; },
+        value() { return []; },
       },
 
-      _hasAvatars: String,
+      /**
+       * The elements of the list.
+       */
+      _listElements: {
+        type: Array,
+        value() { return []; },
+      },
     },
 
     behaviors: [
       Gerrit.BaseUrlBehavior,
+      Gerrit.KeyboardShortcutBehavior,
     ],
 
-    attached: function() {
-      this.$.restAPI.getConfig().then(function(cfg) {
-        this._hasAvatars = !!(cfg && cfg.plugin && cfg.plugin.has_avatars);
-      }.bind(this));
+    keyBindings: {
+      'down': '_handleDown',
+      'enter space': '_handleEnter',
+      'tab': '_handleTab',
+      'up': '_handleUp',
     },
 
-    _handleDropdownTap: function(e) {
-      this.$.dropdown.close();
+    /**
+     * Handle the up key.
+     * @param {!Event} e
+     */
+    _handleUp(e) {
+      if (this.$.dropdown.opened) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.$.cursor.previous();
+      } else {
+        this._open();
+      }
     },
 
-    _showDropdownTapHandler: function(e) {
+    /**
+     * Handle the down key.
+     * @param {!Event} e
+     */
+    _handleDown(e) {
+      if (this.$.dropdown.opened) {
+        e.preventDefault();
+        e.stopPropagation();
+        this.$.cursor.next();
+      } else {
+        this._open();
+      }
+    },
+
+    /**
+     * Handle the tab key.
+     * @param {!Event} e
+     */
+    _handleTab(e) {
+      if (this.$.dropdown.opened) {
+        // Tab in a native select is a no-op. Emulate this.
+        e.preventDefault();
+        e.stopPropagation();
+      }
+    },
+
+    /**
+     * Handle the enter key.
+     * @param {!Event} e
+     */
+    _handleEnter(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      if (this.$.dropdown.opened) {
+        // TODO(kaspern): This solution will not work in Shadow DOM, and
+        // is not particularly robust in general. Find a better solution
+        // when page.js has been abstracted away from components.
+        const el = this.$.cursor.target.querySelector(':not([hidden])');
+        if (el) { el.click(); }
+      } else {
+        this._open();
+      }
+    },
+
+    /**
+     * Handle a click on the iron-dropdown element.
+     * @param {!Event} e
+     */
+    _handleDropdownTap(e) {
+      // async is needed so that that the click event is fired before the
+      // dropdown closes (This was a bug for touch devices).
+      this.async(() => {
+        this.$.dropdown.close();
+      }, 1);
+    },
+
+    /**
+     * Hanlde a click on the button to open the dropdown.
+     * @param {!Event} e
+     */
+    _showDropdownTapHandler(e) {
+      this._open();
+    },
+
+    /**
+     * Open the dropdown and initialize the cursor.
+     */
+    _open() {
       this.$.dropdown.open();
+      this.$.cursor.setCursorAtIndex(0);
+      Polymer.dom.flush();
+      this.$.cursor.target.focus();
     },
 
-    _getClassIfBold: function(bold) {
+    /**
+     * Get the class for a top-content item based on the given boolean.
+     * @param {boolean} bold Whether the item is bold.
+     * @return {string} The class for the top-content item.
+     */
+    _getClassIfBold(bold) {
       return bold ? 'bold-text' : '';
     },
 
-    _computeURLHelper: function(host, path) {
+    /**
+     * Build a URL for the given host and path. If there is a base URL, it will
+     * be included between the host and the path.
+     * @param {!string} host
+     * @param {!string} path
+     * @return {!string} The scheme-relative URL.
+     */
+    _computeURLHelper(host, path) {
       return '//' + host + this.getBaseUrl() + path;
     },
 
-    _computeRelativeURL: function(path) {
-      var host = window.location.host;
+    /**
+     * Build a scheme-relative URL for the current host. Will include the base
+     * URL if one is present. Note: the URL will be scheme-relative but absolute
+     * with regard to the host.
+     * @param {!string} path The path for the URL.
+     * @return {!string} The scheme-relative URL.
+     */
+    _computeRelativeURL(path) {
+      const host = window.location.host;
       return this._computeURLHelper(host, path);
     },
 
-    _computeLinkURL: function(link) {
+    /**
+     * Compute the URL for a link object.
+     * @param {!Object} link The object describing the link.
+     * @return {!string} The URL.
+     */
+    _computeLinkURL(link) {
       if (typeof link.url === 'undefined') {
         return '';
       }
@@ -97,19 +222,52 @@
       return this._computeRelativeURL(link.url);
     },
 
-    _computeLinkRel: function(link) {
-      return link.target ? 'noopener' : null;
+    /**
+     * Compute the value for the rel attribute of an anchor for the given link
+     * object. If the link has a target value, then the rel must be "noopener"
+     * for security reasons.
+     * @param {!Object} link The object describing the link.
+     * @return {?string} The rel value for the link.
+     */
+    _computeLinkRel(link) {
+      // Note: noopener takes precedence over external.
+      if (link.target) { return REL_NOOPENER; }
+      if (link.external) { return REL_EXTERNAL; }
+      return null;
     },
 
-    _handleItemTap: function(e) {
-      var id = e.target.getAttribute('data-id');
-      if (id && this.disabledIds.indexOf(id) === -1) {
+    /**
+     * Handle a click on an item of the dropdown.
+     * @param {!Event} e
+     */
+    _handleItemTap(e) {
+      const id = e.target.getAttribute('data-id');
+      const item = this.items.find(item => item.id === id);
+      if (id && !this.disabledIds.includes(id)) {
+        if (item) {
+          this.dispatchEvent(new CustomEvent('tap-item', {detail: item}));
+        }
         this.dispatchEvent(new CustomEvent('tap-item-' + id));
       }
     },
 
-    _computeDisabledClass: function(id, disabledIdsRecord) {
-      return disabledIdsRecord.base.indexOf(id) === -1 ? '' : 'disabled';
+    /**
+     * If a dropdown item is shown as a button, get the class for the button.
+     * @param {string} id
+     * @param {!Object} disabledIdsRecord The change record for the disabled IDs
+     *     list.
+     * @return {!string} The class for the item button.
+     */
+    _computeDisabledClass(id, disabledIdsRecord) {
+      return disabledIdsRecord.base.includes(id) ? 'disabled' : '';
+    },
+
+    /**
+     * Recompute the stops for the dropdown item cursor.
+     */
+    _resetCursorStops() {
+      Polymer.dom.flush();
+      this._listElements = Polymer.dom(this.root).querySelectorAll('li');
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
index 74ad85e..ab31f7c 100644
--- a/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-dropdown/gr-dropdown_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-dropdown.html">
 
 <script>void(0);</script>
@@ -33,30 +32,37 @@
 </test-fixture>
 
 <script>
-  suite('gr-dropdown tests', function() {
-    var element;
+  suite('gr-dropdown tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
+        getConfig() { return Promise.resolve({}); },
       });
       element = fixture('basic');
+      sandbox = sinon.sandbox.create();
     });
 
-    test('tap on trigger opens menu', function() {
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('tap on trigger opens menu', () => {
+      sandbox.stub(element, '_open', () => { element.$.dropdown.open(); });
       assert.isFalse(element.$.dropdown.opened);
       MockInteractions.tap(element.$.trigger);
       assert.isTrue(element.$.dropdown.opened);
     });
 
-    test('_computeURLHelper', function() {
-      var path = '/test';
-      var host = 'http://www.testsite.com';
-      var computedPath = element._computeURLHelper(host, path);
+    test('_computeURLHelper', () => {
+      const path = '/test';
+      const host = 'http://www.testsite.com';
+      const computedPath = element._computeURLHelper(host, path);
       assert.equal(computedPath, '//http://www.testsite.com/test');
     });
 
-    test('link URLs', function() {
+    test('link URLs', () => {
       assert.equal(
           element._computeLinkURL({url: '/test'}),
           '//' + window.location.host + '/test');
@@ -65,49 +71,104 @@
           '/test');
     });
 
-    test('link rel', function() {
-      assert.isNull(element._computeLinkRel({url: '/test'}));
-      assert.equal(
-          element._computeLinkRel({url: '/test', target: '_blank'}),
-          'noopener');
+    test('link rel', () => {
+      let link = {url: '/test'};
+      assert.isNull(element._computeLinkRel(link));
+
+      link = {url: '/test', target: '_blank'};
+      assert.equal(element._computeLinkRel(link), 'noopener');
+
+      link = {url: '/test', external: true};
+      assert.equal(element._computeLinkRel(link), 'external');
+
+      link = {url: '/test', target: '_blank', external: true};
+      assert.equal(element._computeLinkRel(link), 'noopener');
     });
 
-    test('_getClassIfBold', function() {
-      var bold = true;
+    test('_getClassIfBold', () => {
+      let bold = true;
       assert.equal(element._getClassIfBold(bold), 'bold-text');
 
       bold = false;
       assert.equal(element._getClassIfBold(bold), '');
     });
 
-    test('Top text exists and is bolded correctly', function() {
+    test('Top text exists and is bolded correctly', () => {
       element.topContent = [{text: 'User', bold: true}, {text: 'email'}];
       flushAsynchronousOperations();
-      var topItems = Polymer.dom(element.root).querySelectorAll('.top-item');
+      const topItems = Polymer.dom(element.root).querySelectorAll('.top-item');
       assert.equal(topItems.length, 2);
       assert.isTrue(topItems[0].classList.contains('bold-text'));
       assert.isFalse(topItems[1].classList.contains('bold-text'));
     });
 
-    test('non link items', function() {
-      element.items = [
-          {name: 'item one', id: 'foo'}, {name: 'item two', id: 'bar'}];
-      var stub = sinon.stub();
-      element.addEventListener('tap-item-foo', stub);
+    test('non link items', () => {
+      const item0 = {name: 'item one', id: 'foo'};
+      element.items = [item0, {name: 'item two', id: 'bar'}];
+      const fooTapped = sandbox.stub();
+      const tapped = sandbox.stub();
+      element.addEventListener('tap-item-foo', fooTapped);
+      element.addEventListener('tap-item', tapped);
       flushAsynchronousOperations();
       MockInteractions.tap(element.$$('.itemAction'));
-      assert.isTrue(stub.called);
+      assert.isTrue(fooTapped.called);
+      assert.isTrue(tapped.called);
+      assert.deepEqual(tapped.lastCall.args[0].detail, item0);
     });
 
-    test('disabled non link item', function() {
+    test('disabled non link item', () => {
       element.items = [{name: 'item one', id: 'foo'}];
       element.disabledIds = ['foo'];
 
-      var stub = sinon.stub();
+      const stub = sandbox.stub();
+      const tapped = sandbox.stub();
       element.addEventListener('tap-item-foo', stub);
+      element.addEventListener('tap-item', tapped);
       flushAsynchronousOperations();
       MockInteractions.tap(element.$$('.itemAction'));
       assert.isFalse(stub.called);
+      assert.isFalse(tapped.called);
+    });
+
+    suite('keyboard navigation', () => {
+      setup(() => {
+        element.items = [
+          {name: 'item one', id: 'foo'},
+          {name: 'item two', id: 'bar'},
+        ];
+        flushAsynchronousOperations();
+      });
+
+      test('down', () => {
+        const stub = sandbox.stub(element.$.cursor, 'next');
+        assert.isFalse(element.$.dropdown.opened);
+        MockInteractions.pressAndReleaseKeyOn(element, 40);
+        assert.isTrue(element.$.dropdown.opened);
+        MockInteractions.pressAndReleaseKeyOn(element, 40);
+        assert.isTrue(stub.called);
+      });
+
+      test('up', () => {
+        const stub = sandbox.stub(element.$.cursor, 'previous');
+        assert.isFalse(element.$.dropdown.opened);
+        MockInteractions.pressAndReleaseKeyOn(element, 38);
+        assert.isTrue(element.$.dropdown.opened);
+        MockInteractions.pressAndReleaseKeyOn(element, 38);
+        assert.isTrue(stub.called);
+      });
+
+      test('enter/space', () => {
+        // Because enter and space are handled by the same fn, we need only to
+        // test one.
+        assert.isFalse(element.$.dropdown.opened);
+        MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+        assert.isTrue(element.$.dropdown.opened);
+
+        const el = element.$.cursor.target.querySelector(':not([hidden])');
+        const stub = sandbox.stub(el, 'click');
+        MockInteractions.pressAndReleaseKeyOn(element, 32); // Space
+        assert.isTrue(stub.called);
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
index bd87db3..e8c8037 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.html
@@ -16,10 +16,11 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-editable-content">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
@@ -30,6 +31,8 @@
         width: 100%;
 
         --iron-autogrow-textarea: {
+          box-sizing: border-box;
+          overflow-y: hidden;
           white-space: pre;
         };
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
index 86211a4..e6ea72d 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content.js
@@ -53,11 +53,11 @@
       _newContent: String,
     },
 
-    focusTextarea: function() {
+    focusTextarea() {
       this.$$('iron-autogrow-textarea').textarea.focus();
     },
 
-    _editingChanged: function(editing) {
+    _editingChanged(editing) {
       if (!editing) { return; }
 
       // TODO(wyatta) switch linkify sequence, see issue 5526.
@@ -65,16 +65,16 @@
           this.content.replace(/^R=\u200B/gm, 'R=') : this.content;
     },
 
-    _computeSaveDisabled: function(disabled, content, newContent) {
+    _computeSaveDisabled(disabled, content, newContent) {
       return disabled || (content === newContent);
     },
 
-    _handleSave: function(e) {
+    _handleSave(e) {
       e.preventDefault();
       this.fire('editable-content-save', {content: this._newContent});
     },
 
-    _handleCancel: function(e) {
+    _handleCancel(e) {
       e.preventDefault();
       this.editing = false;
       this.fire('editable-content-cancel');
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
index b25e815..d8e5b21 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-content/gr-editable-content_test.html
@@ -20,6 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
 
 <link rel="import" href="gr-editable-content.html">
@@ -33,37 +34,37 @@
 </test-fixture>
 
 <script>
-  suite('gr-editable-content tests', function() {
-    var element;
+  suite('gr-editable-content tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('save event', function(done) {
+    test('save event', done => {
       element._newContent = 'foo';
-      element.addEventListener('editable-content-save', function(e) {
+      element.addEventListener('editable-content-save', e => {
         assert.equal(e.detail.content, 'foo');
         done();
       });
       MockInteractions.tap(element.$$('gr-button[primary]'));
     });
 
-    test('cancel event', function(done) {
-      element.addEventListener('editable-content-cancel', function() {
+    test('cancel event', done => {
+      element.addEventListener('editable-content-cancel', () => {
         done();
       });
       MockInteractions.tap(element.$$('gr-button:not([primary])'));
     });
 
-    test('enabling editing updates edit field contents', function() {
+    test('enabling editing updates edit field contents', () => {
       element.content = 'current content';
       element._newContent = 'stale content';
       element.editing = true;
       assert.equal(element._newContent, 'current content');
     });
 
-    test('disabling editing does not update edit field contents', function() {
+    test('disabling editing does not update edit field contents', () => {
       element.content = 'current content';
       element.editing = true;
       element._newContent = 'stale content';
@@ -71,24 +72,24 @@
       assert.equal(element._newContent, 'stale content');
     });
 
-    test('zero width spaces are removed properly', function() {
+    test('zero width spaces are removed properly', () => {
       element.removeZeroWidthSpace = true;
       element.content = 'R=\u200Btest@google.com';
       element.editing = true;
       assert.equal(element._newContent, 'R=test@google.com');
     });
 
-    suite('editing', function() {
-      setup(function() {
+    suite('editing', () => {
+      setup(() => {
         element.content = 'current content';
         element.editing = true;
       });
 
-      test('save button is disabled initially', function() {
+      test('save button is disabled initially', () => {
         assert.isTrue(element.$$('gr-button[primary]').disabled);
       });
 
-      test('save button is enabled when content changes', function() {
+      test('save button is enabled when content changes', () => {
         element._newContent = 'new content';
         assert.isFalse(element.$$('gr-button[primary]').disabled);
       });
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
index 32cff2a..ef78f3a 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.html
@@ -14,14 +14,22 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-<link rel="import" href="../../../bower_components/iron-input/iron-input.html">
+
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../../bower_components/iron-dropdown/iron-dropdown.html">
+<link rel="import" href="../../../bower_components/paper-input/paper-input.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
 <dom-module id="gr-editable-label">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         align-items: center;
         display: inline-flex;
       }
+      :host([uppercase]) label {
+        text-transform: uppercase;
+      }
       input,
       label {
         width: 100%;
@@ -32,29 +40,64 @@
       label {
         color: #777;
         display: inline-block;
+        font-family: var(--font-family-bold);
         overflow: hidden;
         text-overflow: ellipsis;
         white-space: nowrap;
       }
       label.editable {
+        color: var(--color-link);
         cursor: pointer;
       }
-      label.editable.placeholder {
-        color: #00f;
-        text-decoration: underline;
+      #dropdown {
+        box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
+      }
+      .inputContainer {
+        padding: .8em;
+        background-color: #fff;
+      }
+      .buttons {
+        display: flex;
+        padding-top: 1.2em;
+        justify-content: flex-end;
+        width: 100%;
+      }
+      .buttons gr-button {
+        margin-left: .5em;
+      }
+      paper-input {
+        --paper-input-container: {
+          padding: 0;
+          min-width: 15em;
+        }
+        --paper-input-container-input: {
+          font-size: 1em;
+        }
       }
     </style>
-    <input
-        is="iron-input"
-        id="input"
-        hidden$="[[!editing]]"
-        on-keydown="_handleInputKeydown"
-        bind-value="{{_inputText}}">
-    <label
-        hidden$="[[editing]]"
-        class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
-        title$="[[_computeLabel(value, placeholder)]]"
-        on-tap="_open">[[_computeLabel(value, placeholder)]]</label>
+      <label
+          class$="[[_computeLabelClass(readOnly, value, placeholder)]]"
+          title$="[[_computeLabel(value, placeholder)]]"
+          on-tap="_showDropdown">[[_computeLabel(value, placeholder)]]</label>
+      <iron-dropdown id="dropdown"
+          vertical-align="auto"
+          horizontal-align="auto"
+          vertical-offset="[[_verticalOffset]]"
+          allow-outside-scroll="true"
+          on-iron-overlay-canceled="_cancel">
+        <div class="dropdown-content" slot="dropdown-content">
+          <div class="inputContainer">
+            <paper-input
+                id="input"
+                label="[[labelText]]"
+                value="{{_inputText}}"></paper-input>
+            <div class="buttons">
+              <gr-button link id="cancelBtn" on-tap="_cancel">cancel</gr-button>
+              <gr-button link id="saveBtn" on-tap="_save">save</gr-button>
+            </div>
+          </div>
+        </div>
+    </iron-dropdown>
   </template>
   <script src="gr-editable-label.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
index f3e83f9..af06291 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label.js
@@ -14,6 +14,9 @@
 (function() {
   'use strict';
 
+  const AWAIT_MAX_ITERS = 10;
+  const AWAIT_STEP = 5;
+
   Polymer({
     is: 'gr-editable-label',
 
@@ -24,6 +27,7 @@
      */
 
     properties: {
+      labelText: String,
       editing: {
         type: Boolean,
         value: false,
@@ -31,74 +35,144 @@
       value: {
         type: String,
         notify: true,
-        value: null,
+        value: '',
         observer: '_updateTitle',
       },
       placeholder: {
         type: String,
-        value: null,
+        value: '',
       },
       readOnly: {
         type: Boolean,
         value: false,
       },
+      uppercase: {
+        type: Boolean,
+        reflectToAttribute: true,
+        value: false,
+      },
       _inputText: String,
+      // This is used to push the iron-input element up on the page, so
+      // the input is placed in approximately the same position as the
+      // trigger.
+      _verticalOffset: {
+        type: Number,
+        readOnly: true,
+        value: -30,
+      },
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    keyBindings: {
+      enter: '_handleEnter',
+      esc: '_handleEsc',
     },
 
     hostAttributes: {
       tabindex: '0',
     },
 
-    _usePlaceholder: function(value, placeholder) {
+    _usePlaceholder(value, placeholder) {
       return (!value || !value.length) && placeholder;
     },
 
-    _computeLabel: function(value, placeholder) {
+    _computeLabel(value, placeholder) {
       if (this._usePlaceholder(value, placeholder)) {
         return placeholder;
       }
       return value;
     },
 
-    _open: function() {
+    _showDropdown() {
       if (this.readOnly || this.editing) { return; }
-
-      this._inputText = this.value;
-      this.editing = true;
-
-      this.async(function() {
-        this.$.input.focus();
-        this.$.input.setSelectionRange(0, this.$.input.value.length);
+      this._open().then(() => {
+        this.$.input.$.input.focus();
+        if (!this.$.input.value) { return; }
+        this.$.input.$.input.setSelectionRange(0, this.$.input.value.length);
       });
     },
 
-    _save: function() {
-      if (!this.editing) { return; }
+    _open(...args) {
+      this.$.dropdown.open();
+      this._inputText = this.value;
+      this.editing = true;
 
+      return new Promise(resolve => {
+        Polymer.IronOverlayBehaviorImpl.open.apply(this.$.dropdown, args);
+        this._awaitOpen(resolve);
+      });
+    },
+
+    /**
+     * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
+     * opening. Eventually replace with a direct way to listen to the overlay.
+     */
+    _awaitOpen(fn) {
+      let iters = 0;
+      const step = () => {
+        this.async(() => {
+          if (this.style.display !== 'none') {
+            fn.call(this);
+          } else if (iters++ < AWAIT_MAX_ITERS) {
+            step.call(this);
+          }
+        }, AWAIT_STEP);
+      };
+      step.call(this);
+    },
+
+    _id() {
+      return this.getAttribute('id') || 'global';
+    },
+
+    _save() {
+      if (!this.editing) { return; }
+      this.$.dropdown.close();
       this.value = this._inputText;
       this.editing = false;
       this.fire('changed', this.value);
     },
 
-    _cancel: function() {
+    _cancel() {
       if (!this.editing) { return; }
-
+      this.$.dropdown.close();
       this.editing = false;
       this._inputText = this.value;
     },
 
-    _handleInputKeydown: function(e) {
-      if (e.keyCode === 13) {  // Enter key
+    /**
+     * @suppress {checkTypes}
+     * Closure doesn't think 'e' is an Event.
+     * TODO(beckysiegel) figure out why.
+     */
+    _handleEnter(e) {
+      e = this.getKeyboardEvent(e);
+      const target = Polymer.dom(e).rootTarget;
+      if (target === this.$.input.$.input) {
         e.preventDefault();
         this._save();
-      } else if (e.keyCode === 27) { // Escape key
+      }
+    },
+
+    /**
+     * @suppress {checkTypes}
+     * Closure doesn't think 'e' is an Event.
+     * TODO(beckysiegel) figure out why.
+     */
+    _handleEsc(e) {
+      e = this.getKeyboardEvent(e);
+      const target = Polymer.dom(e).rootTarget;
+      if (target === this.$.input.$.input) {
         e.preventDefault();
         this._cancel();
       }
     },
 
-    _computeLabelClass: function(readOnly, value, placeholder) {
-      var classes = [];
+    _computeLabelClass(readOnly, value, placeholder) {
+      const classes = [];
       if (!readOnly) { classes.push('editable'); }
       if (this._usePlaceholder(value, placeholder)) {
         classes.push('placeholder');
@@ -106,8 +180,8 @@
       return classes.join(' ');
     },
 
-    _updateTitle: function(value) {
-      this.setAttribute('title', (value && value.length) ? value : null);
+    _updateTitle(value) {
+      this.setAttribute('title', this._computeLabel(value, this.placeholder));
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
index 42770aa..ec5b64a 100644
--- a/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-editable-label/gr-editable-label_test.html
@@ -20,6 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
 
 <link rel="import" href="gr-editable-label.html">
@@ -34,6 +35,12 @@
   </template>
 </test-fixture>
 
+<test-fixture id="no-placeholder">
+  <template>
+    <gr-editable-label value=""></gr-editable-label>
+  </template>
+</test-fixture>
+
 <test-fixture id="read-only">
   <template>
     <gr-editable-label
@@ -44,88 +51,186 @@
 </test-fixture>
 
 <script>
-  suite('gr-editable-label tests', function() {
-    var element;
-    var input;
-    var label;
+  suite('gr-editable-label tests', () => {
+    let element;
+    let elementNoPlaceholder;
+    let input;
+    let label;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
+      elementNoPlaceholder = fixture('no-placeholder');
 
-      input = element.$$('input');
+      input = element.$.input.$.input;
       label = element.$$('label');
+      sandbox = sinon.sandbox.create();
     });
 
-    test('element render', function() {
-      // The input is hidden and the label is visible:
-      assert.isNotNull(input.getAttribute('hidden'));
-      assert.isNull(label.getAttribute('hidden'));
+    teardown(() => {
+      sandbox.restore();
+    });
 
+    test('element render', () => {
+      // The dropdown is closed and the label is visible:
+      assert.isFalse(element.$.dropdown.opened);
       assert.isTrue(label.classList.contains('editable'));
-
       assert.equal(label.textContent, 'value text');
 
       MockInteractions.tap(label);
 
       Polymer.dom.flush();
 
-      // The input is visible and the label is hidden:
-      assert.isNull(input.getAttribute('hidden'));
-      assert.isNotNull(label.getAttribute('hidden'));
-
+      // The dropdown is open (which covers up the label):
+      assert.isTrue(element.$.dropdown.opened);
       assert.equal(input.value, 'value text');
     });
 
-    test('edit value', function(done) {
-      var editedStub = sinon.stub();
+    test('title with placeholder', done => {
+      assert.equal(element.title, 'value text');
+      element.value = '';
+
+      element.async(() => {
+        assert.equal(element.title, 'label text');
+        done();
+      });
+    });
+
+    test('title without placeholder', done => {
+      assert.equal(elementNoPlaceholder.title, '');
+      element.value = 'value text';
+
+      element.async(() => {
+        assert.equal(element.title, 'value text');
+        done();
+      });
+    });
+
+    test('edit value', done => {
+      const editedStub = sandbox.stub();
       element.addEventListener('changed', editedStub);
+      assert.isFalse(element.editing);
 
       MockInteractions.tap(label);
 
       Polymer.dom.flush();
 
+      assert.isTrue(element.editing);
       element._inputText = 'new text';
 
       assert.isFalse(editedStub.called);
 
-      element.async(function() {
+      element.async(() => {
         assert.isTrue(editedStub.called);
         assert.equal(input.value, 'new text');
+        assert.isFalse(element.editing);
         done();
       });
 
       // Press enter:
       MockInteractions.keyDownOn(input, 13);
     });
-  });
 
-  suite('gr-editable-label read-only tests', function() {
-    var element;
-    var input;
-    var label;
-
-    setup(function() {
-      element = fixture('read-only');
-
-      input = element.$$('input');
-      label = element.$$('label');
-    });
-
-    test('disallows edit when read-only', function() {
-      // The input is hidden and the label is visible:
-      assert.isNotNull(input.getAttribute('hidden'));
-      assert.isNull(label.getAttribute('hidden'));
+    test('save button', done => {
+      const editedStub = sandbox.stub();
+      element.addEventListener('changed', editedStub);
+      assert.isFalse(element.editing);
 
       MockInteractions.tap(label);
 
       Polymer.dom.flush();
 
-      // The input is still hidden and the label is still visible:
-      assert.isNotNull(input.getAttribute('hidden'));
-      assert.isNull(label.getAttribute('hidden'));
+      assert.isTrue(element.editing);
+      element._inputText = 'new text';
+
+      assert.isFalse(editedStub.called);
+
+      element.async(() => {
+        assert.isTrue(editedStub.called);
+        assert.equal(input.value, 'new text');
+        assert.isFalse(element.editing);
+        done();
+      });
+
+      // Press enter:
+      MockInteractions.tap(element.$.saveBtn, 13);
     });
 
-    test('label is not marked as editable', function() {
+
+    test('edit and then escape key', done => {
+      const editedStub = sandbox.stub();
+      element.addEventListener('changed', editedStub);
+      assert.isFalse(element.editing);
+
+      MockInteractions.tap(label);
+
+      Polymer.dom.flush();
+
+      assert.isTrue(element.editing);
+      element._inputText = 'new text';
+
+      assert.isFalse(editedStub.called);
+
+      element.async(() => {
+        assert.isFalse(editedStub.called);
+        // Text changes sould be discarded.
+        assert.equal(input.value, 'value text');
+        assert.isFalse(element.editing);
+        done();
+      });
+
+      // Press escape:
+      MockInteractions.keyDownOn(input, 27);
+    });
+
+    test('cancel button', done => {
+      const editedStub = sandbox.stub();
+      element.addEventListener('changed', editedStub);
+      assert.isFalse(element.editing);
+
+      MockInteractions.tap(label);
+
+      Polymer.dom.flush();
+
+      assert.isTrue(element.editing);
+      element._inputText = 'new text';
+
+      assert.isFalse(editedStub.called);
+
+      element.async(() => {
+        assert.isFalse(editedStub.called);
+        // Text changes sould be discarded.
+        assert.equal(input.value, 'value text');
+        assert.isFalse(element.editing);
+        done();
+      });
+
+      // Press escape:
+      MockInteractions.tap(element.$.cancelBtn);
+    });
+  });
+
+  suite('gr-editable-label read-only tests', () => {
+    let element;
+    let label;
+
+    setup(() => {
+      element = fixture('read-only');
+      label = element.$$('label');
+    });
+
+    test('disallows edit when read-only', () => {
+      // The dropdown is closed.
+      assert.isFalse(element.$.dropdown.opened);
+      MockInteractions.tap(label);
+
+      Polymer.dom.flush();
+
+      // The dropdown is still closed.
+      assert.isFalse(element.$.dropdown.opened);
+    });
+
+    test('label is not marked as editable', () => {
       assert.isFalse(label.classList.contains('editable'));
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
new file mode 100644
index 0000000..a1c80ae
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.html
@@ -0,0 +1,50 @@
+<!--
+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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-fixed-panel">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        min-height: var(--header-height);
+        position: relative;
+      }
+      header {
+        background: inherit;
+        border: inherit;
+        display: inline;
+        height: inherit;
+      }
+      .floating {
+        left: 0;
+        position: fixed;
+        width: 100%;
+        will-change: top;
+      }
+      .fixedAtTop {
+        border-bottom: 1px solid #a4a4a4;
+        box-shadow: 0 4px 4px rgba(0,0,0,0.1);
+      }
+    </style>
+    <header id="header" class$="[[_computeHeaderClass(_headerFloating, _topLast)]]">
+      <content></content>
+    </header>
+  </template>
+  <script src="gr-fixed-panel.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
new file mode 100644
index 0000000..f4f1a93
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel.js
@@ -0,0 +1,194 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-fixed-panel',
+
+    properties: {
+      floatingDisabled: Boolean,
+      readyForMeasure: {
+        type: Boolean,
+        observer: '_readyForMeasureObserver',
+      },
+      keepOnScroll: {
+        type: Boolean,
+        value: false,
+      },
+      _isMeasured: {
+        type: Boolean,
+        value: false,
+      },
+
+      /**
+       * Initial offset from the top of the document, in pixels.
+       */
+      _topInitial: Number,
+
+      /**
+       * Current offset from the top of the window, in pixels.
+       */
+      _topLast: Number,
+
+      _headerHeight: Number,
+      _headerFloating: {
+        type: Boolean,
+        value: false,
+      },
+      _observer: {
+        type: Object,
+        value: null,
+      },
+      _webComponentsReady: Boolean,
+    },
+
+    attached() {
+      if (this.floatingDisabled) {
+        return;
+      }
+      // Enable content measure unless blocked by param.
+      if (this.readyForMeasure !== false) {
+        this.readyForMeasure = true;
+      }
+      this.listen(window, 'resize', 'update');
+      this.listen(window, 'scroll', '_updateOnScroll');
+      this._observer = new MutationObserver(this.update.bind(this));
+      this._observer.observe(this.$.header, {childList: true, subtree: true});
+    },
+
+    detached() {
+      this.unlisten(window, 'scroll', '_updateOnScroll');
+      this.unlisten(window, 'resize', 'update');
+      if (this._observer) {
+        this._observer.disconnect();
+      }
+    },
+
+    _readyForMeasureObserver(readyForMeasure) {
+      if (readyForMeasure) {
+        this.update();
+      }
+    },
+
+    _computeHeaderClass(headerFloating, topLast) {
+      const fixedAtTop = this.keepOnScroll && topLast === 0;
+      return [
+        headerFloating ? 'floating' : '',
+        fixedAtTop ? 'fixedAtTop' : '',
+      ].join(' ');
+    },
+
+    _getScrollY() {
+      return window.scrollY;
+    },
+
+    unfloat() {
+      if (this.floatingDisabled) {
+        return;
+      }
+      this.$.header.style.top = '';
+      this._headerFloating = false;
+      this.updateStyles({'--header-height': ''});
+    },
+
+    update() {
+      this.debounce('update', () => {
+        this._updateDebounced();
+      }, 100);
+    },
+
+    _updateOnScroll() {
+      this.debounce('update', () => {
+        this._updateDebounced();
+      });
+    },
+
+    _updateDebounced() {
+      if (this.floatingDisabled) {
+        return;
+      }
+      this._isMeasured = false;
+      this._maybeFloatHeader();
+      this._reposition();
+    },
+
+    _reposition() {
+      if (!this._headerFloating) {
+        return;
+      }
+      const header = this.$.header;
+      const scrollY = this._topInitial - this._getScrollY();
+      let newTop;
+      if (this.keepOnScroll) {
+        if (scrollY > 0) {
+          // Reposition to imitate natural scrolling.
+          newTop = scrollY;
+        } else {
+          newTop = 0;
+        }
+      } else if (scrollY > -this._headerHeight ||
+          this._topLast < -this._headerHeight) {
+        // Allow to scroll away, but ignore when far behind the edge.
+        newTop = scrollY;
+      } else {
+        newTop = -this._headerHeight;
+      }
+      if (this._topLast !== newTop) {
+        if (newTop === undefined) {
+          header.style.top = '';
+        } else {
+          header.style.top = newTop + 'px';
+        }
+        this._topLast = newTop;
+      }
+    },
+
+    _measure() {
+      if (this._isMeasured) {
+        return; // Already measured.
+      }
+      const rect = this.$.header.getBoundingClientRect();
+      if (rect.height === 0 && rect.width === 0) {
+        return; // Not ready for measurement yet.
+      }
+      const top = document.body.scrollTop + rect.top;
+      this._topLast = top;
+      this._headerHeight = rect.height;
+      this._topInitial =
+        this.getBoundingClientRect().top + document.body.scrollTop;
+      this._isMeasured = true;
+    },
+
+    _isFloatingNeeded() {
+      return this.keepOnScroll ||
+        document.body.scrollWidth > document.body.clientWidth;
+    },
+
+    _maybeFloatHeader() {
+      if (!this._isFloatingNeeded()) {
+        return;
+      }
+      this._measure();
+      if (this._isMeasured) {
+        this._floatHeader();
+      }
+    },
+
+    _floatHeader() {
+      this.updateStyles({'--header-height': this._headerHeight + 'px'});
+      this._headerFloating = true;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
new file mode 100644
index 0000000..408b7c9
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-fixed-panel/gr-fixed-panel_test.html
@@ -0,0 +1,111 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-fixed-panel</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-fixed-panel.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-fixed-panel>
+      <div style="height: 100px"></div>
+    </gr-fixed-panel>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-fixed-panel', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      element.readyForMeasure = true;
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('can be disabled with floatingDisabled', () => {
+      element.floatingDisabled = true;
+      sandbox.stub(element, '_reposition');
+      window.dispatchEvent(new CustomEvent('resize'));
+      element.flushDebouncer('update');
+      assert.isFalse(element._reposition.called);
+    });
+
+    test('header is the height of the content', () => {
+      assert.equal(element.getBoundingClientRect().height, 100);
+    });
+
+    test('scroll triggers _reposition', () => {
+      sandbox.stub(element, '_reposition');
+      window.dispatchEvent(new CustomEvent('scroll'));
+      element.flushDebouncer('update');
+      assert.isTrue(element._reposition.called);
+    });
+
+    suite('_reposition', () => {
+      const getHeaderTop = function() {
+        return element.$.header.style.top;
+      };
+
+      const emulateScrollY = function(distance) {
+        element._getScrollY.returns(distance);
+        element._updateDebounced();
+        element.flushDebouncer('scroll');
+      };
+
+      setup(() => {
+        element._headerTopInitial = 10;
+        sandbox.stub(element, '_getScrollY').returns(0);
+      });
+
+      test('scrolls header along with document', () => {
+        emulateScrollY(20);
+        assert.equal(getHeaderTop(), '-12px');
+      });
+
+      test('does not stick to the top by default', () => {
+        emulateScrollY(150);
+        assert.equal(getHeaderTop(), '-100px');
+      });
+
+      test('sticks to the top if enabled', () => {
+        element.keepOnScroll = true;
+        emulateScrollY(120);
+        assert.equal(getHeaderTop(), '0px');
+      });
+
+      test('drops a shadow when fixed to the top', () => {
+        element.keepOnScroll = true;
+        emulateScrollY(5);
+        assert.isFalse(element.$.header.classList.contains('fixedAtTop'));
+        emulateScrollY(120);
+        assert.isTrue(element.$.header.classList.contains('fixedAtTop'));
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
index d719f70..1ef5a0c 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.html
@@ -15,10 +15,11 @@
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../gr-linked-text/gr-linked-text.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-formatted-text">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
         font-family: var(--font-family);
@@ -27,12 +28,17 @@
       ul,
       blockquote,
       gr-linked-text.pre {
-        margin: 0 0 1.4em 0;
+        margin: 0 0 .8em 0;
       }
-      :host.noTrailingMargin p:last-child,
-      :host.noTrailingMargin ul:last-child,
-      :host.noTrailingMargin blockquote:last-child,
-      :host.noTrailingMargin gr-linked-text.pre:last-child {
+      p,
+      ul,
+      blockquote {
+        max-width: var(--gr-formatted-text-prose-max-width, none);
+      }
+      :host(.noTrailingMargin) p:last-child,
+      :host(.noTrailingMargin) ul:last-child,
+      :host(.noTrailingMargin) blockquote:last-child,
+      :host(.noTrailingMargin) gr-linked-text.pre:last-child {
         margin: 0;
       }
       blockquote {
@@ -40,6 +46,7 @@
         padding: 0 .7em;
       }
       li {
+        list-style-type: disc;
         margin-left: 1.4em;
       }
       gr-linked-text.pre {
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
index a2130b2..d2392e1 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text.js
@@ -14,7 +14,8 @@
 (function() {
   'use strict';
 
-  var QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
+  // eslint-disable-next-line no-unused-vars
+  const QUOTE_MARKER_PATTERN = /\n\s?>\s/g;
 
   Polymer({
     is: 'gr-formatted-text',
@@ -35,7 +36,7 @@
       '_contentOrConfigChanged(content, config)',
     ],
 
-    ready: function() {
+    ready() {
       if (this.noTrailingMargin) {
         this.classList.add('noTrailingMargin');
       }
@@ -50,23 +51,23 @@
      *
      * @return {string}
      */
-    getTextContent: function() {
+    getTextContent() {
       return this._blocksToText(this._computeBlocks(this.content));
     },
 
-    _contentChanged: function(content) {
+    _contentChanged(content) {
       // In the case where the config may not be set (perhaps due to the
       // request for it still being in flight), set the content anyway to
       // prevent waiting on the config to display the text.
       if (this.config) { return; }
-      this.$.container.textContent = content;
+      this._contentOrConfigChanged(content);
     },
 
     /**
      * Given a source string, update the DOM inside #container.
      */
-    _contentOrConfigChanged: function(content) {
-      var container = Polymer.dom(this.$.container);
+    _contentOrConfigChanged(content) {
+      const container = Polymer.dom(this.$.container);
 
       // Remove existing content.
       while (container.firstChild) {
@@ -74,10 +75,9 @@
       }
 
       // Add new content.
-      this._computeNodes(this._computeBlocks(content))
-          .forEach(function(node) {
+      for (const node of this._computeNodes(this._computeBlocks(content))) {
         container.appendChild(node);
-      });
+      }
     },
 
     /**
@@ -102,14 +102,14 @@
      * @param {string} content
      * @return {!Array<!Object>}
      */
-    _computeBlocks: function(content) {
+    _computeBlocks(content) {
       if (!content) { return []; }
 
-      var result = [];
-      var split = content.split('\n\n');
-      var p;
+      const result = [];
+      const split = content.split('\n\n');
+      let p;
 
-      for (var i = 0; i < split.length; i++) {
+      for (let i = 0; i < split.length; i++) {
         p = split[i];
         if (!p.length) { continue; }
 
@@ -153,14 +153,14 @@
      *   potential paragraph).
      * @param {!Array<!Object>} out The list of blocks to append to.
      */
-    _makeList: function(p, out) {
-      var block = null;
-      var inList = false;
-      var inParagraph = false;
-      var lines = p.split('\n');
-      var line;
+    _makeList(p, out) {
+      let block = null;
+      let inList = false;
+      let inParagraph = false;
+      const lines = p.split('\n');
+      let line;
 
-      for (var i = 0; i < lines.length; i++) {
+      for (let i = 0; i < lines.length; i++) {
         line = lines[i];
 
         if (line[0] === '-' || line[0] === '*') {
@@ -171,7 +171,9 @@
             if (inParagraph) {
               // Add the finished paragraph block to the result.
               inParagraph = false;
-              out.push(block);
+              if (block !== null) {
+                out.push(block);
+              }
             }
             inList = true;
             block = {type: 'list', items: []};
@@ -193,15 +195,15 @@
         }
         block.items.push(line);
       }
-      if (block != null) {
+      if (block !== null) {
         out.push(block);
       }
     },
 
-    _makeQuote: function(p) {
-      var quotedLines = p
+    _makeQuote(p) {
+      const quotedLines = p
           .split('\n')
-          .map(function(l) { return l.replace(/^[ ]?>[ ]?/, ''); })
+          .map(l => l.replace(/^[ ]?>[ ]?/, ''))
           .join('\n');
       return {
         type: 'quote',
@@ -209,26 +211,30 @@
       };
     },
 
-    _isQuote: function(p) {
-      return p.indexOf('> ') === 0 || p.indexOf(' > ') === 0;
+    _isQuote(p) {
+      return p.startsWith('> ') || p.startsWith(' > ');
     },
 
-    _isPreFormat: function(p) {
-      return p.indexOf('\n ') !== -1 || p.indexOf('\n\t') !== -1 ||
-          p.indexOf(' ') === 0 || p.indexOf('\t') === 0;
+    _isPreFormat(p) {
+      return p.includes('\n ') || p.includes('\n\t') ||
+          p.startsWith(' ') || p.startsWith('\t');
     },
 
-    _isList: function(p) {
-      return p.indexOf('\n- ') !== -1 || p.indexOf('\n* ') !== -1 ||
-          p.indexOf('- ') === 0 || p.indexOf('* ') === 0;
+    _isList(p) {
+      return p.includes('\n- ') || p.includes('\n* ') ||
+          p.startsWith('- ') || p.startsWith('* ');
     },
 
-    _makeLinkedText: function(content, isPre) {
-      var text = document.createElement('gr-linked-text');
+    /**
+     * @param {string} content
+     * @param {boolean=} opt_isPre
+     */
+    _makeLinkedText(content, opt_isPre) {
+      const text = document.createElement('gr-linked-text');
       text.config = this.config;
       text.content = content;
       text.pre = true;
-      if (isPre) {
+      if (opt_isPre) {
         text.classList.add('pre');
       }
       return text;
@@ -239,19 +245,19 @@
      * @param  {!Array<!Object>} blocks
      * @return {!Array<!HTMLElement>}
      */
-    _computeNodes: function(blocks) {
-      return blocks.map(function(block) {
+    _computeNodes(blocks) {
+      return blocks.map(block => {
         if (block.type === 'paragraph') {
-          var p = document.createElement('p');
+          const p = document.createElement('p');
           p.appendChild(this._makeLinkedText(block.text));
           return p;
         }
 
         if (block.type === 'quote') {
-          var bq = document.createElement('blockquote');
-          this._computeNodes(block.blocks).forEach(function(node) {
+          const bq = document.createElement('blockquote');
+          for (const node of this._computeNodes(block.blocks)) {
             bq.appendChild(node);
-          });
+          }
           return bq;
         }
 
@@ -260,19 +266,19 @@
         }
 
         if (block.type === 'list') {
-          var ul = document.createElement('ul');
-          block.items.forEach(function(item) {
-            var li = document.createElement('li');
+          const ul = document.createElement('ul');
+          for (const item of block.items) {
+            const li = document.createElement('li');
             li.appendChild(this._makeLinkedText(item));
             ul.appendChild(li);
-          }.bind(this));
+          }
           return ul;
         }
-      }.bind(this));
+      });
     },
 
-    _blocksToText: function(blocks) {
-      return blocks.map(function(block) {
+    _blocksToText(blocks) {
+      return blocks.map(block => {
         if (block.type === 'paragraph' || block.type === 'pre') {
           return block.text;
         }
@@ -282,7 +288,7 @@
         if (block.type === 'list') {
           return block.items.join('\n');
         }
-      }.bind(this)).join('\n\n');
+      }).join('\n\n');
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
index 5afe60a..b1c536f 100644
--- a/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-formatted-text/gr-formatted-text_test.html
@@ -20,7 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-formatted-text.html">
 
 <script>void(0);</script>
@@ -32,9 +32,9 @@
 </test-fixture>
 
 <script>
-  suite('gr-formatted-text tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-formatted-text tests', () => {
+    let element;
+    let sandbox;
 
     function assertBlock(result, index, type, text) {
       assert.equal(result[index].type, type);
@@ -46,73 +46,73 @@
       assert.equal(result[resultIndex].items[itemIndex], text);
     }
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('parse null undefined and empty', function() {
+    test('parse null undefined and empty', () => {
       assert.lengthOf(element._computeBlocks(null), 0);
       assert.lengthOf(element._computeBlocks(undefined), 0);
       assert.lengthOf(element._computeBlocks(''), 0);
     });
 
-    test('parse simple', function() {
-      var comment = 'Para1';
-      var result = element._computeBlocks(comment);
+    test('parse simple', () => {
+      const comment = 'Para1';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'paragraph', comment);
     });
 
-    test('parse multiline para', function() {
-      var comment = 'Para 1\nStill para 1';
-      var result = element._computeBlocks(comment);
+    test('parse multiline para', () => {
+      const comment = 'Para 1\nStill para 1';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'paragraph', comment);
     });
 
-    test('parse para break', function() {
-      var comment = 'Para 1\n\nPara 2\n\nPara 3';
-      var result = element._computeBlocks(comment);
+    test('parse para break', () => {
+      const comment = 'Para 1\n\nPara 2\n\nPara 3';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'Para 1');
       assertBlock(result, 1, 'paragraph', 'Para 2');
       assertBlock(result, 2, 'paragraph', 'Para 3');
     });
 
-    test('parse quote', function() {
-      var comment = '> Quote text';
-      var result = element._computeBlocks(comment);
+    test('parse quote', () => {
+      const comment = '> Quote text';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assert.equal(result[0].type, 'quote');
       assert.lengthOf(result[0].blocks, 1);
       assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
     });
 
-    test('parse quote lead space', function() {
-      var comment = ' > Quote text';
-      var result = element._computeBlocks(comment);
+    test('parse quote lead space', () => {
+      const comment = ' > Quote text';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assert.equal(result[0].type, 'quote');
       assert.lengthOf(result[0].blocks, 1);
       assertBlock(result[0].blocks, 0, 'paragraph', 'Quote text');
     });
 
-    test('parse excludes empty', function() {
-      var comment = 'Para 1\n\n\n\nPara 2';
-      var result = element._computeBlocks(comment);
+    test('parse excludes empty', () => {
+      const comment = 'Para 1\n\n\n\nPara 2';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'Para 1');
       assertBlock(result, 1, 'paragraph', 'Para 2');
     });
 
-    test('parse multiline quote', function() {
-      var comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
-      var result = element._computeBlocks(comment);
+    test('parse multiline quote', () => {
+      const comment = '> Quote line 1\n> Quote line 2\n > Quote line 3\n';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assert.equal(result[0].type, 'quote');
       assert.lengthOf(result[0].blocks, 1);
@@ -120,55 +120,55 @@
           'Quote line 1\nQuote line 2\nQuote line 3\n');
     });
 
-    test('parse pre', function() {
-      var comment = '    Four space indent.';
-      var result = element._computeBlocks(comment);
+    test('parse pre', () => {
+      const comment = '    Four space indent.';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'pre', comment);
     });
 
-    test('parse one space pre', function() {
-      var comment = ' One space indent.\n Another line.';
-      var result = element._computeBlocks(comment);
+    test('parse one space pre', () => {
+      const comment = ' One space indent.\n Another line.';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'pre', comment);
     });
 
-    test('parse tab pre', function() {
-      var comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
-      var result = element._computeBlocks(comment);
+    test('parse tab pre', () => {
+      const comment = '\tOne tab indent.\n\tAnother line.\n  Yet another!';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'pre', comment);
     });
 
-    test('parse intermediate leading whitespace pre', function() {
-      var comment = 'No indent.\n\tNonzero indent.\nNo indent again.';
-      var result = element._computeBlocks(comment);
+    test('parse intermediate leading whitespace pre', () => {
+      const comment = 'No indent.\n\tNonzero indent.\nNo indent again.';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertBlock(result, 0, 'pre', comment);
     });
 
-    test('parse star list', function() {
-      var comment = '* Item 1\n* Item 2\n* Item 3';
-      var result = element._computeBlocks(comment);
+    test('parse star list', () => {
+      const comment = '* Item 1\n* Item 2\n* Item 3';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertListBlock(result, 0, 0, 'Item 1');
       assertListBlock(result, 0, 1, 'Item 2');
       assertListBlock(result, 0, 2, 'Item 3');
     });
 
-    test('parse dash list', function() {
-      var comment = '- Item 1\n- Item 2\n- Item 3';
-      var result = element._computeBlocks(comment);
+    test('parse dash list', () => {
+      const comment = '- Item 1\n- Item 2\n- Item 3';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertListBlock(result, 0, 0, 'Item 1');
       assertListBlock(result, 0, 1, 'Item 2');
       assertListBlock(result, 0, 2, 'Item 3');
     });
 
-    test('parse mixed list', function() {
-      var comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
-      var result = element._computeBlocks(comment);
+    test('parse mixed list', () => {
+      const comment = '- Item 1\n* Item 2\n- Item 3\n* Item 4';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assertListBlock(result, 0, 0, 'Item 1');
       assertListBlock(result, 0, 1, 'Item 2');
@@ -176,8 +176,8 @@
       assertListBlock(result, 0, 3, 'Item 4');
     });
 
-    test('parse mixed block types', function() {
-      var comment = 'Paragraph\nacross\na\nfew\nlines.' +
+    test('parse mixed block types', () => {
+      const comment = 'Paragraph\nacross\na\nfew\nlines.' +
           '\n\n' +
           '> Quote\n> across\n> not many lines.' +
           '\n\n' +
@@ -190,7 +190,7 @@
           '\tPreformatted text.' +
           '\n\n' +
           'Parting words.';
-      var result = element._computeBlocks(comment);
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 7);
       assertBlock(result, 0, 'paragraph', 'Paragraph\nacross\na\nfew\nlines.');
 
@@ -209,18 +209,18 @@
       assertBlock(result, 6, 'paragraph', 'Parting words.');
     });
 
-    test('bullet list 1', function() {
-      var comment = 'A\n\n* line 1\n* 2nd line';
-      var result = element._computeBlocks(comment);
+    test('bullet list 1', () => {
+      const comment = 'A\n\n* line 1\n* 2nd line';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'A');
       assertListBlock(result, 1, 0, 'line 1');
       assertListBlock(result, 1, 1, '2nd line');
     });
 
-    test('bullet list 2', function() {
-      var comment = 'A\n\n* line 1\n* 2nd line\n\nB';
-      var result = element._computeBlocks(comment);
+    test('bullet list 2', () => {
+      const comment = 'A\n\n* line 1\n* 2nd line\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'A');
       assertListBlock(result, 1, 0, 'line 1');
@@ -228,50 +228,50 @@
       assertBlock(result, 2, 'paragraph', 'B');
     });
 
-    test('bullet list 3', function() {
-      var comment = '* line 1\n* 2nd line\n\nB';
-      var result = element._computeBlocks(comment);
+    test('bullet list 3', () => {
+      const comment = '* line 1\n* 2nd line\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertListBlock(result, 0, 0, 'line 1');
       assertListBlock(result, 0, 1, '2nd line');
       assertBlock(result, 1, 'paragraph', 'B');
     });
 
-    test('bullet list 4', function() {
-      var comment = 'To see this bug, you have to:\n' +
+    test('bullet list 4', () => {
+      const comment = 'To see this bug, you have to:\n' +
           '* Be on IMAP or EAS (not on POP)\n' +
           '* Be very unlucky\n';
-      var result = element._computeBlocks(comment);
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
       assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
       assertListBlock(result, 1, 1, 'Be very unlucky');
     });
 
-    test('bullet list 5', function() {
-      var comment = 'To see this bug,\n' +
+    test('bullet list 5', () => {
+      const comment = 'To see this bug,\n' +
           'you have to:\n' +
           '* Be on IMAP or EAS (not on POP)\n' +
           '* Be very unlucky\n';
-      var result = element._computeBlocks(comment);
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'To see this bug, you have to:');
       assertListBlock(result, 1, 0, 'Be on IMAP or EAS (not on POP)');
       assertListBlock(result, 1, 1, 'Be very unlucky');
     });
 
-    test('dash list 1', function() {
-      var comment = 'A\n\n- line 1\n- 2nd line';
-      var result = element._computeBlocks(comment);
+    test('dash list 1', () => {
+      const comment = 'A\n\n- line 1\n- 2nd line';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'A');
       assertListBlock(result, 1, 0, 'line 1');
       assertListBlock(result, 1, 1, '2nd line');
     });
 
-    test('dash list 2', function() {
-      var comment = 'A\n\n- line 1\n- 2nd line\n\nB';
-      var result = element._computeBlocks(comment);
+    test('dash list 2', () => {
+      const comment = 'A\n\n- line 1\n- 2nd line\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'A');
       assertListBlock(result, 1, 0, 'line 1');
@@ -279,52 +279,52 @@
       assertBlock(result, 2, 'paragraph', 'B');
     });
 
-    test('dash list 3', function() {
-      var comment = '- line 1\n- 2nd line\n\nB';
-      var result = element._computeBlocks(comment);
+    test('dash list 3', () => {
+      const comment = '- line 1\n- 2nd line\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertListBlock(result, 0, 0, 'line 1');
       assertListBlock(result, 0, 1, '2nd line');
       assertBlock(result, 1, 'paragraph', 'B');
     });
 
-    test('pre format 1', function() {
-      var comment = 'A\n\n  This is pre\n  formatted';
-      var result = element._computeBlocks(comment);
+    test('pre format 1', () => {
+      const comment = 'A\n\n  This is pre\n  formatted';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'paragraph', 'A');
       assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
     });
 
-    test('pre format 2', function() {
-      var comment = 'A\n\n  This is pre\n  formatted\n\nbut this is not';
-      var result = element._computeBlocks(comment);
+    test('pre format 2', () => {
+      const comment = 'A\n\n  This is pre\n  formatted\n\nbut this is not';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'A');
       assertBlock(result, 1, 'pre', '  This is pre\n  formatted');
       assertBlock(result, 2, 'paragraph', 'but this is not');
     });
 
-    test('pre format 3', function() {
-      var comment = 'A\n\n  Q\n    <R>\n  S\n\nB';
-      var result = element._computeBlocks(comment);
+    test('pre format 3', () => {
+      const comment = 'A\n\n  Q\n    <R>\n  S\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'A');
       assertBlock(result, 1, 'pre', '  Q\n    <R>\n  S');
       assertBlock(result, 2, 'paragraph', 'B');
     });
 
-    test('pre format 4', function() {
-      var comment = '  Q\n    <R>\n  S\n\nB';
-      var result = element._computeBlocks(comment);
+    test('pre format 4', () => {
+      const comment = '  Q\n    <R>\n  S\n\nB';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assertBlock(result, 0, 'pre', '  Q\n    <R>\n  S');
       assertBlock(result, 1, 'paragraph', 'B');
     });
 
-    test('quote 1', function() {
-      var comment = '> I\'m happy\n > with quotes!\n\nSee above.';
-      var result = element._computeBlocks(comment);
+    test('quote 1', () => {
+      const comment = '> I\'m happy\n > with quotes!\n\nSee above.';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 2);
       assert.equal(result[0].type, 'quote');
       assert.lengthOf(result[0].blocks, 1);
@@ -332,9 +332,9 @@
       assertBlock(result, 1, 'paragraph', 'See above.');
     });
 
-    test('quote 2', function() {
-      var comment = 'See this said:\n\n > a quoted\n > string block\n\nOK?';
-      var result = element._computeBlocks(comment);
+    test('quote 2', () => {
+      const comment = 'See this said:\n\n > a quoted\n > string block\n\nOK?';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 3);
       assertBlock(result, 0, 'paragraph', 'See this said:');
       assert.equal(result[1].type, 'quote');
@@ -343,9 +343,9 @@
       assertBlock(result, 2, 'paragraph', 'OK?');
     });
 
-    test('nested quotes', function() {
-      var comment = ' > > prior\n > \n > next\n';
-      var result = element._computeBlocks(comment);
+    test('nested quotes', () => {
+      const comment = ' > > prior\n > \n > next\n';
+      const result = element._computeBlocks(comment);
       assert.lengthOf(result, 1);
       assert.equal(result[0].type, 'quote');
       assert.lengthOf(result[0].blocks, 2);
@@ -355,25 +355,23 @@
       assertBlock(result[0].blocks, 1, 'paragraph', 'next\n');
     });
 
-    test('getTextContent', function() {
-      var comment = 'Paragraph\n\n  pre\n\n* List\n* Of\n* Items\n\n> Quote';
+    test('getTextContent', () => {
+      const comment = 'Paragraph\n\n  pre\n\n* List\n* Of\n* Items\n\n> Quote';
       element.content = comment;
-      var result = element.getTextContent();
-      var expected = 'Paragraph\n\n  pre\n\nList\nOf\nItems\n\nQuote';
+      const result = element.getTextContent();
+      const expected = 'Paragraph\n\n  pre\n\nList\nOf\nItems\n\nQuote';
       assert.equal(result, expected);
     });
 
-    test('_contentOrConfigChanged not called without config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_computeNodes called without config', () => {
+      const computeNodesSpy = sandbox.spy(element, '_computeNodes');
       element.content = 'some text';
-      assert.isTrue(contentStub.called);
-      assert.isFalse(contentConfigStub.called);
+      assert.isTrue(computeNodesSpy.called);
     });
 
-    test('_contentOrConfigChanged called with config', function() {
-      var contentStub = sandbox.stub(element, '_contentChanged');
-      var contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
+    test('_contentOrConfigChanged called with config', () => {
+      const contentStub = sandbox.stub(element, '_contentChanged');
+      const contentConfigStub = sandbox.stub(element, '_contentOrConfigChanged');
       element.content = 'some text';
       element.config = {};
       assert.isTrue(contentStub.called);
diff --git a/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
new file mode 100644
index 0000000..4e3e045
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-icons/gr-icons.html
@@ -0,0 +1,32 @@
+<!--
+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.
+-->
+<link rel="import" href="../../../bower_components/iron-icon/iron-icon.html">
+<link rel="import" href="../../../bower_components/iron-iconset-svg/iron-iconset-svg.html">
+
+<iron-iconset-svg name="gr-icons" size="24">
+  <svg>
+    <defs>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <g id="search"><path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></g>
+      <!-- This SVG is a copy from iron-icons https://github.com/PolymerElements/iron-icons/blob/master/iron-icons.html -->
+      <g id="settings"><path d="M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.3-.61-.22l-2.49 1c-.52-.4-1.08-.73-1.69-.98l-.38-2.65C14.46 2.18 14.25 2 14 2h-4c-.25 0-.46.18-.49.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1c-.23-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65c-.19.15-.24.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65c.03.24.24.42.49.42h4c.25 0 .46-.18.49-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1c.23.09.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.65zM12 15.5c-1.93 0-3.5-1.57-3.5-3.5s1.57-3.5 3.5-3.5 3.5 1.57 3.5 3.5-1.57 3.5-3.5 3.5z"/></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="side-by-side"><path d="M17.1578947,10.8888889 L2.84210526,10.8888889 C2.37894737,10.8888889 2,11.2888889 2,11.7777778 L2,17.1111111 C2,17.6 2.37894737,18 2.84210526,18 L17.1578947,18 C17.6210526,18 18,17.6 18,17.1111111 L18,11.7777778 C18,11.2888889 17.6210526,10.8888889 17.1578947,10.8888889 Z M17.1578947,2 L2.84210526,2 C2.37894737,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.37894737,9.11111111 2.84210526,9.11111111 L17.1578947,9.11111111 C17.6210526,9.11111111 18,8.71111111 18,8.22222222 L18,2.88888889 C18,2.4 17.6210526,2 17.1578947,2 Z M16.1973628,2 L2.78874238,2 C2.35493407,2 2,2.4 2,2.88888889 L2,8.22222222 C2,8.71111111 2.35493407,9.11111111 2.78874238,9.11111111 L16.1973628,9.11111111 C16.6311711,9.11111111 16.9861052,8.71111111 16.9861052,8.22222222 L16.9861052,2.88888889 C16.9861052,2.4 16.6311711,2 16.1973628,2 Z" id="Shape" transform="scale(1.2) translate(10.000000, 10.000000) rotate(-90.000000) translate(-10.000000, -10.000000)"/></g>
+      <!-- This is a custom PolyGerrit SVG -->
+      <g id="unified"><path d="M4,2 L17,2 C18.1045695,2 19,2.8954305 19,4 L19,16 C19,17.1045695 18.1045695,18 17,18 L4,18 C2.8954305,18 2,17.1045695 2,16 L2,4 L2,4 C2,2.8954305 2.8954305,2 4,2 L4,2 Z M4,7 L4,9 L17,9 L17,7 L4,7 Z M4,11 L4,13 L17,13 L17,11 L4,11 Z" id="Combined-Shape" transform="scale(1.2)"/></g>
+    </defs>
+  </svg>
+</iron-iconset-svg>
\ No newline at end of file
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
index 72c7f6e..6853a41 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -14,7 +14,8 @@
 (function(window) {
   'use strict';
 
-  function GrChangeActionsInterface(el) {
+  function GrChangeActionsInterface(plugin, el) {
+    this.plugin = plugin;
     this._el = el;
     this.RevisionActions = el.RevisionActions;
     this.ChangeActions = el.ChangeActions;
@@ -22,17 +23,27 @@
   }
 
   GrChangeActionsInterface.prototype.addPrimaryActionKey = function(key) {
-    if (this._el.primaryActionKeys.indexOf(key) !== -1) { return; }
+    if (this._el.primaryActionKeys.includes(key)) { return; }
 
     this._el.push('primaryActionKeys', key);
   };
 
   GrChangeActionsInterface.prototype.removePrimaryActionKey = function(key) {
-    this._el.primaryActionKeys = this._el.primaryActionKeys.filter(function(k) {
+    this._el.primaryActionKeys = this._el.primaryActionKeys.filter(k => {
       return k !== key;
     });
   };
 
+  GrChangeActionsInterface.prototype.setActionOverflow = function(type, key,
+      overflow) {
+    return this._el.setActionOverflow(type, key, overflow);
+  };
+
+  GrChangeActionsInterface.prototype.setActionPriority = function(type, key,
+      priority) {
+    return this._el.setActionPriority(type, key, priority);
+  };
+
   GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
       hidden) {
     return this._el.setActionHidden(type, key, hidden);
@@ -63,5 +74,10 @@
     this._el.setActionButtonProp(key, 'enabled', enabled);
   };
 
+  GrChangeActionsInterface.prototype.getActionDetails = function(action) {
+    return this._el.getActionDetails(action) ||
+      this._el.getActionDetails(this.plugin.getPluginName() + '~' + action);
+  };
+
   window.GrChangeActionsInterface = GrChangeActionsInterface;
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index 20b5dcb..53d7345 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -20,8 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <!--
 This must refer to the element this interface is wrapping around. Otherwise
 breaking changes to gr-change-actions won’t be noticed.
@@ -37,43 +36,44 @@
 </test-fixture>
 
 <script>
-  suite('gr-js-api-interface tests', function() {
-    var element;
-    var changeActions;
+  suite('gr-js-api-interface tests', () => {
+    let element;
+    let changeActions;
 
     // Because deepEqual doesn’t behave in Safari.
     function assertArraysEqual(actual, expected) {
       assert.equal(actual.length, expected.length);
-      for (var i = 0; i < actual.length; i++) {
+      for (let i = 0; i < actual.length; i++) {
         assert.equal(actual[i], expected[i]);
       }
     }
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       element.change = {};
       element._hasKnownChainState = false;
-      var plugin;
-      Gerrit.install(function(p) { plugin = p; }, '0.1',
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       changeActions = plugin.changeActions();
     });
 
-    teardown(function() {
+    teardown(() => {
       changeActions = null;
     });
 
-    test('property existence', function() {
-      [
+    test('property existence', () => {
+      const properties = [
         'ActionType',
         'ChangeActions',
         'RevisionActions',
-      ].forEach(function(p) {
+      ];
+      for (const p of properties) {
         assertArraysEqual(changeActions[p], element[p]);
-      });
+      }
     });
 
-    test('add/remove primary action keys', function() {
+    test('add/remove primary action keys', () => {
       element.primaryActionKeys = [];
       changeActions.addPrimaryActionKey('foo');
       assertArraysEqual(element.primaryActionKeys, ['foo']);
@@ -89,34 +89,34 @@
       assertArraysEqual(element.primaryActionKeys, []);
     });
 
-    test('action buttons', function(done) {
-      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      var handler = sinon.spy();
+    test('action buttons', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const handler = sinon.spy();
       changeActions.addTapListener(key, handler);
-      flush(function() {
+      flush(() => {
         MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
         assert(handler.calledOnce);
         changeActions.removeTapListener(key, handler);
         MockInteractions.tap(element.$$('[data-action-key="' + key + '"]'));
         assert(handler.calledOnce);
         changeActions.remove(key);
-        flush(function() {
+        flush(() => {
           assert.isNull(element.$$('[data-action-key="' + key + '"]'));
           done();
         });
       });
     });
 
-    test('action button properties', function(done) {
-      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(function() {
-        var button = element.$$('[data-action-key="' + key + '"]');
+    test('action button properties', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        const button = element.$$('[data-action-key="' + key + '"]');
         assert.isOk(button);
         assert.equal(button.getAttribute('data-label'), 'Bork!');
         assert.isNotOk(button.disabled);
         changeActions.setLabel(key, 'Yo');
         changeActions.setEnabled(key, false);
-        flush(function() {
+        flush(() => {
           assert.equal(button.getAttribute('data-label'), 'Yo');
           assert.isTrue(button.disabled);
           done();
@@ -124,20 +124,58 @@
       });
     });
 
-    test('hide action buttons', function(done) {
-      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
-      flush(function() {
-        var button = element.$$('[data-action-key="' + key + '"]');
+    test('hide action buttons', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        const button = element.$$('[data-action-key="' + key + '"]');
         assert.isOk(button);
         assert.isFalse(button.hasAttribute('hidden'));
-        changeActions.setActionHidden(changeActions.ActionType.REVISION, key,
-            true);
-        flush(function() {
-          var button = element.$$('[data-action-key="' + key + '"]');
+        changeActions.setActionHidden(
+            changeActions.ActionType.REVISION, key, true);
+        flush(() => {
+          const button = element.$$('[data-action-key="' + key + '"]');
           assert.isNotOk(button);
           done();
         });
       });
     });
+
+    test('move action button to overflow', done => {
+      const key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(() => {
+        assert.isTrue(element.$.moreActions.hidden);
+        assert.isOk(element.$$('[data-action-key="' + key + '"]'));
+        changeActions.setActionOverflow(
+            changeActions.ActionType.REVISION, key, true);
+        flush(() => {
+          assert.isNotOk(element.$$('[data-action-key="' + key + '"]'));
+          assert.isFalse(element.$.moreActions.hidden);
+          assert.strictEqual(element.$.moreActions.items[0].name, 'Bork!');
+          done();
+        });
+      });
+    });
+
+    test('change actions priority', done => {
+      const key1 =
+          changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      const key2 =
+          changeActions.add(changeActions.ActionType.CHANGE, 'Squanch?');
+      flush(() => {
+        let buttons =
+            Polymer.dom(element.root).querySelectorAll('[data-action-key]');
+        assert.equal(buttons[0].getAttribute('data-action-key'), key1);
+        assert.equal(buttons[1].getAttribute('data-action-key'), key2);
+        changeActions.setActionPriority(
+            changeActions.ActionType.REVISION, key1, 10);
+        flush(() => {
+          buttons =
+              Polymer.dom(element.root).querySelectorAll('[data-action-key]');
+          assert.equal(buttons[0].getAttribute('data-action-key'), key2);
+          assert.equal(buttons[1].getAttribute('data-action-key'), key1);
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
index 9d6b83b..65aa364 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api.js
@@ -14,17 +14,48 @@
 (function(window) {
   'use strict';
 
-  function GrChangeReplyInterface(el) {
+  /**
+   * @deprecated
+   */
+  function GrChangeReplyInterfaceOld(el) {
     this._el = el;
   }
 
-  GrChangeReplyInterface.prototype.setLabelValue = function(label, value) {
+  GrChangeReplyInterfaceOld.prototype.getLabelValue = function(label) {
+    return this._el.getLabelValue(label);
+  };
+
+  GrChangeReplyInterfaceOld.prototype.setLabelValue = function(label, value) {
     this._el.setLabelValue(label, value);
   };
 
-  GrChangeReplyInterface.prototype.send = function() {
-    return this._el.send();
+  GrChangeReplyInterfaceOld.prototype.send = function(opt_includeComments) {
+    return this._el.send(opt_includeComments);
   };
 
+  function GrChangeReplyInterface(plugin, el) {
+    GrChangeReplyInterfaceOld.call(this, el);
+    this.plugin = plugin;
+    this._hookName = (plugin.getPluginName() || 'test') + '-autogenerated-'
+      + String(Math.random()).split('.')[1];
+  }
+  GrChangeReplyInterface.prototype._hookName = '';
+  GrChangeReplyInterface.prototype._hookClass = null;
+  GrChangeReplyInterface.prototype._hookPromise = null;
+
+  GrChangeReplyInterface.prototype =
+    Object.create(GrChangeReplyInterfaceOld.prototype);
+  GrChangeReplyInterface.prototype.constructor = GrChangeReplyInterface;
+
+  GrChangeReplyInterface.prototype.addReplyTextChangedCallback =
+    function(handler) {
+      this.plugin.hook('reply-text').onAttached(el => {
+        if (!el.content) { return; }
+        el.content.addEventListener('value-changed', e => {
+          handler(e.detail.value);
+        });
+      });
+    };
+
   window.GrChangeReplyInterface = GrChangeReplyInterface;
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
index d7d5cfe..2f67035 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-reply-js-api_test.html
@@ -20,12 +20,11 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <!--
-     This must refer to the element this interface is wrapping around. Otherwise
-     breaking changes to gr-reply-dialog won’t be noticed.
-   -->
+This must refer to the element this interface is wrapping around. Otherwise
+breaking changes to gr-reply-dialog won’t be noticed.
+-->
 <link rel="import" href="../../change/gr-reply-dialog/gr-reply-dialog.html">
 
 <script>void(0);</script>
@@ -37,37 +36,41 @@
 </test-fixture>
 
 <script>
-  suite('gr-change-reply-js-api tests', function() {
-    var element;
-    var sandbox;
-    var changeReply;
+  suite('gr-change-reply-js-api tests', () => {
+    let element;
+    let sandbox;
+    let changeReply;
 
-    setup(function() {
+    setup(() => {
       stub('gr-rest-api-interface', {
-        getConfig: function() { return Promise.resolve({}); },
-        getAccount: function() { return Promise.resolve(null); },
+        getConfig() { return Promise.resolve({}); },
+        getAccount() { return Promise.resolve(null); },
       });
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
-      var plugin;
-      Gerrit.install(function(p) { plugin = p; }, '0.1',
+      let plugin;
+      Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
       changeReply = plugin.changeReply();
     });
 
-    teardown(function() {
+    teardown(() => {
       changeReply = null;
       sandbox.restore();
     });
 
-    test('calls', function() {
-      var setLabelValueStub = sinon.stub(element, 'setLabelValue');
-      changeReply.setLabelValue('My-Label', '+1337');
-      assert(setLabelValueStub.calledWithExactly('My-Label', '+1337'));
+    test('calls', () => {
+      sandbox.stub(element, 'getLabelValue').returns('+123');
+      assert.equal(changeReply.getLabelValue('My-Label'), '+123');
 
-      var sendStub = sinon.stub(element, 'send');
-      changeReply.send();
-      assert(sendStub.calledWithExactly());
+      sandbox.stub(element, 'setLabelValue');
+      changeReply.setLabelValue('My-Label', '+1337');
+      assert.isTrue(
+          element.setLabelValue.calledWithExactly('My-Label', '+1337'));
+
+      sandbox.stub(element, 'send');
+      changeReply.send(false);
+      assert.isTrue(element.send.calledWithExactly(false));
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index 5c0535b..fda085a 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -14,13 +14,23 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../plugins/gr-attribute-helper/gr-attribute-helper.html">
+<link rel="import" href="../../plugins/gr-dom-hooks/gr-dom-hooks.html">
+<link rel="import" href="../../plugins/gr-event-helper/gr-event-helper.html">
+<link rel="import" href="../../plugins/gr-popup-interface/gr-popup-interface.html">
+<link rel="import" href="../../plugins/gr-theme-api/gr-theme-api.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-js-api-interface">
-  <template></template>
   <script src="gr-change-actions-js-api.js"></script>
   <script src="gr-change-reply-js-api.js"></script>
   <script src="gr-js-api-interface.js"></script>
+  <script src="gr-plugin-endpoints.js"></script>
+  <script src="gr-plugin-action-context.js"></script>
+  <script src="gr-plugin-rest-api.js"></script>
   <script src="gr-public-js-api.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
index 34ca728..420d4af 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -14,7 +14,7 @@
 (function() {
   'use strict';
 
-  var EventType = {
+  const EventType = {
     HISTORY: 'history',
     LABEL_CHANGE: 'labelchange',
     SHOW_CHANGE: 'showchange',
@@ -25,7 +25,7 @@
     POST_REVERT: 'postrevert',
   };
 
-  var Element = {
+  const Element = {
     CHANGE_ACTIONS: 'changeactions',
     REPLY_DIALOG: 'replydialog',
   };
@@ -36,19 +36,21 @@
     properties: {
       _elements: {
         type: Object,
-        value: {},  // Shared across all instances.
+        value: {}, // Shared across all instances.
       },
       _eventCallbacks: {
         type: Object,
-        value: {},  // Shared across all instances.
+        value: {}, // Shared across all instances.
       },
     },
 
-    Element: Element,
-    EventType: EventType,
+    behaviors: [Gerrit.PatchSetBehavior],
 
-    handleEvent: function(type, detail) {
-      Gerrit.awaitPluginsLoaded().then(function() {
+    Element,
+    EventType,
+
+    handleEvent(type, detail) {
+      Gerrit.awaitPluginsLoaded().then(() => {
         switch (type) {
           case EventType.HISTORY:
             this._handleHistory(detail);
@@ -67,27 +69,27 @@
                 type);
             break;
         }
-      }.bind(this));
+      });
     },
 
-    addElement: function(key, el) {
+    addElement(key, el) {
       this._elements[key] = el;
     },
 
-    getElement: function(key) {
+    getElement(key) {
       return this._elements[key];
     },
 
-    addEventCallback: function(eventName, callback) {
+    addEventCallback(eventName, callback) {
       if (!this._eventCallbacks[eventName]) {
         this._eventCallbacks[eventName] = [];
       }
       this._eventCallbacks[eventName].push(callback);
     },
 
-    canSubmitChange: function(change, revision) {
-      var submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
-      var cancelSubmit = submitCallbacks.some(function(callback) {
+    canSubmitChange(change, revision) {
+      const submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
+      const cancelSubmit = submitCallbacks.some(callback => {
         try {
           return callback(change, revision) === false;
         } catch (err) {
@@ -99,30 +101,31 @@
       return !cancelSubmit;
     },
 
-    _removeEventCallbacks: function() {
-      for (var k in EventType) {
+    _removeEventCallbacks() {
+      for (const k in EventType) {
+        if (!EventType.hasOwnProperty(k)) { continue; }
         this._eventCallbacks[EventType[k]] = [];
       }
     },
 
-    _handleHistory: function(detail) {
-      this._getEventCallbacks(EventType.HISTORY).forEach(function(cb) {
+    _handleHistory(detail) {
+      for (const cb of this._getEventCallbacks(EventType.HISTORY)) {
         try {
           cb(detail.path);
         } catch (err) {
           console.error(err);
         }
-      });
+      }
     },
 
-    _handleShowChange: function(detail) {
-      this._getEventCallbacks(EventType.SHOW_CHANGE).forEach(function(cb) {
-        var change = detail.change;
-        var patchNum = detail.patchNum;
-        var revision;
-        for (var rev in change.revisions) {
-          if (change.revisions[rev]._number == patchNum) {
-            revision = change.revisions[rev];
+    _handleShowChange(detail) {
+      for (const cb of this._getEventCallbacks(EventType.SHOW_CHANGE)) {
+        const change = detail.change;
+        const patchNum = detail.patchNum;
+        let revision;
+        for (const rev of Object.values(change.revisions || {})) {
+          if (this.patchNumEquals(rev._number, patchNum)) {
+            revision = rev;
             break;
           }
         }
@@ -131,67 +134,63 @@
         } catch (err) {
           console.error(err);
         }
-      });
+      }
     },
 
-    handleCommitMessage: function(change, msg) {
-      this._getEventCallbacks(EventType.COMMIT_MSG_EDIT).forEach(
-          function(cb) {
-            try {
-              cb(change, msg);
-            } catch (err) {
-              console.error(err);
-            }
-          }
-      );
+    handleCommitMessage(change, msg) {
+      for (const cb of this._getEventCallbacks(EventType.COMMIT_MSG_EDIT)) {
+        try {
+          cb(change, msg);
+        } catch (err) {
+          console.error(err);
+        }
+      }
     },
 
-    _handleComment: function(detail) {
-      this._getEventCallbacks(EventType.COMMENT).forEach(function(cb) {
+    _handleComment(detail) {
+      for (const cb of this._getEventCallbacks(EventType.COMMENT)) {
         try {
           cb(detail.node);
         } catch (err) {
           console.error(err);
         }
-      });
+      }
     },
 
-    _handleLabelChange: function(detail) {
-      this._getEventCallbacks(EventType.LABEL_CHANGE).forEach(function(cb) {
+    _handleLabelChange(detail) {
+      for (const cb of this._getEventCallbacks(EventType.LABEL_CHANGE)) {
         try {
           cb(detail.change);
         } catch (err) {
           console.error(err);
         }
-      });
+      }
     },
 
-    modifyRevertMsg: function(change, revertMsg, origMsg) {
-      this._getEventCallbacks(EventType.REVERT).forEach(function(callback) {
+    modifyRevertMsg(change, revertMsg, origMsg) {
+      for (const cb of this._getEventCallbacks(EventType.REVERT)) {
         try {
-          revertMsg = callback(change, revertMsg, origMsg);
+          revertMsg = cb(change, revertMsg, origMsg);
         } catch (err) {
           console.error(err);
         }
-      });
+      }
       return revertMsg;
     },
 
-    getLabelValuesPostRevert: function(change) {
-      var labels = {};
-      this._getEventCallbacks(EventType.POST_REVERT).forEach(
-          function(callback) {
-            try {
-              labels = callback(change);
-            } catch (err) {
-              console.error(err);
-            }
-          }
-      );
+    getLabelValuesPostRevert(change) {
+      let labels = {};
+      for (const cb of this._getEventCallbacks(EventType.POST_REVERT)) {
+        try {
+          labels = cb(change);
+        } catch (err) {
+          console.error(err);
+        }
+      }
       return labels;
     },
 
-    _getEventCallbacks: function(type) {
+    _getEventCallbacks(type) {
       return this._eventCallbacks[type] || [];
     },
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index d62bdd8..62fc1db 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -20,6 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-js-api-interface.html">
 
 <script>void(0);</script>
@@ -31,45 +32,140 @@
 </test-fixture>
 
 <script>
-  suite('gr-js-api-interface tests', function() {
-    var element;
-    var plugin;
-    var errorStub;
-    var sandbox;
+  suite('gr-js-api-interface tests', () => {
+    let element;
+    let plugin;
+    let errorStub;
+    let sandbox;
+    let getResponseObjectStub;
+    let sendStub;
 
-    var throwErrFn = function() {
+    const throwErrFn = function() {
       throw Error('Unfortunately, this handler has stopped');
     };
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
+      getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
+      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
       stub('gr-rest-api-interface', {
-        getAccount: function() {
+        getAccount() {
           return Promise.resolve({name: 'Judy Hopps'});
         },
+        getResponseObject: getResponseObjectStub,
+        send(...args) {
+          return sendStub(...args);
+        },
       });
       element = fixture('basic');
       errorStub = sandbox.stub(console, 'error');
       Gerrit._setPluginsCount(1);
-      Gerrit.install(function(p) { plugin = p; }, '0.1',
+      Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
       element._removeEventCallbacks();
       plugin = null;
     });
 
-    test('url', function() {
+    test('reuse plugin for install calls', () => {
+      let otherPlugin;
+      Gerrit.install(p => { otherPlugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      assert.strictEqual(plugin, otherPlugin);
+    });
+
+    test('url', () => {
       assert.equal(plugin.url(), 'http://test.com/plugins/testplugin/');
       assert.equal(plugin.url('/static/test.js'),
           'http://test.com/plugins/testplugin/static/test.js');
     });
 
-    test('history event', function(done) {
+    test('_send on failure rejects with response text', () => {
+      sendStub.returns(Promise.resolve(
+          {status: 400, text() { return Promise.resolve('text'); }}));
+      return plugin._send().catch(r => {
+        assert.equal(r, 'text');
+      });
+    });
+
+    test('_send on failure without text rejects with code', () => {
+      sendStub.returns(Promise.resolve(
+          {status: 400, text() { return Promise.resolve(null); }}));
+      return plugin._send().catch(r => {
+        assert.equal(r, '400');
+      });
+    });
+
+    test('get', () => {
+      const response = {foo: 'foo'};
+      getResponseObjectStub.returns(Promise.resolve(response));
+      return plugin.get('/url', r => {
+        assert.isTrue(sendStub.calledWith(
+            'GET', 'http://test.com/plugins/testplugin/url'));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('get using Promise', () => {
+      const response = {foo: 'foo'};
+      getResponseObjectStub.returns(Promise.resolve(response));
+      return plugin.get('/url', r => 'rubbish').then(r => {
+        assert.isTrue(sendStub.calledWith(
+            'GET', 'http://test.com/plugins/testplugin/url'));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('post', () => {
+      const payload = {foo: 'foo'};
+      const response = {bar: 'bar'};
+      getResponseObjectStub.returns(Promise.resolve(response));
+      return plugin.post('/url', payload, r => {
+        assert.isTrue(sendStub.calledWith(
+            'POST', 'http://test.com/plugins/testplugin/url', payload));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('put', () => {
+      const payload = {foo: 'foo'};
+      const response = {bar: 'bar'};
+      getResponseObjectStub.returns(Promise.resolve(response));
+      return plugin.put('/url', payload, r => {
+        assert.isTrue(sendStub.calledWith(
+            'PUT', 'http://test.com/plugins/testplugin/url', payload));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('delete works', () => {
+      const response = {status: 204};
+      sendStub.returns(Promise.resolve(response));
+      return plugin.delete('/url', r => {
+        assert.isTrue(sendStub.calledWithExactly(
+            'DELETE', 'http://test.com/plugins/testplugin/url'));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('delete fails', () => {
+      sendStub.returns(Promise.resolve(
+          {status: 400, text() { return Promise.resolve('text'); }}));
+      return plugin.delete('/url', r => {
+        throw new Error('Should not resolve');
+      }).catch(err => {
+        assert.isTrue(sendStub.calledWith(
+            'DELETE', 'http://test.com/plugins/testplugin/url'));
+        assert.equal('text', err);
+      });
+    });
+
+    test('history event', done => {
       plugin.on(element.EventType.HISTORY, throwErrFn);
-      plugin.on(element.EventType.HISTORY, function(path) {
+      plugin.on(element.EventType.HISTORY, path => {
         assert.equal(path, '/path/to/awesomesauce');
         assert.isTrue(errorStub.calledOnce);
         done();
@@ -78,13 +174,13 @@
           {path: '/path/to/awesomesauce'});
     });
 
-    test('showchange event', function(done) {
-      var testChange = {
+    test('showchange event', done => {
+      const testChange = {
         _number: 42,
         revisions: {def: {_number: 2}, abc: {_number: 1}},
       };
       plugin.on(element.EventType.SHOW_CHANGE, throwErrFn);
-      plugin.on(element.EventType.SHOW_CHANGE, function(change, revision) {
+      plugin.on(element.EventType.SHOW_CHANGE, (change, revision) => {
         assert.deepEqual(change, testChange);
         assert.deepEqual(revision, testChange.revisions.abc);
         assert.isTrue(errorStub.calledOnce);
@@ -94,28 +190,28 @@
           {change: testChange, patchNum: 1});
     });
 
-    test('handleEvent awaits plugins load', function(done) {
-      var testChange = {
+    test('handleEvent awaits plugins load', done => {
+      const testChange = {
         _number: 42,
         revisions: {def: {_number: 2}, abc: {_number: 1}},
       };
-      var spy = sandbox.spy();
+      const spy = sandbox.spy();
       Gerrit._setPluginsCount(1);
       plugin.on(element.EventType.SHOW_CHANGE, spy);
       element.handleEvent(element.EventType.SHOW_CHANGE,
           {change: testChange, patchNum: 1});
       assert.isFalse(spy.called);
       Gerrit._setPluginsCount(0);
-      flush(function() {
+      flush(() => {
         assert.isTrue(spy.called);
         done();
       });
     });
 
-    test('comment event', function(done) {
-      var testCommentNode = {foo: 'bar'};
+    test('comment event', done => {
+      const testCommentNode = {foo: 'bar'};
       plugin.on(element.EventType.COMMENT, throwErrFn);
-      plugin.on(element.EventType.COMMENT, function(commentNode) {
+      plugin.on(element.EventType.COMMENT, commentNode => {
         assert.deepEqual(commentNode, testCommentNode);
         assert.isTrue(errorStub.calledOnce);
         done();
@@ -123,7 +219,7 @@
       element.handleEvent(element.EventType.COMMENT, {node: testCommentNode});
     });
 
-    test('revert event', function() {
+    test('revert event', () => {
       function appendToRevertMsg(c, revertMsg, originalMsg) {
         return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
       }
@@ -134,16 +230,16 @@
       plugin.on(element.EventType.REVERT, throwErrFn);
       plugin.on(element.EventType.REVERT, appendToRevertMsg);
       assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-                   'test\n> origTest\ninfo');
+          'test\n> origTest\ninfo');
       assert.isTrue(errorStub.calledOnce);
 
       plugin.on(element.EventType.REVERT, appendToRevertMsg);
       assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
-                   'test\n> origTest\ninfo\n> origTest\ninfo');
+          'test\n> origTest\ninfo\n> origTest\ninfo');
       assert.isTrue(errorStub.calledTwice);
     });
 
-    test('postrevert event', function() {
+    test('postrevert event', () => {
       function getLabels(c) {
         return {'Code-Review': 1};
       }
@@ -158,10 +254,10 @@
       assert.isTrue(errorStub.calledOnce);
     });
 
-    test('commitmsgedit event', function(done) {
-      var testMsg = 'Test CL commit message';
+    test('commitmsgedit event', done => {
+      const testMsg = 'Test CL commit message';
       plugin.on(element.EventType.COMMIT_MSG_EDIT, throwErrFn);
-      plugin.on(element.EventType.COMMIT_MSG_EDIT, function(change, msg) {
+      plugin.on(element.EventType.COMMIT_MSG_EDIT, (change, msg) => {
         assert.deepEqual(msg, testMsg);
         assert.isTrue(errorStub.calledOnce);
         done();
@@ -169,10 +265,10 @@
       element.handleCommitMessage(null, testMsg);
     });
 
-    test('labelchange event', function(done) {
-      var testChange = {_number: 42};
+    test('labelchange event', done => {
+      const testChange = {_number: 42};
       plugin.on(element.EventType.LABEL_CHANGE, throwErrFn);
-      plugin.on(element.EventType.LABEL_CHANGE, function(change) {
+      plugin.on(element.EventType.LABEL_CHANGE, change => {
         assert.deepEqual(change, testChange);
         assert.isTrue(errorStub.calledOnce);
         done();
@@ -180,41 +276,41 @@
       element.handleEvent(element.EventType.LABEL_CHANGE, {change: testChange});
     });
 
-    test('submitchange', function() {
+    test('submitchange', () => {
       plugin.on(element.EventType.SUBMIT_CHANGE, throwErrFn);
-      plugin.on(element.EventType.SUBMIT_CHANGE, function() { return true; });
+      plugin.on(element.EventType.SUBMIT_CHANGE, () => { return true; });
       assert.isTrue(element.canSubmitChange());
       assert.isTrue(errorStub.calledOnce);
-      plugin.on(element.EventType.SUBMIT_CHANGE, function() { return false; });
-      plugin.on(element.EventType.SUBMIT_CHANGE, function() { return true; });
+      plugin.on(element.EventType.SUBMIT_CHANGE, () => { return false; });
+      plugin.on(element.EventType.SUBMIT_CHANGE, () => { return true; });
       assert.isFalse(element.canSubmitChange());
       assert.isTrue(errorStub.calledTwice);
     });
 
-    test('versioning', function() {
-      var callback = sandbox.spy();
+    test('versioning', () => {
+      const callback = sandbox.spy();
       Gerrit.install(callback, '0.0pre-alpha');
       assert(callback.notCalled);
     });
 
-    test('getAccount', function(done) {
-      Gerrit.getLoggedIn().then(function(loggedIn) {
+    test('getAccount', done => {
+      Gerrit.getLoggedIn().then(loggedIn => {
         assert.isTrue(loggedIn);
         done();
       });
     });
 
-    test('_setPluginsCount', function(done) {
+    test('_setPluginsCount', done => {
       stub('gr-reporting', {
-        pluginsLoaded: function() {
+        pluginsLoaded() {
           assert.equal(Gerrit._pluginsPending, 0);
           done();
-        }
+        },
       });
       Gerrit._setPluginsCount(0);
     });
 
-    test('_arePluginsLoaded', function() {
+    test('_arePluginsLoaded', () => {
       assert.isTrue(Gerrit._arePluginsLoaded());
       Gerrit._setPluginsCount(1);
       assert.isFalse(Gerrit._arePluginsLoaded());
@@ -222,12 +318,12 @@
       assert.isTrue(Gerrit._arePluginsLoaded());
     });
 
-    test('_pluginInstalled', function(done) {
+    test('_pluginInstalled', done => {
       stub('gr-reporting', {
-        pluginsLoaded: function() {
+        pluginsLoaded() {
           assert.equal(Gerrit._pluginsPending, 0);
           done();
-        }
+        },
       });
       Gerrit._setPluginsCount(2);
       Gerrit._pluginInstalled();
@@ -235,33 +331,136 @@
       Gerrit._pluginInstalled();
     });
 
-    test('install calls _pluginInstalled', function() {
+    test('install calls _pluginInstalled', () => {
       sandbox.stub(Gerrit, '_pluginInstalled');
-      Gerrit.install(function(p) { plugin = p; }, '0.1',
+      Gerrit.install(p => { plugin = p; }, '0.1',
           'http://test.com/plugins/testplugin/static/test.js');
+
+      // testplugin has already been installed once (in setup).
+      assert.isFalse(Gerrit._pluginInstalled.called);
+
+      // testplugin2 plugin has not yet been installed.
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin2/static/test.js');
       assert.isTrue(Gerrit._pluginInstalled.calledOnce);
     });
 
-    test('install calls _pluginInstalled on error', function() {
+    test('install calls _pluginInstalled on error', () => {
       sandbox.stub(Gerrit, '_pluginInstalled');
-      Gerrit.install(function() {}, '0.0pre-alpha');
+      Gerrit.install(() => {}, '0.0pre-alpha');
       assert.isTrue(Gerrit._pluginInstalled.calledOnce);
     });
 
-    test('installGwt calls _pluginInstalled', function() {
+    test('installGwt calls _pluginInstalled', () => {
       sandbox.stub(Gerrit, '_pluginInstalled');
       Gerrit.installGwt();
       assert.isTrue(Gerrit._pluginInstalled.calledOnce);
     });
 
-    test('installGwt returns a stub object', function() {
-      var plugin = Gerrit.installGwt();
+    test('installGwt returns a stub object', () => {
+      const plugin = Gerrit.installGwt();
       sandbox.stub(console, 'warn');
       assert.isAbove(Object.keys(plugin).length, 0);
-      Object.keys(plugin).forEach(function(name) {
+      for (const name of Object.keys(plugin)) {
         console.warn.reset();
         plugin[name]();
         assert.isTrue(console.warn.calledOnce);
+      }
+    });
+
+    test('attributeHelper', () => {
+      assert.isOk(plugin.attributeHelper());
+    });
+
+    test('deprecated.install', () => {
+      plugin.deprecated.install();
+      assert.strictEqual(plugin.popup, plugin.deprecated.popup);
+      assert.strictEqual(plugin.onAction, plugin.deprecated.onAction);
+      assert.notStrictEqual(plugin.install, plugin.deprecated.install);
+    });
+
+    suite('test plugin with base url', () => {
+      setup(() => {
+        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns('/r');
+
+        Gerrit._setPluginsCount(1);
+        Gerrit.install(p => { plugin = p; }, '0.1',
+            'http://test.com/r/plugins/testplugin/static/test.js');
+      });
+
+      test('url', () => {
+        assert.notEqual(plugin.url(), 'http://test.com/plugins/testplugin/');
+        assert.equal(plugin.url(), 'http://test.com/r/plugins/testplugin/');
+        assert.equal(plugin.url('/static/test.js'),
+            'http://test.com/r/plugins/testplugin/static/test.js');
+      });
+    });
+
+    suite('popup', () => {
+      test('popup(element) is deprecated', () => {
+        assert.throws(() => {
+          plugin.popup(document.createElement('div'));
+        });
+      });
+
+      test('popup(moduleName) creates popup with component', () => {
+        const openStub = sandbox.stub();
+        sandbox.stub(window, 'GrPopupInterface').returns({
+          open: openStub,
+        });
+        plugin.popup('some-name');
+        assert.isTrue(openStub.calledOnce);
+        assert.isTrue(GrPopupInterface.calledWith(plugin, 'some-name'));
+      });
+
+      test('deprecated.popup(element) creates popup with element', () => {
+        const el = document.createElement('div');
+        el.textContent = 'some text here';
+        const openStub = sandbox.stub(GrPopupInterface.prototype, 'open');
+        openStub.returns(Promise.resolve({
+          _getElement() {
+            return document.createElement('div');
+          }}));
+        plugin.deprecated.popup(el);
+        assert.isTrue(openStub.calledOnce);
+      });
+    });
+
+    suite('onAction', () => {
+      let change;
+      let revision;
+      let actionDetails;
+
+      setup(() => {
+        change = {};
+        revision = {};
+        actionDetails = {__key: 'some'};
+        sandbox.stub(plugin, 'on').callsArgWith(1, change, revision);
+        sandbox.stub(plugin, 'changeActions').returns({
+          addTapListener: sandbox.stub().callsArg(1),
+          getActionDetails: () => actionDetails,
+        });
+      });
+
+      test('returns GrPluginActionContext', () => {
+        const stub = sandbox.stub();
+        plugin.deprecated.onAction('change', 'foo', ctx => {
+          assert.isTrue(ctx instanceof GrPluginActionContext);
+          assert.strictEqual(ctx.change, change);
+          assert.strictEqual(ctx.revision, revision);
+          assert.strictEqual(ctx.action, actionDetails);
+          assert.strictEqual(ctx.plugin, plugin);
+          stub();
+        });
+        assert.isTrue(stub.called);
+      });
+
+      test('other actions', () => {
+        const stub = sandbox.stub();
+        plugin.deprecated.onAction('project', 'foo', stub);
+        plugin.deprecated.onAction('edit', 'foo', stub);
+        plugin.deprecated.onAction('branch', 'foo', stub);
+        assert.isFalse(stub.called);
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
new file mode 100644
index 0000000..f0c42f0
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context.js
@@ -0,0 +1,97 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function(window) {
+  'use strict';
+
+  function GrPluginActionContext(plugin, action, change, revision) {
+    this.action = action;
+    this.plugin = plugin;
+    this.change = change;
+    this.revision = revision;
+    this._popups = [];
+  }
+
+  GrPluginActionContext.prototype.popup = function(element) {
+    this._popups.push(this.plugin.deprecated.popup(element));
+  };
+
+  GrPluginActionContext.prototype.hide = function() {
+    for (const popupApi of this._popups) {
+      popupApi.close();
+    }
+    this._popups.splice(0);
+  };
+
+  GrPluginActionContext.prototype.refresh = function() {
+    window.location.reload();
+  };
+
+  GrPluginActionContext.prototype.textfield = function() {
+    return document.createElement('paper-input');
+  };
+
+  GrPluginActionContext.prototype.br = function() {
+    return document.createElement('br');
+  };
+
+  GrPluginActionContext.prototype.msg = function(text) {
+    const label = document.createElement('gr-label');
+    Polymer.dom(label).appendChild(document.createTextNode(text));
+    return label;
+  };
+
+  GrPluginActionContext.prototype.div = function(...els) {
+    const div = document.createElement('div');
+    for (const el of els) {
+      Polymer.dom(div).appendChild(el);
+    }
+    return div;
+  };
+
+  GrPluginActionContext.prototype.button = function(label, callbacks) {
+    const onClick = callbacks && callbacks.onclick;
+    const button = document.createElement('gr-button');
+    Polymer.dom(button).appendChild(document.createTextNode(label));
+    if (onClick) {
+      this.plugin.eventHelper(button).onTap(onClick);
+    }
+    return button;
+  };
+
+  GrPluginActionContext.prototype.checkbox = function() {
+    const checkbox = document.createElement('input');
+    checkbox.type = 'checkbox';
+    return checkbox;
+  };
+
+  GrPluginActionContext.prototype.label = function(checkbox, title) {
+    return this.div(checkbox, this.msg(title));
+  };
+
+  GrPluginActionContext.prototype.prependLabel = function(title, checkbox) {
+    return this.label(checkbox, title);
+  };
+
+  GrPluginActionContext.prototype.call = function(payload, onSuccess) {
+    if (!this.action.__url) {
+      console.warn(`Unable to ${this.action.method} to ${this.action.__key}!`);
+      return;
+    }
+    this.plugin.restApi()
+        .send(this.action.method, this.action.__url, payload)
+        .then(onSuccess);
+  };
+
+  window.GrPluginActionContext = GrPluginActionContext;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
new file mode 100644
index 0000000..072a781
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-action-context_test.html
@@ -0,0 +1,131 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-action-context</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-js-api-interface.html"/>
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-plugin-action-context tests', () => {
+    let instance;
+    let sandbox;
+    let plugin;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      Gerrit._setPluginsCount(1);
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      instance = new GrPluginActionContext(plugin);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('popup() and hide()', () => {
+      const popupApiStub = {
+        close: sandbox.stub(),
+      };
+      sandbox.stub(plugin.deprecated, 'popup').returns(popupApiStub);
+      const el = {};
+      instance.popup(el);
+      assert.isTrue(instance.plugin.deprecated.popup.calledWith(el));
+
+      instance.hide();
+      assert.isTrue(popupApiStub.close.called);
+    });
+
+    test('textfield', () => {
+      assert.equal(instance.textfield().tagName, 'PAPER-INPUT');
+    });
+
+    test('br', () => {
+      assert.equal(instance.br().tagName, 'BR');
+    });
+
+    test('msg', () => {
+      const el = instance.msg('foobar');
+      assert.equal(el.tagName, 'GR-LABEL');
+      assert.equal(el.textContent, 'foobar');
+    });
+
+    test('div', () => {
+      const el1 = document.createElement('span');
+      el1.textContent = 'foo';
+      const el2 = document.createElement('div');
+      el2.textContent = 'bar';
+      const div = instance.div(el1, el2);
+      assert.equal(div.tagName, 'DIV');
+      assert.equal(div.textContent, 'foobar');
+    });
+
+    test('button', () => {
+      const clickStub = sandbox.stub();
+      const button = instance.button('foo', {onclick: clickStub});
+      MockInteractions.tap(button);
+      flush(() => {
+        assert.isTrue(clickStub.called);
+        assert.equal(button.textContent, 'foo');
+      });
+    });
+
+    test('checkbox', () => {
+      const el = instance.checkbox();
+      assert.equal(el.tagName, 'INPUT');
+      assert.equal(el.type, 'checkbox');
+    });
+
+    test('label', () => {
+      const fakeMsg = {};
+      const fakeCheckbox = {};
+      sandbox.stub(instance, 'div');
+      sandbox.stub(instance, 'msg').returns(fakeMsg);
+      instance.label(fakeCheckbox, 'foo');
+      assert.isTrue(instance.div.calledWithExactly(fakeCheckbox, fakeMsg));
+    });
+
+    test('call', () => {
+      instance.action = {
+        method: 'METHOD',
+        __key: 'key',
+        __url: '/changes/1/revisions/2/foo~bar',
+      };
+      const sendStub = sandbox.stub().returns(Promise.resolve());
+      sandbox.stub(plugin, 'restApi').returns({
+        send: sendStub,
+      });
+      const payload = {foo: 'foo'};
+      const successStub = sandbox.stub();
+      instance.call(payload, successStub);
+      assert.isTrue(sendStub.calledWith(
+          'METHOD', '/changes/1/revisions/2/foo~bar', payload));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
new file mode 100644
index 0000000..1ee9eec
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints.js
@@ -0,0 +1,111 @@
+// 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.
+(function(window) {
+  'use strict';
+
+  function GrPluginEndpoints() {
+    this._endpoints = {};
+    this._callbacks = {};
+  }
+
+  GrPluginEndpoints.prototype.onNewEndpoint = function(endpoint, callback) {
+    if (!this._callbacks[endpoint]) {
+      this._callbacks[endpoint] = [];
+    }
+    this._callbacks[endpoint].push(callback);
+  };
+
+  GrPluginEndpoints.prototype.registerModule = function(plugin, endpoint, type,
+      moduleName, domHook) {
+    if (!this._endpoints[endpoint]) {
+      this._endpoints[endpoint] = [];
+    }
+    const moduleInfo = {
+      moduleName,
+      plugin,
+      pluginUrl: plugin._url,
+      type,
+      domHook,
+    };
+    this._endpoints[endpoint].push(moduleInfo);
+    if (Gerrit._arePluginsLoaded() && this._callbacks[endpoint]) {
+      this._callbacks[endpoint].forEach(callback => callback(moduleInfo));
+    }
+  };
+
+  /**
+   * Get detailed information about modules registered with an extension
+   * endpoint.
+   * @param {string} name Endpoint name.
+   * @param {?{
+   *   type: (string|undefined),
+   *   moduleName: (string|undefined)
+   * }} opt_options
+   * @return {!Array<{
+   *   moduleName: string,
+   *   plugin: Plugin,
+   *   pluginUrl: String,
+   *   type: EndpointType,
+   *   domHook: !Object
+   * }>}
+   */
+  GrPluginEndpoints.prototype.getDetails = function(name, opt_options) {
+    const type = opt_options && opt_options.type;
+    const moduleName = opt_options && opt_options.moduleName;
+    if (!this._endpoints[name]) {
+      return [];
+    }
+    return this._endpoints[name]
+        .filter(item => (!type || item.type === type) &&
+                    (!moduleName || moduleName == item.moduleName));
+  };
+
+  /**
+   * Get detailed module names for instantiating at the endpoint.
+   * @param {string} name Endpoint name.
+   * @param {?{
+   *   type: (string|undefined),
+   *   moduleName: (string|undefined)
+   * }} opt_options
+   * @return {!Array<string>}
+   */
+  GrPluginEndpoints.prototype.getModules = function(name, opt_options) {
+    const modulesData = this.getDetails(name, opt_options);
+    if (!modulesData.length) {
+      return [];
+    }
+    return modulesData.map(m => m.moduleName);
+  };
+
+  /**
+   * Get .html plugin URLs with element and module definitions.
+   * @param {string} name Endpoint name.
+   * @param {?{
+   *   type: (string|undefined),
+   *   moduleName: (string|undefined)
+   * }} opt_options
+   * @return {!Array<!URL>}
+   */
+  GrPluginEndpoints.prototype.getPlugins = function(name, opt_options) {
+    const modulesData =
+          this.getDetails(name, opt_options).filter(
+              data => data.pluginUrl.pathname.includes('.html'));
+    if (!modulesData.length) {
+      return [];
+    }
+    return Array.from(new Set(modulesData.map(m => m.pluginUrl)));
+  };
+
+  window.GrPluginEndpoints = GrPluginEndpoints;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
new file mode 100644
index 0000000..a61cdc8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-endpoints_test.html
@@ -0,0 +1,122 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-endpoints</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-js-api-interface.html"/>
+
+<script>
+  suite('gr-plugin-endpoints tests', () => {
+    let sandbox;
+    let instance;
+    let pluginFoo;
+    let pluginBar;
+    let domHook;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      domHook = {};
+      instance = new GrPluginEndpoints();
+      Gerrit.install(p => { pluginFoo = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/foo.html');
+      instance.registerModule(
+          pluginFoo, 'a-place', 'decorate', 'foo-module', domHook);
+      Gerrit.install(p => { pluginBar = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/bar.html');
+      instance.registerModule(
+          pluginBar, 'a-place', 'style', 'bar-module', domHook);
+      sandbox.stub(Gerrit, '_arePluginsLoaded').returns(true);
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('getDetails all', () => {
+      assert.deepEqual(instance.getDetails('a-place'), [
+        {
+          moduleName: 'foo-module',
+          plugin: pluginFoo,
+          pluginUrl: pluginFoo._url,
+          type: 'decorate',
+          domHook,
+        },
+        {
+          moduleName: 'bar-module',
+          plugin: pluginBar,
+          pluginUrl: pluginBar._url,
+          type: 'style',
+          domHook,
+        },
+      ]);
+    });
+
+    test('getDetails by type', () => {
+      assert.deepEqual(instance.getDetails('a-place', {type: 'style'}), [
+        {
+          moduleName: 'bar-module',
+          plugin: pluginBar,
+          pluginUrl: pluginBar._url,
+          type: 'style',
+          domHook,
+        },
+      ]);
+    });
+
+    test('getDetails by module', () => {
+      assert.deepEqual(
+          instance.getDetails('a-place', {moduleName: 'foo-module'}),
+          [
+            {
+              moduleName: 'foo-module',
+              plugin: pluginFoo,
+              pluginUrl: pluginFoo._url,
+              type: 'decorate',
+              domHook,
+            },
+          ]);
+    });
+
+    test('getModules', () => {
+      assert.deepEqual(
+          instance.getModules('a-place'), ['foo-module', 'bar-module']);
+    });
+
+    test('getPlugins', () => {
+      assert.deepEqual(
+          instance.getPlugins('a-place'), [pluginFoo._url, pluginBar._url]);
+    });
+
+    test('onNewEndpoint', () => {
+      const newModuleStub = sandbox.stub();
+      instance.onNewEndpoint('a-place', newModuleStub);
+      instance.registerModule(
+          pluginFoo, 'a-place', 'replace', 'zaz-module', domHook);
+      assert.deepEqual(newModuleStub.lastCall.args[0], {
+        moduleName: 'zaz-module',
+        plugin: pluginFoo,
+        pluginUrl: pluginFoo._url,
+        type: 'replace',
+        domHook,
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
new file mode 100644
index 0000000..d04a758
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api.js
@@ -0,0 +1,104 @@
+// 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.
+(function(window) {
+  'use strict';
+
+  function GrPluginRestApi(opt_prefix) {
+    this.opt_prefix = opt_prefix || '';
+    this._restApi = document.createElement('gr-rest-api-interface');
+  }
+
+  GrPluginRestApi.prototype.getLoggedIn = function() {
+    return this._restApi.getLoggedIn();
+  };
+
+  /**
+   * Fetch and return native browser REST API Response.
+   * @param {string} method HTTP Method (GET, POST, etc)
+   * @param {string} url URL without base path or plugin prefix
+   * @param {Object=} payload Respected for POST and PUT only.
+   * @return {!Promise}
+   */
+  GrPluginRestApi.prototype.fetch = function(method, url, opt_payload) {
+    return this._restApi.send(method, this.opt_prefix + url, opt_payload);
+  };
+
+  /**
+   * Fetch and parse REST API response, if request succeeds.
+   * @param {string} method HTTP Method (GET, POST, etc)
+   * @param {string} url URL without base path or plugin prefix
+   * @param {Object=} payload Respected for POST and PUT only.
+   * @return {!Promise} resolves on success, rejects on error.
+   */
+  GrPluginRestApi.prototype.send = function(method, url, opt_payload) {
+    return this.fetch(method, url, opt_payload).then(response => {
+      if (response.status < 200 || response.status >= 300) {
+        return response.text().then(text => {
+          if (text) {
+            return Promise.reject(text);
+          } else {
+            return Promise.reject(response.status);
+          }
+        });
+      } else {
+        return this._restApi.getResponseObject(response);
+      }
+    });
+  };
+
+  /**
+   * @param {string} url URL without base path or plugin prefix
+   * @return {!Promise} resolves on success, rejects on error.
+   */
+  GrPluginRestApi.prototype.get = function(url) {
+    return this.send('GET', url);
+  };
+
+  /**
+   * @param {string} url URL without base path or plugin prefix
+   * @return {!Promise} resolves on success, rejects on error.
+   */
+  GrPluginRestApi.prototype.post = function(url, opt_payload) {
+    return this.send('POST', url, opt_payload);
+  };
+
+  /**
+   * @param {string} url URL without base path or plugin prefix
+   * @return {!Promise} resolves on success, rejects on error.
+   */
+  GrPluginRestApi.prototype.put = function(url, opt_payload) {
+    return this.send('PUT', url, opt_payload);
+  };
+
+  /**
+   * @param {string} url URL without base path or plugin prefix
+   * @return {!Promise} resolves on 204, rejects on error.
+   */
+  GrPluginRestApi.prototype.delete = function(url) {
+    return this.fetch('DELETE', url).then(response => {
+      if (response.status !== 204) {
+        return response.text().then(text => {
+          if (text) {
+            return Promise.reject(text);
+          } else {
+            return Promise.reject(response.status);
+          }
+        });
+      }
+      return response;
+    });
+  };
+
+  window.GrPluginRestApi = GrPluginRestApi;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
new file mode 100644
index 0000000..fd5da3f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-plugin-rest-api_test.html
@@ -0,0 +1,124 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-plugin-rest-api</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-js-api-interface.html"/>
+
+<script>
+  suite('gr-plugin-rest-api tests', () => {
+    let instance;
+    let sandbox;
+    let getResponseObjectStub;
+    let sendStub;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      getResponseObjectStub = sandbox.stub().returns(Promise.resolve());
+      sendStub = sandbox.stub().returns(Promise.resolve({status: 200}));
+      stub('gr-rest-api-interface', {
+        getAccount() {
+          return Promise.resolve({name: 'Judy Hopps'});
+        },
+        getResponseObject: getResponseObjectStub,
+        send(...args) {
+          return sendStub(...args);
+        },
+      });
+      Gerrit._setPluginsCount(1);
+      Gerrit.install(p => { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      instance = new GrPluginRestApi();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('fetch', () => {
+      const payload = {foo: 'foo'};
+      return instance.fetch('HTTP_METHOD', '/url', payload).then(r => {
+        assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
+        assert.equal(r.status, 200);
+        assert.isFalse(getResponseObjectStub.called);
+      });
+    });
+
+    test('send', () => {
+      const payload = {foo: 'foo'};
+      const response = {bar: 'bar'};
+      getResponseObjectStub.returns(Promise.resolve(response));
+      return instance.send('HTTP_METHOD', '/url', payload).then(r => {
+        assert.isTrue(sendStub.calledWith('HTTP_METHOD', '/url', payload));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('get', () => {
+      const response = {foo: 'foo'};
+      getResponseObjectStub.returns(Promise.resolve(response));
+      return instance.get('/url').then(r => {
+        assert.isTrue(sendStub.calledWith('GET', '/url'));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('post', () => {
+      const payload = {foo: 'foo'};
+      const response = {bar: 'bar'};
+      getResponseObjectStub.returns(Promise.resolve(response));
+      return instance.post('/url', payload).then(r => {
+        assert.isTrue(sendStub.calledWith('POST', '/url', payload));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('put', () => {
+      const payload = {foo: 'foo'};
+      const response = {bar: 'bar'};
+      getResponseObjectStub.returns(Promise.resolve(response));
+      return instance.put('/url', payload).then(r => {
+        assert.isTrue(sendStub.calledWith('PUT', '/url', payload));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('delete works', () => {
+      const response = {status: 204};
+      sendStub.returns(Promise.resolve(response));
+      return instance.delete('/url').then(r => {
+        assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+        assert.strictEqual(r, response);
+      });
+    });
+
+    test('delete fails', () => {
+      sendStub.returns(Promise.resolve(
+          {status: 400, text() { return Promise.resolve('text'); }}));
+      return instance.delete('/url').then(r => {
+        throw new Error('Should not resolve');
+      }).catch(err => {
+        assert.isTrue(sendStub.calledWith('DELETE', '/url'));
+        assert.equal('text', err);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index b3ae649..ac4129f 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -14,36 +14,103 @@
 (function(window) {
   'use strict';
 
-  var warnNotSupported = function(opt_name) {
+  const warnNotSupported = function(opt_name) {
     console.warn('Plugin API method ' + (opt_name || '') + ' is not supported');
   };
 
-  var stubbedMethods = ['_loadedGwt', 'screen', 'settingsScreen', 'panel'];
-  var GWT_PLUGIN_STUB = {};
-  stubbedMethods.forEach(function(name) {
-    GWT_PLUGIN_STUB[name] = warnNotSupported.bind(null, name);
-  });
+  /**
+   * Hash of loaded and installed plugins, name to Plugin object.
+   */
+  const plugins = {};
 
-  var API_VERSION = '0.1';
+  const stubbedMethods = ['_loadedGwt', 'screen', 'settingsScreen', 'panel'];
+  const GWT_PLUGIN_STUB = {};
+  for (const name of stubbedMethods) {
+    GWT_PLUGIN_STUB[name] = warnNotSupported.bind(null, name);
+  }
+
+  let _restAPI;
+  const getRestAPI = () => {
+    if (!_restAPI) {
+      _restAPI = document.createElement('gr-rest-api-interface');
+    }
+    return _restAPI;
+  };
+
+  // TODO (viktard): deprecate in favor of GrPluginRestApi.
+  function send(method, url, opt_callback, opt_payload) {
+    return getRestAPI().send(method, url, opt_payload).then(response => {
+      if (response.status < 200 || response.status >= 300) {
+        return response.text().then(text => {
+          if (text) {
+            return Promise.reject(text);
+          } else {
+            return Promise.reject(response.status);
+          }
+        });
+      } else {
+        return getRestAPI().getResponseObject(response);
+      }
+    }).then(response => {
+      if (opt_callback) {
+        opt_callback(response);
+      }
+      return response;
+    });
+  }
+
+  const API_VERSION = '0.1';
+
+  /**
+   * Plugin-provided custom components can affect content in extension
+   * points using one of following methods:
+   * - DECORATE: custom component is set with `content` attribute and may
+   *   decorate (e.g. style) DOM element.
+   * - REPLACE: contents of extension point are replaced with the custom
+   *   component.
+   * - STYLE: custom component is a shared styles module that is inserted
+   *   into the extension point.
+   */
+  const EndpointType = {
+    DECORATE: 'decorate',
+    REPLACE: 'replace',
+    STYLE: 'style',
+  };
 
   // GWT JSNI uses $wnd to refer to window.
   // http://www.gwtproject.org/doc/latest/DevGuideCodingBasicsJSNI.html
   window.$wnd = window;
 
+  function getPluginNameFromUrl(url) {
+    const base = Gerrit.BaseUrlBehavior.getBaseUrl();
+    const pathname = url.pathname.replace(base, '');
+    // Site theme is server from predefined path.
+    if (pathname === '/static/gerrit-theme.html') {
+      return 'gerrit-theme';
+    } else if (!pathname.startsWith('/plugins')) {
+      console.warn('Plugin not being loaded from /plugins base path:',
+          url.href, '— Unable to determine name.');
+      return;
+    }
+    return pathname.split('/')[2];
+  }
+
   function Plugin(opt_url) {
+    this._domHooks = new GrDomHooksManager(this);
+
     if (!opt_url) {
       console.warn('Plugin not being loaded from /plugins base path.',
           'Unable to determine name.');
       return;
     }
+    this.deprecated = {
+      install: deprecatedAPI.install.bind(this),
+      popup: deprecatedAPI.popup.bind(this),
+      onAction: deprecatedAPI.onAction.bind(this),
+    };
 
     this._url = new URL(opt_url);
-    if (this._url.pathname.indexOf('/plugins') !== 0) {
-      console.warn('Plugin not being loaded from /plugins base path:',
-          this._url.href, '— Unable to determine name.');
-      return;
-    }
-    this._name = this._url.pathname.split('/')[2];
+    this._name = getPluginNameFromUrl(this._url);
   }
 
   Plugin._sharedAPIElement = document.createElement('gr-js-api-interface');
@@ -54,6 +121,30 @@
     return this._name;
   };
 
+  Plugin.prototype.registerStyleModule = function(endpointName, moduleName) {
+    Gerrit._endpoints.registerModule(
+        this, endpointName, EndpointType.STYLE, moduleName);
+  };
+
+  Plugin.prototype.registerCustomComponent = function(
+      endpointName, opt_moduleName, opt_options) {
+    const type = opt_options && opt_options.replace ?
+          EndpointType.REPLACE : EndpointType.DECORATE;
+    const hook = this._domHooks.getDomHook(endpointName, opt_moduleName);
+    const moduleName = opt_moduleName || hook.getModuleName();
+    Gerrit._endpoints.registerModule(
+        this, endpointName, type, moduleName, hook);
+    return hook.getPublicAPI();
+  };
+
+  /**
+   * Returns instance of DOM hook API for endpoint. Creates a placeholder
+   * element for the first call.
+   */
+  Plugin.prototype.hook = function(endpointName, opt_options) {
+    return this.registerCustomComponent(endpointName, undefined, opt_options);
+  };
+
   Plugin.prototype.getServerInfo = function() {
     return document.createElement('gr-rest-api-interface').getConfig();
   };
@@ -63,37 +154,154 @@
   };
 
   Plugin.prototype.url = function(opt_path) {
-    return this._url.origin + '/plugins/' + this._name + (opt_path || '/');
+    const base = Gerrit.BaseUrlBehavior.getBaseUrl();
+    return this._url.origin + base + '/plugins/' +
+        this._name + (opt_path || '/');
+  };
+
+  Plugin.prototype._send = function(method, url, opt_callback, opt_payload) {
+    return send(method, this.url(url), opt_callback, opt_payload);
+  };
+
+  Plugin.prototype.get = function(url, opt_callback) {
+    console.warn('.get() is deprecated! Use .restApi().get()');
+    return this._send('GET', url, opt_callback);
+  };
+
+  Plugin.prototype.post = function(url, payload, opt_callback) {
+    console.warn('.post() is deprecated! Use .restApi().post()');
+    return this._send('POST', url, opt_callback, payload);
+  };
+
+  Plugin.prototype.put = function(url, payload, opt_callback) {
+    console.warn('.put() is deprecated! Use .restApi().put()');
+    return this._send('PUT', url, opt_callback, payload);
+  };
+
+  Plugin.prototype.delete = function(url, opt_callback) {
+    return Gerrit.delete(this.url(url), opt_callback);
   };
 
   Plugin.prototype.changeActions = function() {
-    return new GrChangeActionsInterface(Plugin._sharedAPIElement.getElement(
-        Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
+    return new GrChangeActionsInterface(this,
+      Plugin._sharedAPIElement.getElement(
+          Plugin._sharedAPIElement.Element.CHANGE_ACTIONS));
   };
 
   Plugin.prototype.changeReply = function() {
-    return new GrChangeReplyInterface(Plugin._sharedAPIElement.getElement(
-        Plugin._sharedAPIElement.Element.REPLY_DIALOG));
+    return new GrChangeReplyInterface(this,
+      Plugin._sharedAPIElement.getElement(
+          Plugin._sharedAPIElement.Element.REPLY_DIALOG));
   };
 
-  var Gerrit = window.Gerrit || {};
+  Plugin.prototype.changeView = function() {
+    return new GrChangeViewApi(this);
+  };
+
+  Plugin.prototype.theme = function() {
+    return new GrThemeApi(this);
+  };
+
+  Plugin.prototype.project = function() {
+    return new GrProjectApi(this);
+  };
+
+  /**
+   * To make REST requests for plugin-provided endpoints, use
+   * @example
+   * const pluginRestApi = plugin.restApi(plugin.url());
+   *
+   * @param {string} Base url for subsequent .get(), .post() etc requests.
+   */
+  Plugin.prototype.restApi = function(opt_prefix) {
+    return new GrPluginRestApi(opt_prefix);
+  };
+
+  Plugin.prototype.attributeHelper = function(element) {
+    return new GrAttributeHelper(element);
+  };
+
+  Plugin.prototype.eventHelper = function(element) {
+    return new GrEventHelper(element);
+  };
+
+  Plugin.prototype.popup = function(moduleName) {
+    if (typeof moduleName !== 'string') {
+      throw new Error('deprecated, use deprecated.popup');
+    }
+    const api = new GrPopupInterface(this, moduleName);
+    return api.open();
+  };
+
+  const deprecatedAPI = {
+    install() {
+      console.log('Installing deprecated APIs is deprecated!');
+      for (const method in this.deprecated) {
+        if (method === 'install') continue;
+        this[method] = this.deprecated[method];
+      }
+    },
+
+    popup(el) {
+      console.warn('plugin.deprecated.popup() is deprecated, ' +
+          'use plugin.popup() insted!');
+      if (!el) {
+        throw new Error('Popup contents not found');
+      }
+      const api = new GrPopupInterface(this);
+      api.open().then(api => api._getElement().appendChild(el));
+      return api;
+    },
+
+    onAction(type, action, callback) {
+      console.warn('plugin.deprecated.onAction() is deprecated,' +
+          ' use plugin.changeActions() instead!');
+      if (type !== 'change' && type !== 'revision') {
+        console.warn(`${type} actions are not supported.`);
+        return;
+      }
+      this.on('showchange', (change, revision) => {
+        const details = this.changeActions().getActionDetails(action);
+        this.changeActions().addTapListener(details.__key, () => {
+          callback(new GrPluginActionContext(this, details, change, revision));
+        });
+      });
+    },
+
+  };
+
+  const Gerrit = window.Gerrit || {};
+
+  // Provide reset plugins function to clear installed plugins between tests.
+  const app = document.querySelector('#app');
+  if (!app) {
+    // No gr-app found (running tests)
+    Gerrit._resetPlugins = () => {
+      for (const k of Object.keys(plugins)) {
+        delete plugins[k];
+      }
+    };
+  }
 
   // Number of plugins to initialize, -1 means 'not yet known'.
   Gerrit._pluginsPending = -1;
 
+  Gerrit._endpoints = new GrPluginEndpoints();
+
   Gerrit.getPluginName = function() {
     console.warn('Gerrit.getPluginName is not supported in PolyGerrit.',
-        'Please use self.getPluginName() instead.');
+        'Please use plugin.getPluginName() instead.');
   };
 
   Gerrit.css = function(rulesStr) {
     if (!Gerrit._customStyleSheet) {
-      var styleEl = document.createElement('style');
+      const styleEl = document.createElement('style');
       document.head.appendChild(styleEl);
       Gerrit._customStyleSheet = styleEl.sheet;
     }
 
-    var name = '__pg_js_api_class_' + Gerrit._customStyleSheet.cssRules.length;
+    const name = '__pg_js_api_class_' +
+        Gerrit._customStyleSheet.cssRules.length;
     Gerrit._customStyleSheet.insertRule('.' + name + '{' + rulesStr + '}', 0);
     return name;
   };
@@ -106,22 +314,68 @@
       return;
     }
 
-    // TODO(andybons): Polyfill currentScript for IE10/11 (edge supports it).
-    var src = opt_src || (document.currentScript && document.currentScript.src);
-    var plugin = new Plugin(src);
+    const src = opt_src || (document.currentScript &&
+         (document.currentScript.src || document.currentScript.baseURI));
+    const name = getPluginNameFromUrl(new URL(src));
+    const existingPlugin = plugins[name];
+    const plugin = existingPlugin || new Plugin(src);
     try {
       callback(plugin);
+      plugins[name] = plugin;
     } catch (e) {
-      console.warn(plugin.getPluginName() + ' install failed: ' +
-          e.name + ': ' + e.message);
+      console.warn(`${name} install failed: ${e.name}: ${e.message}`);
     }
-    Gerrit._pluginInstalled();
+    // Don't double count plugins that may have an html and js install.
+    // TODO(beckysiegel) remove name check once name issue is resolved.
+    // If there isn't a name, it's due to an issue with the polyfill for
+    // html imports in Safari/Firefox. In this case, other plugin related
+    // features may still be broken, but still make sure to call.
+    // _pluginInstalled.
+    if (!name || !existingPlugin) {
+      Gerrit._pluginInstalled();
+    }
   };
 
   Gerrit.getLoggedIn = function() {
+    console.warn('Gerrit.getLoggedIn() is deprecated! ' +
+        'Use plugin.restApi().getLoggedIn()');
     return document.createElement('gr-rest-api-interface').getLoggedIn();
   };
 
+  Gerrit.get = function(url, callback) {
+    console.warn('.get() is deprecated! Use plugin.restApi().get()');
+    send('GET', url, callback);
+  };
+
+  Gerrit.post = function(url, payload, callback) {
+    console.warn('.post() is deprecated! Use plugin.restApi().post()');
+    send('POST', url, callback, payload);
+  };
+
+  Gerrit.put = function(url, payload, callback) {
+    console.warn('.put() is deprecated! Use plugin.restApi().put()');
+    send('PUT', url, callback, payload);
+  };
+
+  Gerrit.delete = function(url, opt_callback) {
+    console.warn('.delete() is deprecated! Use plugin.restApi().delete()');
+    return getRestAPI().send('DELETE', url).then(response => {
+      if (response.status !== 204) {
+        return response.text().then(text => {
+          if (text) {
+            return Promise.reject(text);
+          } else {
+            return Promise.reject(response.status);
+          }
+        });
+      }
+      if (opt_callback) {
+        opt_callback(response);
+      }
+      return response;
+    });
+  };
+
   /**
    * Polyfill GWT API dependencies to avoid runtime exceptions when loading
    * GWT-compiled plugins.
@@ -140,7 +394,7 @@
       if (Gerrit._arePluginsLoaded()) {
         Gerrit._allPluginsPromise = Promise.resolve();
       } else {
-        Gerrit._allPluginsPromise = new Promise(function(resolve) {
+        Gerrit._allPluginsPromise = new Promise(resolve => {
           Gerrit._resolveAllPluginsLoaded = resolve;
         });
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html
new file mode 100644
index 0000000..8f88b6a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
+
+<dom-module id="gr-limited-text">
+  <template>[[_computeDisplayText(text, limit)]]</template>
+  <script src="gr-limited-text.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
new file mode 100644
index 0000000..eabe061
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text.js
@@ -0,0 +1,72 @@
+// 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.
+(function() {
+  'use strict';
+
+  /*
+   * The gr-limited-text element is for displaying text with a maximum length
+   * (in number of characters) to display. If the length of the text exceeds the
+   * configured limit, then an ellipsis indicates that the text was truncated
+   * and a tooltip containing the full text is enabled.
+   */
+
+  Polymer({
+    is: 'gr-limited-text',
+
+    properties: {
+      /** The un-truncated text to display. */
+      text: String,
+
+      /** The maximum length for the text to display before truncating. */
+      limit: {
+        type: Number,
+        value: null,
+      },
+
+      /** Boolean property used by Gerrit.TooltipBehavior. */
+      hasTooltip: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
+    observers: [
+      '_updateTitle(text, limit)',
+    ],
+
+    behaviors: [
+      Gerrit.TooltipBehavior,
+    ],
+
+    /**
+     * The text or limit have changed. Recompute whether a tooltip needs to be
+     * enabled.
+     */
+    _updateTitle(text, limit) {
+      this.hasTooltip = !!limit && !!text && text.length > limit;
+      if (this.hasTooltip) {
+        this.setAttribute('title', text);
+      } else {
+        this.removeAttribute('title');
+      }
+    },
+
+    _computeDisplayText(text, limit) {
+      if (!!limit && !!text && text.length > limit) {
+        return text.substr(0, limit - 1) + '…';
+      }
+      return text;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
new file mode 100644
index 0000000..9e00331
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-limited-text/gr-limited-text_test.html
@@ -0,0 +1,88 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-limited-text</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-limited-text.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-limited-text></gr-limited-text>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-limited-text tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_updateTitle', () => {
+      const updateSpy = sandbox.spy(element, '_updateTitle');
+      element.text = 'abc 123';
+      flushAsynchronousOperations();
+      assert.isTrue(updateSpy.calledOnce);
+      assert.isNotOk(element.getAttribute('title'));
+      assert.isFalse(element.hasTooltip);
+
+      element.limit = 10;
+      flushAsynchronousOperations();
+      assert.isTrue(updateSpy.calledTwice);
+      assert.isNotOk(element.getAttribute('title'));
+      assert.isFalse(element.hasTooltip);
+
+      element.limit = 3;
+      flushAsynchronousOperations();
+      assert.isTrue(updateSpy.calledThrice);
+      assert.equal(element.getAttribute('title'), 'abc 123');
+      assert.isTrue(element.hasTooltip);
+
+      element.limit = 100;
+      flushAsynchronousOperations();
+      assert.equal(updateSpy.callCount, 4);
+      assert.isNotOk(element.getAttribute('title'));
+      assert.isFalse(element.hasTooltip);
+
+      element.limit = null;
+      flushAsynchronousOperations();
+      assert.equal(updateSpy.callCount, 5);
+      assert.isNotOk(element.getAttribute('title'));
+      assert.isFalse(element.hasTooltip);
+    });
+
+    test('_computeDisplayText', () => {
+      assert.equal(element._computeDisplayText('foo bar', 100), 'foo bar');
+      assert.equal(element._computeDisplayText('foo bar', 4), 'foo…');
+      assert.equal(element._computeDisplayText('foo bar', null), 'foo bar');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
index 5828e7b..d30bad2 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.html
@@ -15,10 +15,14 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
 <link rel="import" href="../gr-button/gr-button.html">
+<link rel="import" href="../gr-limited-text/gr-limited-text.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
 <dom-module id="gr-linked-chip">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
         overflow: hidden;
@@ -30,34 +34,48 @@
         display: inline-flex;
         padding: 0 .5em;
       }
-      gr-button.remove,
       gr-button.remove:hover,
       gr-button.remove:focus {
-        border-color: transparent;
-        color: #333;
+        --gr-button: {
+          color: #333;
+        }
       }
       gr-button.remove {
-        background: #eee;
-        border: 0;
-        color: #666;
-        font-size: 1.7em;
-        font-weight: normal;
-        height: .6em;
-        line-height: .6em;
-        margin-left: .15em;
-        margin-top: -.05em;
-        padding: 0;
-        text-decoration: none;
+        --gr-button: {
+          border: 0;
+          color: #666;
+          font-size: 1.7em;
+          font-weight: normal;
+          height: .6em;
+          line-height: .6em;
+          margin-left: .15em;
+          margin-top: -.05em;
+          padding: 0;
+          text-decoration: none;
+        }
+        --gr-button-hover-color: {
+          color: #333;
+        }
+        --gr-button-hover-background-color: {
+          color: #333;
+        }
       }
       .transparentBackground,
       gr-button.transparentBackground {
         background-color: transparent;
       }
+      :host([disabled]) {
+        opacity: .6;
+        pointer-events: none;
+      }
     </style>
     <div class$="container [[_getBackgroundClass(transparentBackground)]]">
-      <a href$="[[href]]">[[text]]</a>
+      <a href$="[[href]]">
+        <gr-limited-text limit="[[limit]]" text="[[text]]"></gr-limited-text>
+      </a>
       <gr-button
           id="remove"
+          link
           hidden$="[[!removable]]"
           hidden
           class$="remove [[_getBackgroundClass(transparentBackground)]]"
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
index c6a5e4e..bfb8dbb 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip.js
@@ -19,6 +19,11 @@
 
     properties: {
       href: String,
+      disabled: {
+        type: Boolean,
+        value: false,
+        reflectToAttribute: true,
+      },
       removable: {
         type: Boolean,
         value: false,
@@ -28,13 +33,16 @@
         type: Boolean,
         value: false,
       },
+
+      /**  If provided, sets the maximum length of the content. */
+      limit: Number,
     },
 
-    _getBackgroundClass: function(transparent) {
+    _getBackgroundClass(transparent) {
       return transparent ? 'transparentBackground' : '';
     },
 
-    _handleRemoveTap: function(e) {
+    _handleRemoveTap(e) {
       e.preventDefault();
       this.fire('remove');
     },
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
index eefc79d..d707d10 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-chip/gr-linked-chip_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../bower_components/iron-test-helpers/mock-interactions.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-linked-chip.html">
 
 <script>void(0);</script>
@@ -34,21 +34,21 @@
 </test-fixture>
 
 <script>
-  suite('gr-linked-chip tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-linked-chip tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('remove fired', function() {
-      var spy = sandbox.spy();
+    test('remove fired', () => {
+      const spy = sandbox.spy();
       element.addEventListener('remove', spy);
       flushAsynchronousOperations();
       MockInteractions.tap(element.$.remove);
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
index 79db969..a0d9233 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text.html
@@ -15,11 +15,13 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
 <script src="ba-linkify.js"></script>
 <script src="link-text-parser.js"></script>
 <dom-module id="gr-linked-text">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         display: block;
       }
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
index 807278d..52a7de0 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/gr-linked-text_test.html
@@ -20,6 +20,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
 <link rel="import" href="gr-linked-text.html">
@@ -192,6 +193,13 @@
       assert.equal(element.$.output.innerHTML.match(/(R=<a)/g).length, 1);
     });
 
+    test('CC=email labels link correctly', function() {
+      element.removeZeroWidthSpace = true;
+      element.content = 'CC=\u200Btest@google.com';
+      assert.equal(element.$.output.textContent, 'CC=test@google.com');
+      assert.equal(element.$.output.innerHTML.match(/(CC=<a)/g).length, 1);
+    });
+
     test('overlapping links', function() {
       element.config = {
         b1: {
diff --git a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
index e7ca50d..8a489f4 100644
--- a/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-linked-text/link-text-parser.js
@@ -132,7 +132,7 @@
   // TODO(wyatta) switch linkify sequence, see issue 5526.
   if (this.removeZeroWidthSpace) {
     // Remove the zero-width space added in gr-change-view.
-    text = text.replace(/^R=\u200B/gm, 'R=');
+    text = text.replace(/^(CC|R)=\u200B/gm, '$1=');
   }
 
   if (href) {
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
new file mode 100644
index 0000000..016932f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.html
@@ -0,0 +1,83 @@
+<!--
+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.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+<link rel="import" href="../../../behaviors/gr-url-encoding-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+<link rel="import" href="../../shared/gr-button/gr-button.html">
+
+<dom-module id="gr-list-view">
+  <template>
+    <style include="shared-styles">
+      #filter {
+        font-size: 1em;
+        max-width: 25em;
+      }
+      #topContainer {
+        display: flex;
+        justify-content: space-between;
+        margin: 1em;
+      }
+      #createNewContainer:not(.show) {
+        display: none;
+      }
+      a {
+        color: var(--default-text-color);
+        text-decoration: none;
+      }
+      a:hover {
+        text-decoration: underline;
+      }
+      nav {
+        padding: .5em 0;
+        text-align: center;
+      }
+      nav a {
+        display: inline-block;
+      }
+      nav a:first-of-type {
+        margin-right: .5em;
+      }
+    </style>
+    <div id="topContainer">
+      <div>
+        <label>Filter:</label>
+        <input is="iron-input"
+            type="text"
+            id="filter"
+            bind-value="{{filter}}">
+      </div>
+      <div id="createNewContainer"
+          class$="[[_computeCreateClass(createNew)]]">
+        <gr-button id="createNew" on-tap="_createNewItem">
+          Create New
+        </gr-button>
+      </div>
+    </div>
+    <content></content>
+    <nav>
+      <a id="prevArrow"
+          href$="[[_computeNavLink(offset, -1, itemsPerPage, filter, path)]]"
+          hidden$="[[_hidePrevArrow(offset)]]" hidden>&larr; Prev</a>
+      <a id="nextArrow"
+          href$="[[_computeNavLink(offset, 1, itemsPerPage, filter, path)]]"
+          hidden$="[[_hideNextArrow(loading, items)]]" hidden>
+        Next &rarr;</a>
+    </nav>
+  </template>
+  <script src="gr-list-view.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
new file mode 100644
index 0000000..e9ee51a
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view.js
@@ -0,0 +1,96 @@
+// 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.
+(function() {
+  'use strict';
+
+  const REQUEST_DEBOUNCE_INTERVAL_MS = 200;
+
+  Polymer({
+    is: 'gr-list-view',
+
+    properties: {
+      createNew: Boolean,
+      items: Array,
+      itemsPerPage: Number,
+      filter: {
+        type: String,
+        observer: '_filterChanged',
+      },
+      offset: Number,
+      loading: Boolean,
+      path: String,
+    },
+
+    behaviors: [
+      Gerrit.BaseUrlBehavior,
+      Gerrit.URLEncodingBehavior,
+    ],
+
+    detached() {
+      this.cancelDebouncer('reload');
+    },
+
+    _filterChanged(newFilter, oldFilter) {
+      if (!newFilter && !oldFilter) {
+        return;
+      }
+
+      this._debounceReload(newFilter);
+    },
+
+    _debounceReload(filter) {
+      this.debounce('reload', () => {
+        if (filter) {
+          return page.show(`${this.path}/q/filter:` +
+              this.encodeURL(filter, false));
+        }
+        page.show(this.path);
+      }, REQUEST_DEBOUNCE_INTERVAL_MS);
+    },
+
+    _createNewItem() {
+      this.fire('create-clicked');
+    },
+
+    _computeNavLink(offset, direction, itemsPerPage, filter, path) {
+      // Offset could be a string when passed from the router.
+      offset = +(offset || 0);
+      const newOffset = Math.max(0, offset + (itemsPerPage * direction));
+      let href = this.getBaseUrl() + path;
+      if (filter) {
+        href += '/q/filter:' + this.encodeURL(filter, false);
+      }
+      if (newOffset > 0) {
+        href += ',' + newOffset;
+      }
+      return href;
+    },
+
+    _computeCreateClass(createNew) {
+      return createNew ? 'show' : '';
+    },
+
+    _hidePrevArrow(offset) {
+      return offset === 0;
+    },
+
+    _hideNextArrow(loading, items) {
+      let lastPage = false;
+      if (items.length < this.itemsPerPage + 1) {
+        lastPage = true;
+      }
+      return loading || lastPage || !items || !items.length;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
new file mode 100644
index 0000000..680bf93
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-list-view/gr-list-view_test.html
@@ -0,0 +1,155 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-list-view</title>
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-list-view.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-list-view></gr-list-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-list-view tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('_computeNavLink', () => {
+      const offset = 25;
+      const projectsPerPage = 25;
+      let filter = 'test';
+      const path = '/admin/projects';
+
+      sandbox.stub(element, 'getBaseUrl', () => '');
+
+      assert.equal(
+          element._computeNavLink(offset, 1, projectsPerPage, filter, path),
+          '/admin/projects/q/filter:test,50');
+
+      assert.equal(
+          element._computeNavLink(offset, -1, projectsPerPage, filter, path),
+          '/admin/projects/q/filter:test');
+
+      assert.equal(
+          element._computeNavLink(offset, 1, projectsPerPage, null, path),
+          '/admin/projects,50');
+
+      assert.equal(
+          element._computeNavLink(offset, -1, projectsPerPage, null, path),
+          '/admin/projects');
+
+      filter = 'plugins/';
+      assert.equal(
+          element._computeNavLink(offset, 1, projectsPerPage, filter, path),
+          '/admin/projects/q/filter:plugins%252F,50');
+    });
+
+    test('_onValueChange', done => {
+      element.path = '/admin/projects';
+      sandbox.stub(page, 'show', url => {
+        assert.equal(url, '/admin/projects/q/filter:test');
+        done();
+      });
+      element.filter = 'test';
+    });
+
+    test('_filterChanged not reload when swap between falsy values', () => {
+      sandbox.stub(element, '_debounceReload');
+      element.filter = null;
+      element.filter = undefined;
+      element.filter = '';
+      assert.isFalse(element._debounceReload.called);
+    });
+
+    test('next button', done => {
+      element.itemsPerPage = 25;
+      projects = new Array(26);
+
+      flush(() => {
+        let loading;
+        assert.isFalse(element._hideNextArrow(loading, projects));
+        loading = true;
+        assert.isTrue(element._hideNextArrow(loading, projects));
+        loading = false;
+        assert.isFalse(element._hideNextArrow(loading, projects));
+        element._projects = [];
+        assert.isTrue(element._hideNextArrow(loading, element._projects));
+        projects = new Array(4);
+        assert.isTrue(element._hideNextArrow(loading, projects));
+        done();
+      });
+    });
+
+    test('prev button', () => {
+      flush(() => {
+        let offset = 0;
+        assert.isTrue(element._hidePrevArrow(offset));
+        offset = 5;
+        assert.isFalse(element._hidePrevArrow(offset));
+      });
+    });
+
+    test('createNew link appears correctly', () => {
+      assert.isFalse(element.$$('#createNewContainer').classList
+          .contains('show'));
+      element.createNew = true;
+      flushAsynchronousOperations();
+      assert.isTrue(element.$$('#createNewContainer').classList
+          .contains('show'));
+    });
+
+    test('fires create clicked event when button tapped', () => {
+      const clickHandler = sandbox.stub();
+      element.addEventListener('create-clicked', clickHandler);
+      element.createNew = true;
+      flushAsynchronousOperations();
+      MockInteractions.tap(element.$$('#createNew'));
+      assert.isTrue(clickHandler.called);
+    });
+
+    test('next/prev links change when path changes', () => {
+      const BRANCHES_PATH = '/path/to/branches';
+      const TAGS_PATH = '/path/to/tags';
+      sandbox.stub(element, '_computeNavLink');
+      element.offset = 0;
+      element.itemsPerPage = 25;
+      element.filter = '';
+      element.path = BRANCHES_PATH;
+      assert.equal(element._computeNavLink.lastCall.args[4], BRANCHES_PATH);
+      element.path = TAGS_PATH;
+      assert.equal(element._computeNavLink.lastCall.args[4], TAGS_PATH);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
index 9aa80b5..1b59d35 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.html
@@ -16,14 +16,25 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../bower_components/iron-overlay-behavior/iron-overlay-behavior.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-overlay">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         background: #fff;
         box-shadow: rgba(0, 0, 0, 0.3) 0 1px 3px;
       }
+
+      @media screen and (max-width: 50em) {
+        :host {
+          height: 100%;
+          left: 0;
+          position: fixed;
+          right: 0;
+          top: 0;
+        }
+      }
     </style>
     <content></content>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
index 9f271ed..ebf2f02 100644
--- a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay.js
@@ -14,27 +14,67 @@
 (function() {
   'use strict';
 
-  var AWAIT_MAX_ITERS = 10;
-  var AWAIT_STEP = 5;
+  const AWAIT_MAX_ITERS = 10;
+  const AWAIT_STEP = 5;
+  const BREAKPOINT_FULLSCREEN_OVERLAY = '50em';
 
   Polymer({
     is: 'gr-overlay',
 
+    /**
+     * Fired when a fullscreen overlay is closed
+     *
+     * @event fullscreen-overlay-closed
+     */
+
+    /**
+     * Fired when an overlay is opened in full screen mode
+     *
+     * @event fullscreen-overlay-opened
+     */
+
+    properties: {
+      _fullScreenOpen: {
+        type: Boolean,
+        value: false,
+      },
+    },
+
     behaviors: [
       Polymer.IronOverlayBehavior,
     ],
 
-    open: function() {
-      return new Promise(function(resolve) {
-        Polymer.IronOverlayBehaviorImpl.open.apply(this, arguments);
+    listeners: {
+      'iron-overlay-closed': '_close',
+      'iron-overlay-cancelled': '_close',
+    },
+
+    open(...args) {
+      return new Promise(resolve => {
+        Polymer.IronOverlayBehaviorImpl.open.apply(this, args);
+        if (this._isMobile()) {
+          this.fire('fullscreen-overlay-opened');
+          this._fullScreenOpen = true;
+        }
         this._awaitOpen(resolve);
-      }.bind(this));
+      });
+    },
+
+    _isMobile() {
+      return window.matchMedia(`(max-width: ${BREAKPOINT_FULLSCREEN_OVERLAY})`);
+    },
+
+    _close() {
+      if (this._fullScreenOpen) {
+        this.fire('fullscreen-overlay-closed');
+        this._fullScreenOpen = false;
+      }
     },
 
     /**
      * Override the focus stops that iron-overlay-behavior tries to find.
      */
-    setFocusStops: function(stops) {
+    setFocusStops(stops) {
       this.__firstFocusableNode = stops.start;
       this.__lastFocusableNode = stops.end;
     },
@@ -43,21 +83,21 @@
      * NOTE: (wyatta) Slightly hacky way to listen to the overlay actually
      * opening. Eventually replace with a direct way to listen to the overlay.
      */
-    _awaitOpen: function(fn) {
-      var iters = 0;
-      var step = function() {
-        this.async(function() {
+    _awaitOpen(fn) {
+      let iters = 0;
+      const step = () => {
+        this.async(() => {
           if (this.style.display !== 'none') {
             fn.call(this);
           } else if (iters++ < AWAIT_MAX_ITERS) {
             step.call(this);
           }
-        }.bind(this), AWAIT_STEP);
-      }.bind(this);
+        }, AWAIT_STEP);
+      };
       step.call(this);
     },
 
-    _id: function() {
+    _id() {
       return this.getAttribute('id') || 'global';
     },
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
new file mode 100644
index 0000000..3f427ca
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-overlay/gr-overlay_test.html
@@ -0,0 +1,91 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-overlay</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-overlay.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-overlay>
+      <div>content</div>
+    </gr-overlay>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-overlay tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('events are fired on fullscreen view', done => {
+      sandbox.stub(element, '_isMobile').returns(true);
+      const openHandler = sandbox.stub();
+      const closeHandler = sandbox.stub();
+      element.addEventListener('fullscreen-overlay-opened', openHandler);
+      element.addEventListener('fullscreen-overlay-closed', closeHandler);
+
+      element.open().then(() => {
+        assert.isTrue(element._isMobile.called);
+        assert.isTrue(element._fullScreenOpen);
+        assert.isTrue(openHandler.called);
+
+        element._close();
+        assert.isFalse(element._fullScreenOpen);
+        assert.isTrue(closeHandler.called);
+        done();
+      });
+    });
+
+    test('events are not fired on desktop view', done => {
+      sandbox.stub(element, '_isMobile').returns(false);
+      const openHandler = sandbox.stub();
+      const closeHandler = sandbox.stub();
+      element.addEventListener('fullscreen-overlay-opened', openHandler);
+      element.addEventListener('fullscreen-overlay-closed', closeHandler);
+
+      element.open().then(() => {
+        assert.isTrue(element._isMobile.called);
+        assert.isFalse(element._fullScreenOpen);
+        assert.isFalse(openHandler.called);
+
+        element._close();
+        assert.isFalse(element._fullScreenOpen);
+        assert.isFalse(closeHandler.called);
+        done();
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
new file mode 100644
index 0000000..f98a62c
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.html
@@ -0,0 +1,46 @@
+<!--
+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.
+-->
+<link rel="import" href="../../../behaviors/gr-tooltip-behavior/gr-tooltip-behavior.html">
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-page-nav">
+  <template>
+    <style include="shared-styles">
+      #nav {
+        background-color: #f5f5f5;
+        border: 1px solid #eee;
+        border-top: none;
+        height: 100%;
+        position: absolute;
+        top: 0;
+        width: 14em;
+      }
+      #nav.pinned {
+        position: fixed;
+      }
+      @media only screen and (max-width: 53em) {
+        #nav {
+          display: none;
+        }
+      }
+    </style>
+    <nav id="nav">
+      <content></content>
+    </nav>
+  </template>
+  <script src="gr-page-nav.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
new file mode 100644
index 0000000..4c38e3f
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav.js
@@ -0,0 +1,63 @@
+// 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.
+(function() {
+  'use strict';
+
+  Polymer({
+    is: 'gr-page-nav',
+
+    properties: {
+      _headerHeight: Number,
+    },
+
+    attached() {
+      this.listen(window, 'scroll', '_handleBodyScroll');
+    },
+
+    detached() {
+      this.unlisten(window, 'scroll', '_handleBodyScroll');
+    },
+
+    _handleBodyScroll() {
+      if (this._headerHeight === undefined) {
+        let top = this._getOffsetTop(this);
+        // Don't want to include the element that wraps around the nav, start
+        // with its parent.
+        for (let offsetParent = this._getOffsetParent(this.offsetParent);
+           offsetParent;
+           offsetParent = this._getOffsetParent(offsetParent)) {
+          top += this._getOffsetTop(offsetParent);
+        }
+        this._headerHeight = top;
+      }
+
+      this.$.nav.classList.toggle('pinned',
+          this._getScrollY() >= this._headerHeight);
+    },
+
+    /* Functions used for test purposes */
+    _getOffsetParent(element) {
+      if (!element || !element.offsetParent) { return ''; }
+      return element.offsetParent;
+    },
+
+    _getOffsetTop(element) {
+      return element.offsetTop;
+    },
+
+    _getScrollY() {
+      return window.scrollY;
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
new file mode 100644
index 0000000..7e426d7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-page-nav/gr-page-nav_test.html
@@ -0,0 +1,89 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-page-nav</title>
+
+<script src="../../../bower_components/page/page.js"></script>
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="gr-page-nav.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-page-nav>
+      <ul>
+        <li>item</li>
+      </ul>
+    </gr-page-nav>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-page-nav tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+      flushAsynchronousOperations();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('header is not pinned just below top', () => {
+      sandbox.stub(element, '_getOffsetParent', () => 0);
+      sandbox.stub(element, '_getOffsetTop', () => 10);
+      sandbox.stub(element, '_getScrollY', () => 5);
+      element._handleBodyScroll();
+      assert.isFalse(element.$.nav.classList.contains('pinned'));
+    });
+
+    test('header is pinned when scroll down the page', () => {
+      sandbox.stub(element, '_getOffsetParent', () => 0);
+      sandbox.stub(element, '_getOffsetTop', () => 10);
+      sandbox.stub(element, '_getScrollY', () => 25);
+      window.scrollY = 100;
+      element._handleBodyScroll();
+      assert.isTrue(element.$.nav.classList.contains('pinned'));
+    });
+
+    test('header is not pinned just below top with header set', () => {
+      element._headerHeight = 20;
+      sandbox.stub(element, '_getScrollY', () => 15);
+      window.scrollY = 100;
+      element._handleBodyScroll();
+      assert.isFalse(element.$.nav.classList.contains('pinned'));
+    });
+
+    test('header is pinned when scroll down the page with header set', () => {
+      element._headerHeight = 20;
+      sandbox.stub(element, '_getScrollY', () => 25);
+      window.scrollY = 100;
+      element._handleBodyScroll();
+      assert.isTrue(element.$.nav.classList.contains('pinned'));
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.html b/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.html
index 2c8be31a..15f44cf 100644
--- a/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.html
+++ b/polygerrit-ui/app/elements/shared/gr-placeholder/gr-placeholder.html
@@ -16,10 +16,11 @@
 
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-placeholder">
   <template>
-    <style>
+    <style include="shared-styles">
       main {
         margin: 2em auto;
         max-width: 46em;
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
new file mode 100644
index 0000000..4179b46
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth.js
@@ -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.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.Gerrit.Auth) { return; }
+
+  const MAX_GET_TOKEN_RETRIES = 2;
+
+  Gerrit.Auth = {
+    TYPE: {
+      XSRF_TOKEN: 'xsrf_token',
+      ACCESS_TOKEN: 'access_token',
+    },
+
+    _type: null,
+    _cachedTokenPromise: null,
+    _defaultOptions: {},
+    _retriesLeft: MAX_GET_TOKEN_RETRIES,
+
+    _getToken() {
+      return Promise.resolve(this._cachedTokenPromise);
+    },
+
+    /**
+     * Enable cross-domain authentication using OAuth access token.
+     *
+     * @param {
+     *   function(): !Promise<{
+     *     access_token: string,
+     *     expires_at: number
+     *   }>
+     * } getToken
+     * @param {?{credentials:string}} defaultOptions
+     */
+    setup(getToken, defaultOptions) {
+      this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+      if (getToken) {
+        this._type = Gerrit.Auth.TYPE.ACCESS_TOKEN;
+        this._cachedTokenPromise = null;
+        this._getToken = getToken;
+      }
+      this._defaultOptions = {};
+      if (defaultOptions) {
+        for (const p of ['credentials']) {
+          this._defaultOptions[p] = defaultOptions[p];
+        }
+      }
+    },
+
+    /**
+     * Perform network fetch with authentication.
+     *
+     * @param {string} url
+     * @param {Object=} opt_options
+     * @return {!Promise<!Response>}
+     */
+    fetch(url, opt_options) {
+      const options = Object.assign({
+        headers: new Headers(),
+      }, this._defaultOptions, opt_options);
+      if (this._type === Gerrit.Auth.TYPE.ACCESS_TOKEN) {
+        return this._getAccessToken().then(
+            accessToken => this._fetchWithAccessToken(url, options, accessToken)
+        );
+      } else {
+        return this._fetchWithXsrfToken(url, options);
+      }
+    },
+
+    _getCookie(name) {
+      const key = name + '=';
+      let result = '';
+      document.cookie.split(';').some(c => {
+        c = c.trim();
+        if (c.startsWith(key)) {
+          result = c.substring(key.length);
+          return true;
+        }
+      });
+      return result;
+    },
+
+    _isTokenValid(token) {
+      if (!token) { return false; }
+      if (!token.access_token || !token.expires_at) { return false; }
+
+      const expiration = new Date(parseInt(token.expires_at, 10) * 1000);
+      if (Date.now() >= expiration.getTime()) { return false; }
+
+      return true;
+    },
+
+    _fetchWithXsrfToken(url, options) {
+      if (options.method && options.method !== 'GET') {
+        const token = this._getCookie('XSRF_TOKEN');
+        if (token) {
+          options.headers.append('X-Gerrit-Auth', token);
+        }
+      }
+      options.credentials = 'same-origin';
+      return fetch(url, options);
+    },
+
+    /**
+     * @return {!Promise<string>}
+     */
+    _getAccessToken() {
+      if (!this._cachedTokenPromise) {
+        this._cachedTokenPromise = this._getToken();
+      }
+      return this._cachedTokenPromise.then(token => {
+        if (this._isTokenValid(token)) {
+          this._retriesLeft = MAX_GET_TOKEN_RETRIES;
+          return token.access_token;
+        }
+        if (this._retriesLeft > 0) {
+          this._retriesLeft--;
+          this._cachedTokenPromise = null;
+          return this._getAccessToken();
+        }
+        // Fall back to anonymous access.
+        return null;
+      });
+    },
+
+    _fetchWithAccessToken(url, options, accessToken) {
+      const params = [];
+
+      if (accessToken) {
+        params.push(`access_token=${accessToken}`);
+        const baseUrl = Gerrit.BaseUrlBehavior.getBaseUrl();
+        const pathname = baseUrl ?
+              url.substring(url.indexOf(baseUrl) + baseUrl.length) : url;
+        if (!pathname.startsWith('/a/')) {
+          url = url.replace(pathname, '/a' + pathname);
+        }
+      }
+
+      const method = options.method || 'GET';
+      let contentType = options.headers.get('Content-Type');
+
+      // For all requests with body, ensure json content type.
+      if (!contentType && options.body) {
+        contentType = 'application/json';
+      }
+
+      if (method !== 'GET') {
+        options.method = 'POST';
+        params.push(`$m=${method}`);
+        // If a request is not GET, and does not have a body, ensure text/plain
+        // content type.
+        if (!contentType) {
+          contentType = 'text/plain';
+        }
+      }
+
+      if (contentType) {
+        options.headers.set('Content-Type', 'text/plain');
+        params.push(`$ct=${encodeURIComponent(contentType)}`);
+      }
+
+      if (params.length) {
+        url = url + (url.indexOf('?') === -1 ? '?' : '&') + params.join('&');
+      }
+      return fetch(url, options);
+    },
+  };
+
+  window.Gerrit.Auth = Gerrit.Auth;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
new file mode 100644
index 0000000..c254e62
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-auth_test.html
@@ -0,0 +1,186 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-auth</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
+
+<script src="gr-auth.js"></script>
+
+<script>
+  suite('gr-auth', () => {
+    let auth;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      sandbox.stub(window, 'fetch').returns(Promise.resolve({ok: true}));
+      auth = Gerrit.Auth;
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    suite('default (xsrf token header)', () => {
+      test('GET', () => {
+        return auth.fetch('/url', {bar: 'bar'}).then(() => {
+          const [url, options] = fetch.lastCall.args;
+          assert.equal(url, '/url');
+          assert.equal(options.credentials, 'same-origin');
+        });
+      });
+
+      test('POST', () => {
+        sandbox.stub(auth, '_getCookie')
+            .withArgs('XSRF_TOKEN')
+            .returns('foobar');
+        return auth.fetch('/url', {method: 'POST'}).then(() => {
+          const [url, options] = fetch.lastCall.args;
+          assert.equal(url, '/url');
+          assert.equal(options.credentials, 'same-origin');
+          assert.equal(options.headers.get('X-Gerrit-Auth'), 'foobar');
+        });
+      });
+    });
+
+    suite('cors (access token)', () => {
+      let getToken;
+
+      const makeToken = opt_accessToken => {
+        return {
+          access_token: opt_accessToken || 'zbaz',
+          expires_at: new Date(Date.now() + 10e8).getTime(),
+        };
+      };
+
+      setup(() => {
+        getToken = sandbox.stub();
+        getToken.returns(Promise.resolve(makeToken()));
+        auth.setup(getToken);
+      });
+
+      test('base url support', () => {
+        const baseUrl = 'http://foo';
+        sandbox.stub(Gerrit.BaseUrlBehavior, 'getBaseUrl').returns(baseUrl);
+        return auth.fetch(baseUrl + '/url', {bar: 'bar'}).then(() => {
+          const [url] = fetch.lastCall.args;
+          assert.equal(url, 'http://foo/a/url?access_token=zbaz');
+        });
+      });
+
+      test('fetch not signed in', () => {
+        getToken.returns(Promise.resolve());
+        return auth.fetch('/url', {bar: 'bar'}).then(() => {
+          const [url, options] = fetch.lastCall.args;
+          assert.equal(url, '/url');
+          assert.equal(options.bar, 'bar');
+          assert.equal(Object.keys(options.headers).length, 0);
+        });
+      });
+
+      test('fetch signed in', () => {
+        return auth.fetch('/url', {bar: 'bar'}).then(() => {
+          const [url, options] = fetch.lastCall.args;
+          assert.equal(url, '/a/url?access_token=zbaz');
+          assert.equal(options.bar, 'bar');
+        });
+      });
+
+      test('getToken calls are cached', () => {
+        return Promise.all([
+          auth.fetch('/url-one'), auth.fetch('/url-two')]).then(() => {
+            assert.equal(getToken.callCount, 1);
+          });
+      });
+
+      test('getToken refreshes token', () => {
+        sandbox.stub(auth, '_isTokenValid');
+        auth._isTokenValid
+            .onFirstCall().returns(true)
+            .onSecondCall().returns(false)
+            .onThirdCall().returns(true);
+        return auth.fetch('/url-one').then(() => {
+          getToken.returns(Promise.resolve(makeToken('bzzbb')));
+          return auth.fetch('/url-two');
+        }).then(() => {
+          const [[firstUrl], [secondUrl]] = fetch.args;
+          assert.equal(firstUrl, '/a/url-one?access_token=zbaz');
+          assert.equal(secondUrl, '/a/url-two?access_token=bzzbb');
+        });
+      });
+
+      test('signed in token error falls back to anonymous', () => {
+        getToken.returns(Promise.resolve('rubbish'));
+        return auth.fetch('/url', {bar: 'bar'}).then(() => {
+          const [url, options] = fetch.lastCall.args;
+          assert.equal(url, '/url');
+          assert.equal(options.bar, 'bar');
+        });
+      });
+
+      test('_isTokenValid', () => {
+        assert.isFalse(auth._isTokenValid());
+        assert.isFalse(auth._isTokenValid({}));
+        assert.isFalse(auth._isTokenValid({access_token: 'foo'}));
+        assert.isFalse(auth._isTokenValid({
+          access_token: 'foo',
+          expires_at: Date.now()/1000 - 1,
+        }));
+        assert.isTrue(auth._isTokenValid({
+          access_token: 'foo',
+          expires_at: Date.now()/1000 + 1,
+        }));
+      });
+
+      test('HTTP PUT with content type', () => {
+        const originalOptions = {
+          method: 'PUT',
+          headers: new Headers({'Content-Type': 'mail/pigeon'}),
+        };
+        return auth.fetch('/url', originalOptions).then(() => {
+          assert.isTrue(getToken.called);
+          const [url, options] = fetch.lastCall.args;
+          assert.include(url, '$ct=mail%2Fpigeon');
+          assert.include(url, '$m=PUT');
+          assert.include(url, 'access_token=zbaz');
+          assert.equal(options.method, 'POST');
+          assert.equal(options.headers.get('Content-Type'), 'text/plain');
+        });
+      });
+
+      test('HTTP PUT without content type', () => {
+        const originalOptions = {
+          method: 'PUT',
+        };
+        return auth.fetch('/url', originalOptions).then(() => {
+          assert.isTrue(getToken.called);
+          const [url, options] = fetch.lastCall.args;
+          assert.include(url, '$ct=text%2Fplain');
+          assert.include(url, '$m=PUT');
+          assert.include(url, 'access_token=zbaz');
+          assert.equal(options.method, 'POST');
+          assert.equal(options.headers.get('Content-Type'), 'text/plain');
+        });
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
new file mode 100644
index 0000000..d0306b8
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.html
@@ -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.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<dom-module id="gr-etag-decorator">
+  <script src="gr-etag-decorator.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
new file mode 100644
index 0000000..3570081
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator.js
@@ -0,0 +1,99 @@
+// 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.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrEtagDecorator) { return; }
+
+  // Limit cache size because /change/detail responses may be large.
+  const MAX_CACHE_SIZE = 30;
+
+  function GrEtagDecorator() {
+    this._etags = new Map();
+    this._payloadCache = new Map();
+  }
+
+  /**
+   * Get or upgrade fetch options to include an ETag in a request.
+   * @param {string} url The URL being fetched.
+   * @param {!Object=} opt_options Optional options object in which to include
+   *     the ETag request header. If omitted, the result will be a fresh option
+   *     set.
+   * @return {!Object}
+   */
+  GrEtagDecorator.prototype.getOptions = function(url, opt_options) {
+    const etag = this._etags.get(url);
+    if (!etag) {
+      return opt_options;
+    }
+    const options = Object.assign({}, opt_options);
+    options.headers = options.headers || new Headers();
+    options.headers.set('If-None-Match', this._etags.get(url));
+    return options;
+  };
+
+  /**
+   * Handle a response to a request with ETag headers, potentially incorporating
+   * its result in the payload cache.
+   *
+   * @param {string} url The URL of the request.
+   * @param {!Response} response The response object.
+   * @param {string} payload The raw, unparsed JSON contained in the response
+   *     body. Note: because response.text() cannot be read twice, this must be
+   *     provided separately.
+   */
+  GrEtagDecorator.prototype.collect = function(url, response, payload) {
+    if (!response ||
+        !response.ok ||
+        response.status !== 200 ||
+        response.status === 304) {
+      // 304 Not Modified means etag is still valid.
+      return;
+    }
+    this._payloadCache.set(url, payload);
+    const etag = response.headers && response.headers.get('etag');
+    if (!etag) {
+      this._etags.delete(url);
+    } else {
+      this._etags.set(url, etag);
+      this._truncateCache();
+    }
+  };
+
+  /**
+   * Get the cached payload for a given URL.
+   * @param {string} url
+   * @return {string|undefined} Returns the unparsed JSON payload from the
+   *     cache.
+   */
+  GrEtagDecorator.prototype.getCachedPayload = function(url) {
+    return this._payloadCache.get(url);
+  };
+
+  /**
+   * Limit the cache size to MAX_CACHE_SIZE.
+   */
+  GrEtagDecorator.prototype._truncateCache = function() {
+    for (const url of this._etags.keys()) {
+      if (this._etags.size <= MAX_CACHE_SIZE) {
+        break;
+      }
+      this._etags.delete(url);
+      this._payloadCache.delete(url);
+    }
+  };
+
+  window.GrEtagDecorator = GrEtagDecorator;
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
new file mode 100644
index 0000000..77edae7
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-etag-decorator_test.html
@@ -0,0 +1,96 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-etag-decorator</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<script src="gr-etag-decorator.js"></script>
+
+<script>
+  suite('gr-etag-decorator', () => {
+    let etag;
+    let sandbox;
+
+    const fakeRequest = (opt_etag, opt_status) => {
+      const headers = new Headers();
+      if (opt_etag) {
+        headers.set('etag', opt_etag);
+      }
+      const status = opt_status || 200;
+      return {ok: true, status, headers};
+    };
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      etag = new GrEtagDecorator();
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('exists', () => {
+      assert.isOk(etag);
+    });
+
+    test('works', () => {
+      etag.collect('/foo', fakeRequest('bar'));
+      const options = etag.getOptions('/foo');
+      assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
+    });
+
+    test('updates etags', () => {
+      etag.collect('/foo', fakeRequest('bar'));
+      etag.collect('/foo', fakeRequest('baz'));
+      const options = etag.getOptions('/foo');
+      assert.strictEqual(options.headers.get('If-None-Match'), 'baz');
+    });
+
+    test('discards empty etags', () => {
+      etag.collect('/foo', fakeRequest('bar'));
+      etag.collect('/foo', fakeRequest());
+      const options = etag.getOptions('/foo', {headers: new Headers()});
+      assert.isNull(options.headers.get('If-None-Match'));
+    });
+
+    test('discards etags in order used', () => {
+      etag.collect('/foo', fakeRequest('bar'));
+      _.times(29, i => {
+        etag.collect('/qaz/' + i, fakeRequest('qaz'));
+      });
+      let options = etag.getOptions('/foo');
+      assert.strictEqual(options.headers.get('If-None-Match'), 'bar');
+      etag.collect('/zaq', fakeRequest('zaq'));
+      options = etag.getOptions('/foo', {headers: new Headers()});
+      assert.isNull(options.headers.get('If-None-Match'));
+    });
+
+    test('getCachedPayload', () => {
+      const payload = 'payload';
+      etag.collect('/foo', fakeRequest('bar'), payload);
+      assert.strictEqual(etag.getCachedPayload('/foo'), payload);
+      etag.collect('/foo', fakeRequest('bar', 304), 'garbage');
+      assert.strictEqual(etag.getCachedPayload('/foo'), payload);
+      etag.collect('/foo', fakeRequest('bar', 200), 'new payload');
+      assert.strictEqual(etag.getCachedPayload('/foo'), 'new payload');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
index 1e5fdaa..5afed95 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -14,13 +14,19 @@
 limitations under the License.
 -->
 
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/base-url-behavior/base-url-behavior.html">
 <link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
-<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/rest-client-behavior/rest-client-behavior.html">
+<link rel="import" href="gr-etag-decorator.html">
+
+<!-- NB: es6-promise Needed for IE11 and fetch polyfill support, see Issue 4308 -->
 <script src="../../../bower_components/es6-promise/dist/es6-promise.min.js"></script>
 <script src="../../../bower_components/fetch/fetch.js"></script>
 
 <dom-module id="gr-rest-api-interface">
-  <script src="gr-rest-api-interface.js"></script>
+  <!-- NB: Order is important, because of namespaced classes. -->
+  <script src="gr-auth.js"></script>
   <script src="gr-reviewer-updates-parser.js"></script>
+  <script src="gr-rest-api-interface.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index d618c17..0a42ada 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -14,78 +14,33 @@
 (function() {
   'use strict';
 
-  var DiffViewMode = {
+  const DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
   };
-  var JSON_PREFIX = ')]}\'';
-  var MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
-  var PARENT_PATCH_NUM = 'PARENT';
+  const JSON_PREFIX = ')]}\'';
+  const MAX_PROJECT_RESULTS = 25;
+  const MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
+  const PARENT_PATCH_NUM = 'PARENT';
+  const CHECK_SIGN_IN_DEBOUNCE_MS = 3 * 1000;
+  const CHECK_SIGN_IN_DEBOUNCER_NAME = 'checkCredentials';
+  const FAILED_TO_FETCH_ERROR = 'Failed to fetch';
 
-  var Requests = {
+  const Requests = {
     SEND_DIFF_DRAFT: 'sendDiffDraft',
   };
 
-  // Must be kept in sync with the ListChangesOption enum and protobuf.
-  var ListChangesOption = {
-    LABELS: 0,
-    DETAILED_LABELS: 8,
+  const CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE =
+      'Saving draft resulted in HTTP 200 (OK) but expected HTTP 201 (Created)';
+  const HEADER_REPORTING_BLACKLIST = /^set-cookie$/i;
 
-    // Return information on the current patch set of the change.
-    CURRENT_REVISION: 1,
-    ALL_REVISIONS: 2,
-
-    // If revisions are included, parse the commit object.
-    CURRENT_COMMIT: 3,
-    ALL_COMMITS: 4,
-
-    // If a patch set is included, include the files of the patch set.
-    CURRENT_FILES: 5,
-    ALL_FILES: 6,
-
-    // If accounts are included, include detailed account info.
-    DETAILED_ACCOUNTS: 7,
-
-    // Include messages associated with the change.
-    MESSAGES: 9,
-
-    // Include allowed actions client could perform.
-    CURRENT_ACTIONS: 10,
-
-    // Set the reviewed boolean for the caller.
-    REVIEWED: 11,
-
-    // Include download commands for the caller.
-    DOWNLOAD_COMMANDS: 13,
-
-    // Include patch set weblinks.
-    WEB_LINKS: 14,
-
-    // Include consistency check results.
-    CHECK: 15,
-
-    // Include allowed change actions client could perform.
-    CHANGE_ACTIONS: 16,
-
-    // Include a copy of commit messages including review footers.
-    COMMIT_FOOTERS: 17,
-
-    // Include push certificate information along with any patch sets.
-    PUSH_CERTIFICATES: 18,
-
-    // Include change's reviewer updates.
-    REVIEWER_UPDATES: 19,
-
-    // Set the submittable boolean.
-    SUBMITTABLE: 20,
-  };
 
   Polymer({
     is: 'gr-rest-api-interface',
 
     behaviors: [
-      Gerrit.BaseUrlBehavior,
       Gerrit.PathListBehavior,
+      Gerrit.RESTClientBehavior,
     ],
 
     /**
@@ -100,107 +55,385 @@
      * @event network-error
      */
 
+    /**
+     * Fired when credentials were rejected by server (e.g. expired).
+     *
+     * @event auth-error
+     */
+
     properties: {
       _cache: {
         type: Object,
-        value: {},  // Intentional to share the object across instances.
+        value: {}, // Intentional to share the object across instances.
       },
       _sharedFetchPromises: {
         type: Object,
-        value: {},  // Intentional to share the object across instances.
+        value: {}, // Intentional to share the object across instances.
       },
       _pendingRequests: {
         type: Object,
-        value: {},  // Intentional to share the object across instances.
+        value: {}, // Intentional to share the object across instances.
+      },
+      _etags: {
+        type: Object,
+        value: new GrEtagDecorator(), // Share across instances.
+      },
+      /**
+       * Used to maintain a mapping of changeNums to project names.
+       */
+      _projectLookup: {
+        type: Object,
+        value: {}, // Intentional to share the object across instances.
+      },
+      _auth: {
+        type: Object,
+        value: Gerrit.Auth, // Share across instances.
       },
     },
 
-    fetchJSON: function(url, opt_errFn, opt_cancelCondition, opt_params,
-        opt_opts) {
-      opt_opts = opt_opts || {};
-      // Issue 5715, This can be reverted back once
-      // iOS 10.3 and mac os 10.12.4 has the fetch api fix.
-      var fetchOptions = {
-        credentials: 'same-origin'
-      };
-      if (opt_opts.headers !== undefined) {
-        fetchOptions['headers'] = opt_opts.headers;
-      }
+    JSON_PREFIX,
 
-      var urlWithParams = this._urlWithParams(url, opt_params);
-      return fetch(urlWithParams, fetchOptions).then(function(response) {
+    /**
+     * Fetch JSON from url provided.
+     * Returns a Promise that resolves to a native Response.
+     * Doesn't do error checking. Supports cancel condition. Performs auth.
+     * Validates auth expiry errors.
+     * @param {string} url
+     * @param {?function(?Response, string=)=} opt_errFn
+     *    passed as null sometimes.
+     * @param {?function()=} opt_cancelCondition
+     *    passed as null sometimes.
+     * @param {?Object=} opt_params URL params, key-value hash.
+     * @param {?Object=} opt_options Fetch options.
+     */
+    _fetchRawJSON(url, opt_errFn, opt_cancelCondition, opt_params,
+        opt_options) {
+      const urlWithParams = this._urlWithParams(url, opt_params);
+      return this._auth.fetch(urlWithParams, opt_options).then(response => {
         if (opt_cancelCondition && opt_cancelCondition()) {
           response.body.cancel();
           return;
         }
-
-        if (!response.ok) {
-          if (opt_errFn) {
-            opt_errFn.call(null, response);
-            return;
+        return response;
+      }).catch(err => {
+        const isLoggedIn = !!this._cache['/accounts/self/detail'];
+        if (isLoggedIn && err && err.message === FAILED_TO_FETCH_ERROR) {
+          if (!this.isDebouncerActive(CHECK_SIGN_IN_DEBOUNCER_NAME)) {
+            this.checkCredentials();
           }
-          this.fire('server-error', {response: response});
+          this.debounce(CHECK_SIGN_IN_DEBOUNCER_NAME, this.checkCredentials,
+              CHECK_SIGN_IN_DEBOUNCE_MS);
           return;
         }
-
-        return this.getResponseObject(response);
-      }.bind(this)).catch(function(err) {
         if (opt_errFn) {
-          opt_errFn.call(null, null, err);
+          opt_errFn.call(undefined, null, err);
         } else {
           this.fire('network-error', {error: err});
-          throw err;
         }
         throw err;
-      }.bind(this));
+      });
     },
 
-    _urlWithParams: function(url, opt_params) {
+    /**
+     * Fetch JSON from url provided.
+     * Returns a Promise that resolves to a parsed response.
+     * Same as {@link _fetchRawJSON}, plus error handling.
+     * @param {string} url
+     * @param {?function(?Response, string=)=} opt_errFn
+     *    passed as null sometimes.
+     * @param {?function()=} opt_cancelCondition
+     *    passed as null sometimes.
+     * @param {?Object=} opt_params URL params, key-value hash.
+     * @param {?Object=} opt_options Fetch options.
+     */
+    fetchJSON(url, opt_errFn, opt_cancelCondition, opt_params, opt_options) {
+      return this._fetchRawJSON(
+          url, opt_errFn, opt_cancelCondition, opt_params, opt_options)
+          .then(response => {
+            if (!response) {
+              return;
+            }
+            if (!response.ok) {
+              if (opt_errFn) {
+                opt_errFn.call(null, response);
+                return;
+              }
+              this.fire('server-error', {response});
+              return;
+            }
+            return response && this.getResponseObject(response);
+          });
+    },
+
+    /**
+     * @param {string} url
+     * @param {?Object=} opt_params URL params, key-value hash.
+     * @return {string}
+     */
+    _urlWithParams(url, opt_params) {
       if (!opt_params) { return this.getBaseUrl() + url; }
 
-      var params = [];
-      for (var p in opt_params) {
+      const params = [];
+      for (const p in opt_params) {
+        if (!opt_params.hasOwnProperty(p)) { continue; }
         if (opt_params[p] == null) {
           params.push(encodeURIComponent(p));
           continue;
         }
-        var values = [].concat(opt_params[p]);
-        for (var i = 0; i < values.length; i++) {
-          params.push(
-            encodeURIComponent(p) + '=' +
-            encodeURIComponent(values[i]));
+        for (const value of [].concat(opt_params[p])) {
+          params.push(`${encodeURIComponent(p)}=${encodeURIComponent(value)}`);
         }
       }
       return this.getBaseUrl() + url + '?' + params.join('&');
     },
 
-    getResponseObject: function(response) {
-      return response.text().then(function(text) {
-        var result;
+    /**
+     * @param {!Object} response
+     * @return {?}
+     */
+    getResponseObject(response) {
+      return this._readResponsePayload(response)
+          .then(payload => payload.parsed);
+    },
+
+    /**
+     * @param {!Object} response
+     * @return {!Object}
+     */
+    _readResponsePayload(response) {
+      return response.text().then(text => {
+        let result;
         try {
-          result = JSON.parse(text.substring(JSON_PREFIX.length));
+          result = this._parsePrefixedJSON(text);
         } catch (_) {
           result = null;
         }
-        return result;
+        return {parsed: result, raw: text};
       });
     },
 
-    getConfig: function() {
+    /**
+     * @param {string} source
+     * @return {?}
+     */
+    _parsePrefixedJSON(source) {
+      return JSON.parse(source.substring(JSON_PREFIX.length));
+    },
+
+    getConfig() {
       return this._fetchSharedCacheURL('/config/server/info');
     },
 
-    getProjectConfig: function(project) {
+    getProject(project) {
+      return this._fetchSharedCacheURL(
+          '/projects/' + encodeURIComponent(project));
+    },
+
+    getProjectConfig(project) {
       return this._fetchSharedCacheURL(
           '/projects/' + encodeURIComponent(project) + '/config');
     },
 
-    getVersion: function() {
+    getProjectAccess(project) {
+      return this._fetchSharedCacheURL(
+          '/access/?project=' + encodeURIComponent(project));
+    },
+
+    saveProjectConfig(project, config, opt_errFn, opt_ctx) {
+      const encodeName = encodeURIComponent(project);
+      return this.send('PUT', `/projects/${encodeName}/config`, config,
+          opt_errFn, opt_ctx);
+    },
+
+    runProjectGC(project, opt_errFn, opt_ctx) {
+      if (!project) {
+        return '';
+      }
+      const encodeName = encodeURIComponent(project);
+      return this.send('POST', `/projects/${encodeName}/gc`, '',
+          opt_errFn, opt_ctx);
+    },
+
+    /**
+     * @param {?Object} config
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    createProject(config, opt_errFn, opt_ctx) {
+      if (!config.name) { return ''; }
+      const encodeName = encodeURIComponent(config.name);
+      return this.send('PUT', `/projects/${encodeName}`, config, opt_errFn,
+          opt_ctx);
+    },
+
+    /**
+     * @param {?Object} config
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    createGroup(config, opt_errFn, opt_ctx) {
+      if (!config.name) { return ''; }
+      const encodeName = encodeURIComponent(config.name);
+      return this.send('PUT', `/groups/${encodeName}`, config, opt_errFn,
+          opt_ctx);
+    },
+
+    getGroupConfig(group) {
+      const encodeName = encodeURIComponent(group);
+      return this.fetchJSON(`/groups/${encodeName}/detail`);
+    },
+
+    /**
+     * @param {string} project
+     * @param {string} ref
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    deleteProjectBranches(project, ref, opt_errFn, opt_ctx) {
+      if (!project || !ref) {
+        return '';
+      }
+      const encodeName = encodeURIComponent(project);
+      const encodeRef = encodeURIComponent(ref);
+      return this.send('DELETE',
+          `/projects/${encodeName}/branches/${encodeRef}`, '',
+          opt_errFn, opt_ctx);
+    },
+
+    /**
+     * @param {string} project
+     * @param {string} ref
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    deleteProjectTags(project, ref, opt_errFn, opt_ctx) {
+      if (!project || !ref) {
+        return '';
+      }
+      const encodeName = encodeURIComponent(project);
+      const encodeRef = encodeURIComponent(ref);
+      return this.send('DELETE',
+          `/projects/${encodeName}/tags/${encodeRef}`, '',
+          opt_errFn, opt_ctx);
+    },
+
+    /**
+     * @param {string} name
+     * @param {string} branch
+     * @param {string} revision
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    createProjectBranch(name, branch, revision, opt_errFn, opt_ctx) {
+      if (!name || !branch || !revision) { return ''; }
+      const encodeName = encodeURIComponent(name);
+      const encodeBranch = encodeURIComponent(branch);
+      return this.send('PUT',
+          `/projects/${encodeName}/branches/${encodeBranch}`,
+          revision, opt_errFn, opt_ctx);
+    },
+
+    /**
+     * @param {string} name
+     * @param {string} tag
+     * @param {string} revision
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    createProjectTag(name, tag, revision, opt_errFn, opt_ctx) {
+      if (!name || !tag || !revision) { return ''; }
+      const encodeName = encodeURIComponent(name);
+      const encodeTag = encodeURIComponent(tag);
+      return this.send('PUT', `/projects/${encodeName}/tags/${encodeTag}`,
+          revision, opt_errFn, opt_ctx);
+    },
+
+    /**
+     * @param {!string} groupName
+     * @returns {!Promise<boolean>}
+     */
+    getIsGroupOwner(groupName) {
+      const encodeName = encodeURIComponent(groupName);
+      return this._fetchSharedCacheURL(`/groups/?owned&q=${encodeName}`)
+          .then(configs => configs.hasOwnProperty(groupName));
+    },
+
+    getGroupMembers(groupName) {
+      const encodeName = encodeURIComponent(groupName);
+      return this.send('GET', `/groups/${encodeName}/members/`)
+          .then(response => this.getResponseObject(response));
+    },
+
+    getIncludedGroup(groupName) {
+      const encodeName = encodeURIComponent(groupName);
+      return this.send('GET', `/groups/${encodeName}/groups/`)
+          .then(response => this.getResponseObject(response));
+    },
+
+    saveGroupName(groupId, name) {
+      const encodeId = encodeURIComponent(groupId);
+      return this.send('PUT', `/groups/${encodeId}/name`, {name});
+    },
+
+    saveGroupOwner(groupId, ownerId) {
+      const encodeId = encodeURIComponent(groupId);
+      return this.send('PUT', `/groups/${encodeId}/owner`, {owner: ownerId});
+    },
+
+    saveGroupDescription(groupId, description) {
+      const encodeId = encodeURIComponent(groupId);
+      return this.send('PUT', `/groups/${encodeId}/description`,
+          {description});
+    },
+
+    saveGroupOptions(groupId, options) {
+      const encodeId = encodeURIComponent(groupId);
+      return this.send('PUT', `/groups/${encodeId}/options`, options);
+    },
+
+    getGroupAuditLog(group) {
+      return this._fetchSharedCacheURL('/groups/' + group + '/log.audit');
+    },
+
+    saveGroupMembers(groupName, groupMembers) {
+      const encodeName = encodeURIComponent(groupName);
+      const encodeMember = encodeURIComponent(groupMembers);
+      return this.send('PUT', `/groups/${encodeName}/members/${encodeMember}`)
+          .then(response => this.getResponseObject(response));
+    },
+
+    saveIncludedGroup(groupName, includedGroup, opt_errFn) {
+      const encodeName = encodeURIComponent(groupName);
+      const encodeIncludedGroup = encodeURIComponent(includedGroup);
+      return this.send('PUT',
+          `/groups/${encodeName}/groups/${encodeIncludedGroup}`, null,
+          opt_errFn).then(response => {
+            if (response.ok) {
+              return this.getResponseObject(response);
+            }
+          });
+    },
+
+    deleteGroupMembers(groupName, groupMembers) {
+      const encodeName = encodeURIComponent(groupName);
+      const encodeMember = encodeURIComponent(groupMembers);
+      return this.send('DELETE',
+          `/groups/${encodeName}/members/${encodeMember}`);
+    },
+
+    deleteIncludedGroup(groupName, includedGroup) {
+      const encodeName = encodeURIComponent(groupName);
+      const encodeIncludedGroup = encodeURIComponent(includedGroup);
+      return this.send('DELETE',
+          `/groups/${encodeName}/groups/${encodeIncludedGroup}`);
+    },
+
+    getVersion() {
       return this._fetchSharedCacheURL('/config/server/version');
     },
 
-    getDiffPreferences: function() {
-      return this.getLoggedIn().then(function(loggedIn) {
+    getDiffPreferences() {
+      return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           return this._fetchSharedCacheURL('/accounts/self/preferences.diff');
         }
@@ -224,10 +457,15 @@
           tab_size: 8,
           theme: 'DEFAULT',
         });
-      }.bind(this));
+      });
     },
 
-    savePreferences: function(prefs, opt_errFn, opt_ctx) {
+    /**
+     * @param {?Object} prefs
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    savePreferences(prefs, opt_errFn, opt_ctx) {
       // Note (Issue 5142): normalize the download scheme with lower case before
       // saving.
       if (prefs.download_scheme) {
@@ -238,127 +476,209 @@
           opt_ctx);
     },
 
-    saveDiffPreferences: function(prefs, opt_errFn, opt_ctx) {
+    /**
+     * @param {?Object} prefs
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    saveDiffPreferences(prefs, opt_errFn, opt_ctx) {
       // Invalidate the cache.
       this._cache['/accounts/self/preferences.diff'] = undefined;
       return this.send('PUT', '/accounts/self/preferences.diff', prefs,
           opt_errFn, opt_ctx);
     },
 
-    getAccount: function() {
-      return this._fetchSharedCacheURL('/accounts/self/detail', function(resp) {
+    getAccount() {
+      return this._fetchSharedCacheURL('/accounts/self/detail', resp => {
         if (resp.status === 403) {
           this._cache['/accounts/self/detail'] = null;
         }
-      }.bind(this));
+      });
     },
 
-    getAccountEmails: function() {
+    /**
+     * @param {string} userId the ID of the user usch as an email address.
+     * @return {!Promise<!Object>}
+     */
+    getAccountDetails(userId) {
+      return this.fetchJSON(`/accounts/${encodeURIComponent(userId)}/detail`);
+    },
+
+    getAccountEmails() {
       return this._fetchSharedCacheURL('/accounts/self/emails');
     },
 
-    addAccountEmail: function(email, opt_errFn, opt_ctx) {
+    /**
+     * @param {string} email
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    addAccountEmail(email, opt_errFn, opt_ctx) {
       return this.send('PUT', '/accounts/self/emails/' +
           encodeURIComponent(email), null, opt_errFn, opt_ctx);
     },
 
-    deleteAccountEmail: function(email, opt_errFn, opt_ctx) {
+    /**
+     * @param {string} email
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    deleteAccountEmail(email, opt_errFn, opt_ctx) {
       return this.send('DELETE', '/accounts/self/emails/' +
           encodeURIComponent(email), null, opt_errFn, opt_ctx);
     },
 
-    setPreferredAccountEmail: function(email, opt_errFn, opt_ctx) {
+    /**
+     * @param {string} email
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    setPreferredAccountEmail(email, opt_errFn, opt_ctx) {
       return this.send('PUT', '/accounts/self/emails/' +
           encodeURIComponent(email) + '/preferred', null,
-          opt_errFn, opt_ctx).then(function() {
-        // If result of getAccountEmails is in cache, update it in the cache
-        // so we don't have to invalidate it.
-        var cachedEmails = this._cache['/accounts/self/emails'];
-        if (cachedEmails) {
-          var emails = cachedEmails.map(function(entry) {
-            if (entry.email === email) {
-              return {email: email, preferred: true};
-            } else {
-              return {email: email};
+          opt_errFn, opt_ctx).then(() => {
+            // If result of getAccountEmails is in cache, update it in the cache
+            // so we don't have to invalidate it.
+            const cachedEmails = this._cache['/accounts/self/emails'];
+            if (cachedEmails) {
+              const emails = cachedEmails.map(entry => {
+                if (entry.email === email) {
+                  return {email, preferred: true};
+                } else {
+                  return {email};
+                }
+              });
+              this._cache['/accounts/self/emails'] = emails;
             }
           });
-          this._cache['/accounts/self/emails'] = emails;
-        }
-      }.bind(this));
     },
 
-    setAccountName: function(name, opt_errFn, opt_ctx) {
-      return this.send('PUT', '/accounts/self/name', {name: name}, opt_errFn,
-          opt_ctx).then(function(response) {
-            // If result of getAccount is in cache, update it in the cache
-            // so we don't have to invalidate it.
-            var cachedAccount = this._cache['/accounts/self/detail'];
-            if (cachedAccount) {
-              return this.getResponseObject(response).then(function(newName) {
-                // Replace object in cache with new object to force UI updates.
-                // TODO(logan): Polyfill for Object.assign in IE
-                this._cache['/accounts/self/detail'] = Object.assign(
-                    {}, cachedAccount, {name: newName});
-              }.bind(this));
-            }
-          }.bind(this));
+    /**
+     * @param {?Object} obj
+     */
+    _updateCachedAccount(obj) {
+      // If result of getAccount is in cache, update it in the cache
+      // so we don't have to invalidate it.
+      const cachedAccount = this._cache['/accounts/self/detail'];
+      if (cachedAccount) {
+        // Replace object in cache with new object to force UI updates.
+        this._cache['/accounts/self/detail'] =
+            Object.assign({}, cachedAccount, obj);
+      }
     },
 
-    setAccountStatus: function(status, opt_errFn, opt_ctx) {
-      return this.send('PUT', '/accounts/self/status', {status: status},
-          opt_errFn, opt_ctx).then(function(response) {
-            // If result of getAccount is in cache, update it in the cache
-            // so we don't have to invalidate it.
-            var cachedAccount = this._cache['/accounts/self/detail'];
-            if (cachedAccount) {
-              return this.getResponseObject(response).then(function(newStatus) {
-                // Replace object in cache with new object to force UI updates.
-                // TODO(logan): Polyfill for Object.assign in IE
-                this._cache['/accounts/self/detail'] = Object.assign(
-                    {}, cachedAccount, {status: newStatus});
-              }.bind(this));
-            }
-          }.bind(this));
+    /**
+     * @param {string} name
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    setAccountName(name, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/name', {name}, opt_errFn, opt_ctx)
+          .then(response => this.getResponseObject(response)
+              .then(newName => this._updateCachedAccount({name: newName})));
     },
 
-    getAccountGroups: function() {
+    /**
+     * @param {string} username
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    setAccountUsername(username, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/username', {username}, opt_errFn,
+          opt_ctx).then(response => this.getResponseObject(response)
+              .then(newName => this._updateCachedAccount({username: newName})));
+    },
+
+    /**
+     * @param {string} status
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    setAccountStatus(status, opt_errFn, opt_ctx) {
+      return this.send('PUT', '/accounts/self/status', {status},
+          opt_errFn, opt_ctx).then(response => this.getResponseObject(response)
+              .then(newStatus => this._updateCachedAccount(
+                  {status: newStatus})));
+    },
+
+    getAccountStatus(userId) {
+      return this.fetchJSON(`/accounts/${encodeURIComponent(userId)}/status`);
+    },
+
+    getAccountGroups() {
       return this._fetchSharedCacheURL('/accounts/self/groups');
     },
 
-    getAccountCapabilities: function(opt_params) {
-      var queryString = '';
+    getAccountAgreements() {
+      return this._fetchSharedCacheURL('/accounts/self/agreements');
+    },
+
+    /**
+     * @param {string=} opt_params
+     */
+    getAccountCapabilities(opt_params) {
+      let queryString = '';
       if (opt_params) {
         queryString = '?q=' + opt_params
-            .map(function(param) { return encodeURIComponent(param); })
+            .map(param => { return encodeURIComponent(param); })
             .join('&q=');
       }
       return this._fetchSharedCacheURL('/accounts/self/capabilities' +
           queryString);
     },
 
-    getLoggedIn: function() {
-      return this.getAccount().then(function(account) {
+    getLoggedIn() {
+      return this.getAccount().then(account => {
         return account != null;
       });
     },
 
-    checkCredentials: function() {
-      // Skip the REST response cache.
-      return this.fetchJSON('/accounts/self/detail');
+    getIsAdmin() {
+      return this.getLoggedIn().then(isLoggedIn => {
+        if (isLoggedIn) {
+          return this.getAccountCapabilities();
+        } else {
+          return Promise.resolve();
+        }
+      }).then(capabilities => {
+        return capabilities && capabilities.administrateServer;
+      });
     },
 
-    getPreferences: function() {
-      return this.getLoggedIn().then(function(loggedIn) {
+    checkCredentials() {
+      // Skip the REST response cache.
+      return this._fetchRawJSON('/accounts/self/detail').then(response => {
+        if (!response) { return; }
+        if (response.status === 403) {
+          this.fire('auth-error');
+          this._cache['/accounts/self/detail'] = null;
+        } else if (response.ok) {
+          return this.getResponseObject(response);
+        }
+      }).then(response => {
+        if (response) {
+          this._cache['/accounts/self/detail'] = response;
+        }
+        return response;
+      });
+    },
+
+    getDefaultPreferences() {
+      return this._fetchSharedCacheURL('/config/server/preferences');
+    },
+
+    getPreferences() {
+      return this.getLoggedIn().then(loggedIn => {
         if (loggedIn) {
           return this._fetchSharedCacheURL('/accounts/self/preferences').then(
-              function(res) {
-            if (this._isNarrowScreen()) {
-              res.default_diff_view = DiffViewMode.UNIFIED;
-            } else {
-              res.default_diff_view = res.diff_view;
-            }
-            return Promise.resolve(res);
-          }.bind(this));
+              res => {
+                if (this._isNarrowScreen()) {
+                  res.default_diff_view = DiffViewMode.UNIFIED;
+                } else {
+                  res.default_diff_view = res.diff_view;
+                }
+                return Promise.resolve(res);
+              });
         }
 
         return Promise.resolve({
@@ -367,27 +687,41 @@
               DiffViewMode.UNIFIED : DiffViewMode.SIDE_BY_SIDE,
           diff_view: 'SIDE_BY_SIDE',
         });
-      }.bind(this));
+      });
     },
 
-    getWatchedProjects: function() {
+    getWatchedProjects() {
       return this._fetchSharedCacheURL('/accounts/self/watched.projects');
     },
 
-    saveWatchedProjects: function(projects, opt_errFn, opt_ctx) {
+    /**
+     * @param {string} projects
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    saveWatchedProjects(projects, opt_errFn, opt_ctx) {
       return this.send('POST', '/accounts/self/watched.projects', projects,
           opt_errFn, opt_ctx)
-          .then(function(response) {
+          .then(response => {
             return this.getResponseObject(response);
-          }.bind(this));
+          });
     },
 
-    deleteWatchedProjects: function(projects, opt_errFn, opt_ctx) {
+    /**
+     * @param {string} projects
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    deleteWatchedProjects(projects, opt_errFn, opt_ctx) {
       return this.send('POST', '/accounts/self/watched.projects:delete',
           projects, opt_errFn, opt_ctx);
     },
 
-    _fetchSharedCacheURL: function(url, opt_errFn) {
+    /**
+     * @param {string} url
+     * @param {function(?Response, string=)=} opt_errFn
+     */
+    _fetchSharedCacheURL(url, opt_errFn) {
       if (this._sharedFetchPromises[url]) {
         return this._sharedFetchPromises[url];
       }
@@ -395,129 +729,238 @@
       if (this._cache[url] !== undefined) {
         return Promise.resolve(this._cache[url]);
       }
-      this._sharedFetchPromises[url] = this.fetchJSON(url, opt_errFn).then(
-        function(response) {
-          if (response !== undefined) {
-            this._cache[url] = response;
-          }
-          this._sharedFetchPromises[url] = undefined;
-          return response;
-        }.bind(this)).catch(function(err) {
-          this._sharedFetchPromises[url] = undefined;
-          throw err;
-        }.bind(this));
+      this._sharedFetchPromises[url] = this.fetchJSON(url, opt_errFn)
+          .then(response => {
+            if (response !== undefined) {
+              this._cache[url] = response;
+            }
+            this._sharedFetchPromises[url] = undefined;
+            return response;
+          }).catch(err => {
+            this._sharedFetchPromises[url] = undefined;
+            throw err;
+          });
       return this._sharedFetchPromises[url];
     },
 
-    _isNarrowScreen: function() {
+    _isNarrowScreen() {
       return window.innerWidth < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX;
     },
 
-    getChanges: function(changesPerPage, opt_query, opt_offset) {
-      var options = this._listChangesOptionsToHex(
-          ListChangesOption.LABELS,
-          ListChangesOption.DETAILED_ACCOUNTS
+    /**
+     * @param {number=} opt_changesPerPage
+     * @param {string|!Array<string>=} opt_query A query or an array of queries.
+     * @param {number|string=} opt_offset
+     * @param {!Object=} opt_options
+     * @return {?Array<!Object>|?Array<!Array<!Object>>} If opt_query is an
+     *     array, fetchJSON will return an array of arrays of changeInfos. If it
+     *     is unspecified or a string, fetchJSON will return an array of
+     *     changeInfos.
+     */
+    getChanges(opt_changesPerPage, opt_query, opt_offset, opt_options) {
+      const options = opt_options || this.listChangesOptionsToHex(
+          this.ListChangesOption.LABELS,
+          this.ListChangesOption.DETAILED_ACCOUNTS
       );
       // Issue 4524: respect legacy token with max sortkey.
       if (opt_offset === 'n,z') {
         opt_offset = 0;
       }
-      var params = {
-        n: changesPerPage,
+      const params = {
         O: options,
         S: opt_offset || 0,
       };
+      if (opt_changesPerPage) { params.n = opt_changesPerPage; }
       if (opt_query && opt_query.length > 0) {
         params.q = opt_query;
       }
-      return this.fetchJSON('/changes/', null, null, params);
-    },
-
-    getDashboardChanges: function() {
-      var options = this._listChangesOptionsToHex(
-          ListChangesOption.LABELS,
-          ListChangesOption.DETAILED_ACCOUNTS,
-          ListChangesOption.REVIEWED
-      );
-      var params = {
-        O: options,
-        q: [
-          'is:open owner:self',
-          'is:open ((reviewer:self -owner:self -star:ignore) OR assignee:self)',
-          'is:closed (owner:self OR reviewer:self OR assignee:self) -age:4w ' +
-            'limit:10',
-        ],
+      const iterateOverChanges = arr => {
+        for (const change of (arr || [])) {
+          this._maybeInsertInLookup(change);
+        }
       };
-      return this.fetchJSON('/changes/', null, null, params);
+      return this.fetchJSON('/changes/', null, null, params).then(response => {
+        // Response may be an array of changes OR an array of arrays of
+        // changes.
+        if (opt_query instanceof Array) {
+          for (const arr of response) {
+            iterateOverChanges(arr);
+          }
+        } else {
+          iterateOverChanges(response);
+        }
+        return response;
+      });
     },
 
-    getChangeActionURL: function(changeNum, opt_patchNum, endpoint) {
-      return this._changeBaseURL(changeNum, opt_patchNum) + endpoint;
+    /**
+     * Inserts a change into _projectLookup iff it has a valid structure.
+     * @param {?{ _number: (number|string) }} change
+     */
+    _maybeInsertInLookup(change) {
+      if (change && change.project && change._number) {
+        this.setInProjectLookup(change._number, change.project);
+      }
     },
 
-    getChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) {
-      var options = this._listChangesOptionsToHex(
-          ListChangesOption.ALL_COMMITS,
-          ListChangesOption.ALL_REVISIONS,
-          ListChangesOption.CHANGE_ACTIONS,
-          ListChangesOption.CURRENT_ACTIONS,
-          ListChangesOption.DOWNLOAD_COMMANDS,
-          ListChangesOption.SUBMITTABLE,
-          ListChangesOption.WEB_LINKS
+    /**
+     * TODO (beckysiegel) this needs to be rewritten with the optional param
+     * at the end.
+     *
+     * @param {number|string} changeNum
+     * @param {?number|string=} opt_patchNum passed as null sometimes.
+     * @param {?=} endpoint
+     * @return {!Promise<string>}
+     */
+    getChangeActionURL(changeNum, opt_patchNum, endpoint) {
+      return this._changeBaseURL(changeNum, opt_patchNum)
+          .then(url => url + endpoint);
+    },
+
+    /**
+     * @param {number|string} changeNum
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {function()=} opt_cancelCondition
+     */
+    getChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
+      const options = this.listChangesOptionsToHex(
+          this.ListChangesOption.ALL_COMMITS,
+          this.ListChangesOption.ALL_REVISIONS,
+          this.ListChangesOption.CHANGE_ACTIONS,
+          this.ListChangesOption.CURRENT_ACTIONS,
+          this.ListChangesOption.DOWNLOAD_COMMANDS,
+          this.ListChangesOption.SUBMITTABLE,
+          this.ListChangesOption.WEB_LINKS
       );
       return this._getChangeDetail(
           changeNum, options, opt_errFn, opt_cancelCondition)
-            .then(GrReviewerUpdatesParser.parse);
+          .then(GrReviewerUpdatesParser.parse);
     },
 
-    getDiffChangeDetail: function(changeNum, opt_errFn, opt_cancelCondition) {
-      var options = this._listChangesOptionsToHex(
-          ListChangesOption.ALL_REVISIONS
+    /**
+     * @param {number|string} changeNum
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {function()=} opt_cancelCondition
+     */
+    getDiffChangeDetail(changeNum, opt_errFn, opt_cancelCondition) {
+      const params = this.listChangesOptionsToHex(
+          this.ListChangesOption.ALL_REVISIONS
       );
-      return this._getChangeDetail(changeNum, options, opt_errFn,
+      return this._getChangeDetail(changeNum, params, opt_errFn,
           opt_cancelCondition);
     },
 
-    _getChangeDetail: function(changeNum, options, opt_errFn,
+    /**
+     * @param {number|string} changeNum
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {function()=} opt_cancelCondition
+     */
+    _getChangeDetail(changeNum, params, opt_errFn,
         opt_cancelCondition) {
-      return this.fetchJSON(
-          this.getChangeActionURL(changeNum, null, '/detail'),
-          opt_errFn,
-          opt_cancelCondition,
-          {O: options});
+      return this.getChangeActionURL(changeNum, null, '/detail').then(url => {
+        const urlWithParams = this._urlWithParams(url, params);
+        return this._fetchRawJSON(
+            url,
+            opt_errFn,
+            opt_cancelCondition,
+            {O: params},
+            this._etags.getOptions(urlWithParams))
+            .then(response => {
+              if (response && response.status === 304) {
+                return Promise.resolve(this._parsePrefixedJSON(
+                    this._etags.getCachedPayload(urlWithParams)));
+              }
+
+              if (response && !response.ok) {
+                if (opt_errFn) {
+                  opt_errFn.call(null, response);
+                } else {
+                  this.fire('server-error', {response});
+                }
+                return;
+              }
+
+              const payloadPromise = response ?
+                  this._readResponsePayload(response) :
+                  Promise.resolve(null);
+
+              return payloadPromise.then(payload => {
+                if (!payload) { return null; }
+
+                this._etags.collect(urlWithParams, response, payload.raw);
+                this._maybeInsertInLookup(payload);
+
+                return payload.parsed;
+              });
+            });
+      });
     },
 
-    getChangeCommitInfo: function(changeNum, patchNum) {
-      return this.fetchJSON(
-          this.getChangeActionURL(changeNum, patchNum, '/commit?links'));
+    /**
+     * @param {number|string} changeNum
+     * @param {number|string} patchNum
+     */
+    getChangeCommitInfo(changeNum, patchNum) {
+      return this._getChangeURLAndFetch(changeNum, '/commit?links', patchNum);
     },
 
-    getChangeFiles: function(changeNum, patchRange) {
-      var endpoint = '/files';
+    /**
+     * @param {number|string} changeNum
+     * @param {!Promise<?Object>} patchRange
+     */
+    getChangeFiles(changeNum, patchRange) {
+      let endpoint = '/files';
       if (patchRange.basePatchNum !== 'PARENT') {
         endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum);
       }
-      return this.fetchJSON(
-          this.getChangeActionURL(changeNum, patchRange.patchNum, endpoint));
+      return this._getChangeURLAndFetch(changeNum, endpoint,
+          patchRange.patchNum);
     },
 
-    getChangeFilesAsSpeciallySortedArray: function(changeNum, patchRange) {
+    /**
+     * @param {number|string} changeNum
+     * @param {!Promise<?Object>} patchRange
+     */
+    getChangeEditFiles(changeNum, patchRange) {
+      let endpoint = '/edit?list';
+      if (patchRange.basePatchNum !== 'PARENT') {
+        endpoint += '?base=' + encodeURIComponent(patchRange.basePatchNum);
+      }
+      return this._getChangeURLAndFetch(changeNum, endpoint);
+    },
+
+    getChangeFilesAsSpeciallySortedArray(changeNum, patchRange) {
       return this.getChangeFiles(changeNum, patchRange).then(
           this._normalizeChangeFilesResponse.bind(this));
     },
 
-    getChangeFilePathsAsSpeciallySortedArray: function(changeNum, patchRange) {
-      return this.getChangeFiles(changeNum, patchRange).then(function(files) {
-        return Object.keys(files).sort(this.specialFilePathCompare);
-      }.bind(this));
+    getChangeEditFilesAsSpeciallySortedArray(changeNum, patchRange) {
+      return this.getChangeEditFiles(changeNum, patchRange).then(files =>
+            this._normalizeChangeFilesResponse(files.files));
     },
 
-    _normalizeChangeFilesResponse: function(response) {
+    /**
+     * The closure compiler doesn't realize this.specialFilePathCompare is
+     * valid.
+     * @suppress {checkTypes}
+     */
+    getChangeFilePathsAsSpeciallySortedArray(changeNum, patchRange) {
+      return this.getChangeFiles(changeNum, patchRange).then(files => {
+        return Object.keys(files).sort(this.specialFilePathCompare);
+      });
+    },
+
+    /**
+     * The closure compiler doesn't realize this.specialFilePathCompare is
+     * valid.
+     * @suppress {checkTypes}
+     */
+    _normalizeChangeFilesResponse(response) {
       if (!response) { return []; }
-      var paths = Object.keys(response).sort(this.specialFilePathCompare);
-      var files = [];
-      for (var i = 0; i < paths.length; i++) {
-        var info = response[paths[i]];
+      const paths = Object.keys(response).sort(this.specialFilePathCompare);
+      const files = [];
+      for (let i = 0; i < paths.length; i++) {
+        const info = response[paths[i]];
         info.__path = paths[i];
         info.lines_inserted = info.lines_inserted || 0;
         info.lines_deleted = info.lines_deleted || 0;
@@ -526,254 +969,438 @@
       return files;
     },
 
-    getChangeRevisionActions: function(changeNum, patchNum) {
+    getChangeRevisionActions(changeNum, patchNum) {
+      return this._getChangeURLAndFetch(changeNum, '/actions', patchNum)
+          .then(revisionActions => {
+            // The rebase button on change screen is always enabled.
+            if (revisionActions.rebase) {
+              revisionActions.rebase.rebaseOnCurrent =
+                  !!revisionActions.rebase.enabled;
+              revisionActions.rebase.enabled = true;
+            }
+            return revisionActions;
+          });
+    },
+
+    /**
+     * @param {number|string} changeNum
+     * @param {string} inputVal
+     * @param {function(?Response, string=)=} opt_errFn
+     */
+    getChangeSuggestedReviewers(changeNum, inputVal, opt_errFn) {
+      const params = {n: 10};
+      if (inputVal) { params.q = inputVal; }
+      return this._getChangeURLAndFetch(changeNum, '/suggest_reviewers', null,
+          opt_errFn, null, params);
+    },
+
+    /**
+     * @param {number|string} changeNum
+     */
+    getChangeIncludedIn(changeNum) {
+      return this._getChangeURLAndFetch(changeNum, '/in', null);
+    },
+
+    _computeFilter(filter) {
+      if (filter && filter.startsWith('^')) {
+        filter = '&r=' + encodeURIComponent(filter);
+      } else if (filter) {
+        filter = '&m=' + encodeURIComponent(filter);
+      } else {
+        filter = '';
+      }
+      return filter;
+    },
+
+    /**
+     * @param {string} filter
+     * @param {number} groupsPerPage
+     * @param {number=} opt_offset
+     * @return {!Promise<?Object>}
+     */
+    getGroups(filter, groupsPerPage, opt_offset) {
+      const offset = opt_offset || 0;
+
+      return this._fetchSharedCacheURL(
+          `/groups/?n=${groupsPerPage + 1}&S=${offset}` +
+          this._computeFilter(filter)
+      );
+    },
+
+    /**
+     * @param {string} filter
+     * @param {number} projectsPerPage
+     * @param {number=} opt_offset
+     * @return {!Promise<?Object>}
+     */
+    getProjects(filter, projectsPerPage, opt_offset) {
+      const offset = opt_offset || 0;
+
+      return this._fetchSharedCacheURL(
+          `/projects/?d&n=${projectsPerPage + 1}&S=${offset}` +
+          this._computeFilter(filter)
+      );
+    },
+
+    setProjectHead(project, ref) {
+      return this.send(
+          'PUT', `/projects/${encodeURIComponent(project)}/HEAD`, {ref});
+    },
+
+    /**
+     * @param {string} filter
+     * @param {string} project
+     * @param {number} projectsBranchesPerPage
+     * @param {number=} opt_offset
+     * @return {!Promise<?Object>}
+     */
+    getProjectBranches(filter, project, projectsBranchesPerPage, opt_offset) {
+      const offset = opt_offset || 0;
+
       return this.fetchJSON(
-          this.getChangeActionURL(changeNum, patchNum, '/actions')).then(
-              function(revisionActions) {
-                // The rebase button on change screen is always enabled.
-                if (revisionActions.rebase) {
-                  revisionActions.rebase.rebaseOnCurrent =
-                      !!revisionActions.rebase.enabled;
-                  revisionActions.rebase.enabled = true;
-                }
-                return revisionActions;
-              });
+          `/projects/${encodeURIComponent(project)}/branches` +
+          `?n=${projectsBranchesPerPage + 1}&S=${offset}` +
+          this._computeFilter(filter)
+      );
     },
 
-    getChangeSuggestedReviewers: function(changeNum, inputVal, opt_errFn,
-        opt_ctx) {
-      var url = this.getChangeActionURL(changeNum, null, '/suggest_reviewers');
-      return this.fetchJSON(url, opt_errFn, opt_ctx, {
-        n: 10,  // Return max 10 results
-        q: inputVal,
-      });
+    /**
+     * @param {string} filter
+     * @param {string} project
+     * @param {number} projectsTagsPerPage
+     * @param {number=} opt_offset
+     * @return {!Promise<?Object>}
+     */
+    getProjectTags(filter, project, projectsTagsPerPage, opt_offset) {
+      const offset = opt_offset || 0;
+
+      return this.fetchJSON(
+          `/projects/${encodeURIComponent(project)}/tags` +
+          `?n=${projectsTagsPerPage + 1}&S=${offset}` +
+          this._computeFilter(filter)
+      );
     },
 
-    getSuggestedGroups: function(inputVal, opt_n, opt_errFn, opt_ctx) {
-      var params = {s: inputVal};
+    /**
+     * @param {string} filter
+     * @param {number} pluginsPerPage
+     * @param {number=} opt_offset
+     * @return {!Promise<?Object>}
+     */
+    getPlugins(filter, pluginsPerPage, opt_offset) {
+      const offset = opt_offset || 0;
+
+      return this.fetchJSON(
+          `/plugins/?all&n=${pluginsPerPage + 1}&S=${offset}` +
+          this._computeFilter(filter)
+      );
+    },
+
+    getProjectAccessRights(projectName) {
+      return this._fetchSharedCacheURL(
+          `/projects/${encodeURIComponent(projectName)}/access`);
+    },
+
+    setProjectAccessRights(projectName, projectInfo) {
+      return this.send(
+          'POST', `/projects/${encodeURIComponent(projectName)}/access`,
+          projectInfo);
+    },
+
+    /**
+     * @param {string} inputVal
+     * @param {number} opt_n
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    getSuggestedGroups(inputVal, opt_n, opt_errFn, opt_ctx) {
+      const params = {s: inputVal};
       if (opt_n) { params.n = opt_n; }
       return this.fetchJSON('/groups/', opt_errFn, opt_ctx, params);
     },
 
-    getSuggestedProjects: function(inputVal, opt_n, opt_errFn, opt_ctx) {
-      var params = {p: inputVal};
+    /**
+     * @param {string} inputVal
+     * @param {number} opt_n
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    getSuggestedProjects(inputVal, opt_n, opt_errFn, opt_ctx) {
+      const params = {
+        m: inputVal,
+        n: MAX_PROJECT_RESULTS,
+        type: 'ALL',
+      };
       if (opt_n) { params.n = opt_n; }
       return this.fetchJSON('/projects/', opt_errFn, opt_ctx, params);
     },
 
-    getSuggestedAccounts: function(inputVal, opt_n, opt_errFn, opt_ctx) {
-      var params = {q: inputVal, suggest: null};
+    /**
+     * @param {string} inputVal
+     * @param {number} opt_n
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    getSuggestedAccounts(inputVal, opt_n, opt_errFn, opt_ctx) {
+      if (!inputVal) {
+        return Promise.resolve([]);
+      }
+      const params = {suggest: null, q: inputVal};
       if (opt_n) { params.n = opt_n; }
       return this.fetchJSON('/accounts/', opt_errFn, opt_ctx, params);
     },
 
-    addChangeReviewer: function(changeNum, reviewerID) {
+    addChangeReviewer(changeNum, reviewerID) {
       return this._sendChangeReviewerRequest('POST', changeNum, reviewerID);
     },
 
-    removeChangeReviewer: function(changeNum, reviewerID) {
+    removeChangeReviewer(changeNum, reviewerID) {
       return this._sendChangeReviewerRequest('DELETE', changeNum, reviewerID);
     },
 
-    _sendChangeReviewerRequest: function(method, changeNum, reviewerID) {
-      var url = this.getChangeActionURL(changeNum, null, '/reviewers');
-      var body;
-      switch (method) {
-        case 'POST':
-          body = {reviewer: reviewerID};
-          break;
-        case 'DELETE':
-          url += '/' + reviewerID;
-          break;
-        default:
-          throw Error('Unsupported HTTP method: ' + method);
-      }
+    _sendChangeReviewerRequest(method, changeNum, reviewerID) {
+      return this.getChangeActionURL(changeNum, null, '/reviewers')
+          .then(url => {
+            let body;
+            switch (method) {
+              case 'POST':
+                body = {reviewer: reviewerID};
+                break;
+              case 'DELETE':
+                url += '/' + encodeURIComponent(reviewerID);
+                break;
+              default:
+                throw Error('Unsupported HTTP method: ' + method);
+            }
 
-      return this.send(method, url, body);
+            return this.send(method, url, body);
+          });
     },
 
-    getRelatedChanges: function(changeNum, patchNum) {
-      return this.fetchJSON(
-          this.getChangeActionURL(changeNum, patchNum, '/related'));
+    getRelatedChanges(changeNum, patchNum) {
+      return this._getChangeURLAndFetch(changeNum, '/related', patchNum);
     },
 
-    getChangesSubmittedTogether: function(changeNum) {
-      return this.fetchJSON(
-          this.getChangeActionURL(changeNum, null, '/submitted_together'));
+    getChangesSubmittedTogether(changeNum) {
+      return this._getChangeURLAndFetch(changeNum, '/submitted_together', null);
     },
 
-    getChangeConflicts: function(changeNum) {
-      var options = this._listChangesOptionsToHex(
-          ListChangesOption.CURRENT_REVISION,
-          ListChangesOption.CURRENT_COMMIT
+    getChangeConflicts(changeNum) {
+      const options = this.listChangesOptionsToHex(
+          this.ListChangesOption.CURRENT_REVISION,
+          this.ListChangesOption.CURRENT_COMMIT
       );
-      var params = {
+      const params = {
         O: options,
         q: 'status:open is:mergeable conflicts:' + changeNum,
       };
       return this.fetchJSON('/changes/', null, null, params);
     },
 
-    getChangeCherryPicks: function(project, changeID, changeNum) {
-      var options = this._listChangesOptionsToHex(
-          ListChangesOption.CURRENT_REVISION,
-          ListChangesOption.CURRENT_COMMIT
+    getChangeCherryPicks(project, changeID, changeNum) {
+      const options = this.listChangesOptionsToHex(
+          this.ListChangesOption.CURRENT_REVISION,
+          this.ListChangesOption.CURRENT_COMMIT
       );
-      var query = [
+      const query = [
         'project:' + project,
         'change:' + changeID,
         '-change:' + changeNum,
         '-is:abandoned',
       ].join(' ');
-      var params = {
+      const params = {
         O: options,
         q: query,
       };
       return this.fetchJSON('/changes/', null, null, params);
     },
 
-    getChangesWithSameTopic: function(topic) {
-      var options = this._listChangesOptionsToHex(
-          ListChangesOption.LABELS,
-          ListChangesOption.CURRENT_REVISION,
-          ListChangesOption.CURRENT_COMMIT,
-          ListChangesOption.DETAILED_LABELS
+    getChangesWithSameTopic(topic) {
+      const options = this.listChangesOptionsToHex(
+          this.ListChangesOption.LABELS,
+          this.ListChangesOption.CURRENT_REVISION,
+          this.ListChangesOption.CURRENT_COMMIT,
+          this.ListChangesOption.DETAILED_LABELS
       );
-      var params = {
+      const params = {
         O: options,
         q: 'status:open topic:' + topic,
       };
       return this.fetchJSON('/changes/', null, null, params);
     },
 
-    getReviewedFiles: function(changeNum, patchNum) {
-      return this.fetchJSON(
-          this.getChangeActionURL(changeNum, patchNum, '/files?reviewed'));
+    getReviewedFiles(changeNum, patchNum) {
+      return this._getChangeURLAndFetch(changeNum, '/files?reviewed', patchNum);
     },
 
-    saveFileReviewed: function(changeNum, patchNum, path, reviewed, opt_errFn,
-        opt_ctx) {
-      var method = reviewed ? 'PUT' : 'DELETE';
-      var url = this.getChangeActionURL(changeNum, patchNum,
-          '/files/' + encodeURIComponent(path) + '/reviewed');
-
-      return this.send(method, url, null, opt_errFn, opt_ctx);
+    /**
+     * @param {number|string} changeNum
+     * @param {number|string} patchNum
+     * @param {string} path
+     * @param {boolean} reviewed
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    saveFileReviewed(changeNum, patchNum, path, reviewed, opt_errFn, opt_ctx) {
+      const method = reviewed ? 'PUT' : 'DELETE';
+      const e = `/files/${encodeURIComponent(path)}/reviewed`;
+      return this.getChangeURLAndSend(changeNum, method, patchNum, e, null,
+          opt_errFn, opt_ctx);
     },
 
-    saveChangeReview: function(changeNum, patchNum, review, opt_errFn,
-        opt_ctx) {
-      var url = this.getChangeActionURL(changeNum, patchNum, '/review');
-      return this.send('POST', url, review, opt_errFn, opt_ctx);
+    /**
+     * @param {number|string} changeNum
+     * @param {number|string} patchNum
+     * @param {!Object} review
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     */
+    saveChangeReview(changeNum, patchNum, review, opt_errFn, opt_ctx) {
+      const promises = [
+        this.awaitPendingDiffDrafts(),
+        this.getChangeActionURL(changeNum, patchNum, '/review'),
+      ];
+      return Promise.all(promises).then(([, url]) => {
+        return this.send('POST', url, review, opt_errFn, opt_ctx);
+      });
     },
 
-    getFileInChangeEdit: function(changeNum, path) {
-      return this.send('GET',
-          this.getChangeActionURL(changeNum, null,
-              '/edit/' + encodeURIComponent(path)
-          ));
+    getChangeEdit(changeNum, opt_download_commands) {
+      const params = opt_download_commands ? {'download-commands': true} : null;
+      return this.getLoggedIn().then(loggedIn => {
+        return loggedIn ?
+            this._getChangeURLAndFetch(changeNum, '/edit/', null, null, null,
+                params) :
+            false;
+      });
     },
 
-    rebaseChangeEdit: function(changeNum) {
-      return this.send('POST',
-          this.getChangeActionURL(changeNum, null,
-              '/edit:rebase'
-          ));
+    /**
+     * @param {!string} project
+     * @param {!string} branch
+     * @param {!string} subject
+     * @param {!string} topic
+     * @param {!boolean} isPrivate
+     * @param {!boolean} workInProgress
+     */
+    createChange(project, branch, subject, topic, isPrivate,
+        workInProgress) {
+      return this.send('POST', '/changes/',
+          {project, branch, subject, topic, is_private: isPrivate,
+            work_in_progress: workInProgress})
+          .then(response => this.getResponseObject(response));
     },
 
-    deleteChangeEdit: function(changeNum) {
-      return this.send('DELETE',
-          this.getChangeActionURL(changeNum, null,
-              '/edit'
-          ));
+    getFileInChangeEdit(changeNum, path) {
+      const e = '/edit/' + encodeURIComponent(path);
+      return this.getChangeURLAndSend(changeNum, 'GET', null, e);
     },
 
-    restoreFileInChangeEdit: function(changeNum, restore_path) {
-      return this.send('POST',
-          this.getChangeActionURL(changeNum, null, '/edit'),
-          {restore_path: restore_path}
-      );
+    rebaseChangeEdit(changeNum) {
+      return this.getChangeURLAndSend(changeNum, 'POST', null, '/edit:rebase');
     },
 
-    renameFileInChangeEdit: function(changeNum, old_path, new_path) {
-      return this.send('POST',
-          this.getChangeActionURL(changeNum, null, '/edit'),
-          {old_path: old_path},
-          {new_path: new_path}
-      );
+    deleteChangeEdit(changeNum) {
+      return this.getChangeURLAndSend(changeNum, 'DELETE', null, '/edit');
     },
 
-    deleteFileInChangeEdit: function(changeNum, path) {
-      return this.send('DELETE',
-          this.getChangeActionURL(changeNum, null,
-              '/edit/' + encodeURIComponent(path)
-          ));
+    restoreFileInChangeEdit(changeNum, restore_path) {
+      const p = {restore_path};
+      return this.getChangeURLAndSend(changeNum, 'POST', null, '/edit', p);
     },
 
-    saveChangeEdit: function(changeNum, path, contents) {
-      return this.send('PUT',
-          this.getChangeActionURL(changeNum, null,
-              '/edit/' + encodeURIComponent(path)
-          ),
-          contents
-      );
+    renameFileInChangeEdit(changeNum, old_path, new_path) {
+      const p = {old_path, new_path};
+      return this.getChangeURLAndSend(changeNum, 'POST', null, '/edit', p);
     },
 
-    saveChangeCommitMessageEdit: function(changeNum, message) {
-      var url = this.getChangeActionURL(changeNum, null, '/edit:message');
-      return this.send('PUT', url, {message: message});
+    deleteFileInChangeEdit(changeNum, path) {
+      const e = '/edit/' + encodeURIComponent(path);
+      return this.getChangeURLAndSend(changeNum, 'DELETE', null, e);
     },
 
-    publishChangeEdit: function(changeNum) {
-      return this.send('POST',
-          this.getChangeActionURL(changeNum, null, '/edit:publish'));
+    saveChangeEdit(changeNum, path, contents) {
+      const e = '/edit/' + encodeURIComponent(path);
+      return this.getChangeURLAndSend(changeNum, 'PUT', null, e, contents);
     },
 
-    saveChangeStarred: function(changeNum, starred) {
-      var url = '/accounts/self/starred.changes/' + changeNum;
-      var method = starred ? 'PUT' : 'DELETE';
+    // Deprecated, prefer to use putChangeCommitMessage instead.
+    saveChangeCommitMessageEdit(changeNum, message) {
+      const p = {message};
+      return this.getChangeURLAndSend(changeNum, 'PUT', null, '/edit:message',
+          p);
+    },
+
+    publishChangeEdit(changeNum) {
+      return this.getChangeURLAndSend(changeNum, 'POST', null,
+          '/edit:publish');
+    },
+
+    putChangeCommitMessage(changeNum, message) {
+      const p = {message};
+      return this.getChangeURLAndSend(changeNum, 'PUT', null, '/message', p);
+    },
+
+    saveChangeStarred(changeNum, starred) {
+      const url = '/accounts/self/starred.changes/' + changeNum;
+      const method = starred ? 'PUT' : 'DELETE';
       return this.send(method, url);
     },
 
-    send: function(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType) {
-      var headers = new Headers({
-        'X-Gerrit-Auth': this._getCookie('XSRF_TOKEN'),
-      });
-      var options = {
-        method: method,
-        headers: headers,
-        credentials: 'same-origin',
-      };
+    /**
+     * @param {string} method
+     * @param {string} url
+     * @param {?string|number|Object=} opt_body passed as null sometimes
+     *    and also apparently a number. TODO (beckysiegel) remove need for
+     *    number at least.
+     * @param {?function(?Response, string=)=} opt_errFn
+     *    passed as null sometimes.
+     * @param {?=} opt_ctx
+     * @param {?string=} opt_contentType
+     */
+    send(method, url, opt_body, opt_errFn, opt_ctx, opt_contentType) {
+      const options = {method};
       if (opt_body) {
-        headers.append('Content-Type', opt_contentType || 'application/json');
+        options.headers = new Headers();
+        options.headers.set(
+            'Content-Type', opt_contentType || 'application/json');
         if (typeof opt_body !== 'string') {
           opt_body = JSON.stringify(opt_body);
         }
         options.body = opt_body;
       }
-      return fetch(this.getBaseUrl() + url, options).then(function(response) {
+      if (!url.startsWith('http')) {
+        url = this.getBaseUrl() + url;
+      }
+      return this._auth.fetch(url, options).then(response => {
         if (!response.ok) {
           if (opt_errFn) {
-            opt_errFn.call(opt_ctx || null, response);
-            return undefined;
+            return opt_errFn.call(opt_ctx || null, response);
           }
-          this.fire('server-error', {response: response});
+          this.fire('server-error', {response});
         }
-
         return response;
-      }.bind(this)).catch(function(err) {
+      }).catch(err => {
         this.fire('network-error', {error: err});
         if (opt_errFn) {
-          opt_errFn.call(opt_ctx, null, err);
+          return opt_errFn.call(opt_ctx, null, err);
         } else {
           throw err;
         }
-      }.bind(this));
+      });
     },
 
-    getDiff: function(changeNum, basePatchNum, patchNum, path,
+    /**
+     * @param {number|string} changeNum
+     * @param {number|string} basePatchNum
+     * @param {number|string} patchNum
+     * @param {string} path
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {function()=} opt_cancelCondition
+     */
+    getDiff(changeNum, basePatchNum, patchNum, path,
         opt_errFn, opt_cancelCondition) {
-      var url = this._getDiffFetchURL(changeNum, patchNum, path);
-      var params = {
+      const params = {
         context: 'ALL',
         intraline: null,
         whitespace: 'IGNORE_NONE',
@@ -781,36 +1408,50 @@
       if (basePatchNum != PARENT_PATCH_NUM) {
         params.base = basePatchNum;
       }
+      const endpoint = `/files/${encodeURIComponent(path)}/diff`;
 
-      return this.fetchJSON(url, opt_errFn, opt_cancelCondition, params);
+      return this._getChangeURLAndFetch(changeNum, endpoint, patchNum,
+          opt_errFn, opt_cancelCondition, params);
     },
 
-    _getDiffFetchURL: function(changeNum, patchNum, path) {
-      return this._changeBaseURL(changeNum, patchNum) + '/files/' +
-          encodeURIComponent(path) + '/diff';
-    },
-
-    getDiffComments: function(changeNum, opt_basePatchNum, opt_patchNum,
-        opt_path) {
+    /**
+     * @param {number|string} changeNum
+     * @param {number|string=} opt_basePatchNum
+     * @param {number|string=} opt_patchNum
+     * @param {string=} opt_path
+     */
+    getDiffComments(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
       return this._getDiffComments(changeNum, '/comments', opt_basePatchNum,
           opt_patchNum, opt_path);
     },
 
-    getDiffRobotComments: function(changeNum, basePatchNum, patchNum,
-        opt_path) {
+    getDiffRobotComments(changeNum, basePatchNum, patchNum, opt_path) {
       return this._getDiffComments(changeNum, '/robotcomments', basePatchNum,
           patchNum, opt_path);
     },
 
-    getDiffDrafts: function(changeNum, opt_basePatchNum, opt_patchNum,
-        opt_path) {
-      return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
-          opt_patchNum, opt_path);
+    /**
+     * If the user is logged in, fetch the user's draft diff comments. If there
+     * is no logged in user, the request is not made and the promise yields an
+     * empty object.
+     *
+     * @param {number|string} changeNum
+     * @param {number|string=} opt_basePatchNum
+     * @param {number|string=} opt_patchNum
+     * @param {string=} opt_path
+     * @return {!Promise<?Object>}
+     */
+    getDiffDrafts(changeNum, opt_basePatchNum, opt_patchNum, opt_path) {
+      return this.getLoggedIn().then(loggedIn => {
+        if (!loggedIn) { return Promise.resolve({}); }
+        return this._getDiffComments(changeNum, '/drafts', opt_basePatchNum,
+            opt_patchNum, opt_path);
+      });
     },
 
-    _setRange: function(comments, comment) {
+    _setRange(comments, comment) {
       if (comment.in_reply_to && !comment.range) {
-        for (var i = 0; i < comments.length; i++) {
+        for (let i = 0; i < comments.length; i++) {
           if (comments[i].id === comment.in_reply_to) {
             comment.range = comments[i].range;
             break;
@@ -820,41 +1461,54 @@
       return comment;
     },
 
-    _setRanges: function(comments) {
+    _setRanges(comments) {
       comments = comments || [];
-      comments.sort(function(a, b) {
+      comments.sort((a, b) => {
         return util.parseDate(a.updated) - util.parseDate(b.updated);
       });
-      comments.forEach(function(comment) {
+      for (const comment of comments) {
         this._setRange(comments, comment);
-      }.bind(this));
+      }
       return comments;
     },
 
-    _getDiffComments: function(changeNum, endpoint, opt_basePatchNum,
+    /**
+     * @param {number|string} changeNum
+     * @param {string} endpoint
+     * @param {number|string=} opt_basePatchNum
+     * @param {number|string=} opt_patchNum
+     * @param {string=} opt_path
+     */
+    _getDiffComments(changeNum, endpoint, opt_basePatchNum,
         opt_patchNum, opt_path) {
-      if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
-        return this.fetchJSON(
-            this._getDiffCommentsFetchURL(changeNum, endpoint));
-      }
+      /**
+       * Fetches the comments for a given patchNum.
+       * Helper function to make promises more legible.
+       *
+       * @param {string|number=} opt_patchNum
+       * @return {!Object} Diff comments response.
+       */
+      const fetchComments = opt_patchNum => {
+        return this._getChangeURLAndFetch(changeNum, endpoint, opt_patchNum);
+      };
 
+      if (!opt_basePatchNum && !opt_patchNum && !opt_path) {
+        return fetchComments();
+      }
       function onlyParent(c) { return c.side == PARENT_PATCH_NUM; }
       function withoutParent(c) { return c.side != PARENT_PATCH_NUM; }
       function setPath(c) { c.path = opt_path; }
 
-      var promises = [];
-      var comments;
-      var baseComments;
-      var url =
-          this._getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum);
-      promises.push(this.fetchJSON(url).then(function(response) {
+      const promises = [];
+      let comments;
+      let baseComments;
+      let fetchPromise;
+      fetchPromise = fetchComments(opt_patchNum).then(response => {
         comments = response[opt_path] || [];
-
-        // TODO(kaspern): Implement this on in the backend so this can be
-        // removed.
-
-        // Sort comments by date so that parent ranges can be propagated in a
-        // single pass.
+        // TODO(kaspern): Implement this on in the backend so this can
+        // be removed.
+        // Sort comments by date so that parent ranges can be propagated
+        // in a single pass.
         comments = this._setRanges(comments);
 
         if (opt_basePatchNum == PARENT_PATCH_NUM) {
@@ -864,146 +1518,132 @@
         comments = comments.filter(withoutParent);
 
         comments.forEach(setPath);
-      }.bind(this)));
+      });
+      promises.push(fetchPromise);
 
       if (opt_basePatchNum != PARENT_PATCH_NUM) {
-        var baseURL = this._getDiffCommentsFetchURL(changeNum, endpoint,
-            opt_basePatchNum);
-        promises.push(this.fetchJSON(baseURL).then(function(response) {
-          baseComments = (response[opt_path] || []).filter(withoutParent);
-
+        fetchPromise = fetchComments(opt_basePatchNum).then(response => {
+          baseComments = (response[opt_path] || [])
+              .filter(withoutParent);
           baseComments = this._setRanges(baseComments);
-
           baseComments.forEach(setPath);
-        }.bind(this)));
+        });
+        promises.push(fetchPromise);
       }
 
-      return Promise.all(promises).then(function() {
+      return Promise.all(promises).then(() => {
         return Promise.resolve({
-          baseComments: baseComments,
-          comments: comments,
+          baseComments,
+          comments,
         });
       });
     },
 
-    _getDiffCommentsFetchURL: function(changeNum, endpoint, opt_patchNum) {
-      return this._changeBaseURL(changeNum, opt_patchNum) + endpoint;
+    /**
+     * @param {number|string} changeNum
+     * @param {string} endpoint
+     * @param {number|string=} opt_patchNum
+     */
+    _getDiffCommentsFetchURL(changeNum, endpoint, opt_patchNum) {
+      return this._changeBaseURL(changeNum, opt_patchNum)
+          .then(url => url + endpoint);
     },
 
-    saveDiffDraft: function(changeNum, patchNum, draft) {
+    saveDiffDraft(changeNum, patchNum, draft) {
       return this._sendDiffDraftRequest('PUT', changeNum, patchNum, draft);
     },
 
-    deleteDiffDraft: function(changeNum, patchNum, draft) {
+    deleteDiffDraft(changeNum, patchNum, draft) {
       return this._sendDiffDraftRequest('DELETE', changeNum, patchNum, draft);
     },
 
-    hasPendingDiffDrafts: function() {
-      return !!this._pendingRequests[Requests.SEND_DIFF_DRAFT];
+    /**
+     * @returns {boolean} Whether there are pending diff draft sends.
+     */
+    hasPendingDiffDrafts() {
+      const promises = this._pendingRequests[Requests.SEND_DIFF_DRAFT];
+      return promises && promises.length;
     },
 
-    _sendDiffDraftRequest: function(method, changeNum, patchNum, draft) {
-      var url = this.getChangeActionURL(changeNum, patchNum, '/drafts');
+    /**
+     * @returns {!Promise<undefined>} A promise that resolves when all pending
+     *    diff draft sends have resolved.
+     */
+    awaitPendingDiffDrafts() {
+      return Promise.all(this._pendingRequests[Requests.SEND_DIFF_DRAFT] || [])
+          .then(() => {
+            this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
+          });
+    },
+
+    _sendDiffDraftRequest(method, changeNum, patchNum, draft) {
+      const isCreate = !draft.id && method === 'PUT';
+      let endpoint = '/drafts';
       if (draft.id) {
-        url += '/' + draft.id;
+        endpoint += '/' + draft.id;
       }
-      var body;
+      let body;
       if (method === 'PUT') {
         body = draft;
       }
 
       if (!this._pendingRequests[Requests.SEND_DIFF_DRAFT]) {
-        this._pendingRequests[Requests.SEND_DIFF_DRAFT] = 0;
+        this._pendingRequests[Requests.SEND_DIFF_DRAFT] = [];
       }
-      this._pendingRequests[Requests.SEND_DIFF_DRAFT]++;
 
-      return this.send(method, url, body).then(function(res) {
-        this._pendingRequests[Requests.SEND_DIFF_DRAFT]--;
-        return res;
-      }.bind(this));
+      const promise = this.getChangeURLAndSend(changeNum, method, patchNum,
+          endpoint, body);
+      this._pendingRequests[Requests.SEND_DIFF_DRAFT].push(promise);
+
+      if (isCreate) {
+        return this._failForCreate200(promise);
+      }
+
+      return promise;
     },
 
-    _changeBaseURL: function(changeNum, opt_patchNum) {
-      var v = '/changes/' + changeNum;
-      if (opt_patchNum) {
-        v += '/revisions/' + opt_patchNum;
-      }
-      return v;
-    },
-
-    // Derived from
-    // gerrit-extension-api/src/main/j/c/g/gerrit/extensions/client/ListChangesOption.java
-    _listChangesOptionsToHex: function() {
-      var v = 0;
-      for (var i = 0; i < arguments.length; i++) {
-        v |= 1 << arguments[i];
-      }
-      return v.toString(16);
-    },
-
-    _getCookie: function(name) {
-      var key = name + '=';
-      var cookies = document.cookie.split(';');
-      for (var i = 0; i < cookies.length; i++) {
-        var c = cookies[i];
-        while (c.charAt(0) == ' ') {
-          c = c.substring(1);
-        }
-        if (c.indexOf(key) == 0) {
-          return c.substring(key.length, c.length);
-        }
-      }
-      return '';
-    },
-
-    getCommitInfo: function(project, commit) {
+    getCommitInfo(project, commit) {
       return this.fetchJSON(
           '/projects/' + encodeURIComponent(project) +
           '/commits/' + encodeURIComponent(commit));
     },
 
-    _fetchB64File: function(url) {
-      return fetch(this.getBaseUrl() + url, {credentials: 'same-origin'}).then(function(response) {
-        var type = response.headers.get('X-FYI-Content-Type');
-        return response.text()
-          .then(function(text) {
-            return {body: text, type: type};
+    _fetchB64File(url) {
+      return this._auth.fetch(this.getBaseUrl() + url)
+          .then(response => {
+            if (!response.ok) { return Promise.reject(response.statusText); }
+            const type = response.headers.get('X-FYI-Content-Type');
+            return response.text()
+                .then(text => {
+                  return {body: text, type};
+                });
           });
+    },
+
+    /**
+     * @param {string} changeId
+     * @param {string|number} patchNum
+     * @param {string} path
+     * @param {number=} opt_parentIndex
+     */
+    getChangeFileContents(changeId, patchNum, path, opt_parentIndex) {
+      const parent = typeof opt_parentIndex === 'number' ?
+          '?parent=' + opt_parentIndex : '';
+      return this._changeBaseURL(changeId, patchNum).then(url => {
+        url = `${url}/files/${encodeURIComponent(path)}/content${parent}`;
+        return this._fetchB64File(url);
       });
     },
 
-    getChangeFileContents: function(changeId, patchNum, path) {
-      return this._fetchB64File(
-          '/changes/' + encodeURIComponent(changeId) +
-          '/revisions/' + encodeURIComponent(patchNum) +
-          '/files/' + encodeURIComponent(path) +
-          '/content');
-    },
+    getImagesForDiff(changeNum, diff, patchRange) {
+      let promiseA;
+      let promiseB;
 
-    getCommitFileContents: function(projectName, commit, path) {
-      return this._fetchB64File(
-          '/projects/' + encodeURIComponent(projectName) +
-          '/commits/' + encodeURIComponent(commit) +
-          '/files/' + encodeURIComponent(path) +
-          '/content');
-    },
-
-    getImagesForDiff: function(project, commit, changeNum, diff, patchRange) {
-      var promiseA;
-      var promiseB;
-
-      if (diff.meta_a && diff.meta_a.content_type.indexOf('image/') === 0) {
+      if (diff.meta_a && diff.meta_a.content_type.startsWith('image/')) {
         if (patchRange.basePatchNum === 'PARENT') {
-          // Need the commit info know the parent SHA.
-          promiseA = this.getCommitInfo(project, commit).then(function(info) {
-            if (info.parents.length !== 1) {
-              return Promise.reject('Change commit has multiple parents.');
-            }
-            var parent = info.parents[0].commit;
-            return this.getCommitFileContents(project, parent,
-                diff.meta_a.name);
-          }.bind(this));
-
+          // Note: we only attempt to get the image from the first parent.
+          promiseA = this.getChangeFileContents(changeNum, patchRange.patchNum,
+              diff.meta_a.name, 1);
         } else {
           promiseA = this.getChangeFileContents(changeNum,
               patchRange.basePatchNum, diff.meta_a.name);
@@ -1012,81 +1652,124 @@
         promiseA = Promise.resolve(null);
       }
 
-      if (diff.meta_b && diff.meta_b.content_type.indexOf('image/') === 0) {
+      if (diff.meta_b && diff.meta_b.content_type.startsWith('image/')) {
         promiseB = this.getChangeFileContents(changeNum, patchRange.patchNum,
             diff.meta_b.name);
       } else {
         promiseB = Promise.resolve(null);
       }
 
-      return Promise.all([promiseA, promiseB])
-        .then(function(results) {
-          var baseImage = results[0];
-          var revisionImage = results[1];
+      return Promise.all([promiseA, promiseB]).then(results => {
+        const baseImage = results[0];
+        const revisionImage = results[1];
 
-          // Sometimes the server doesn't send back the content type.
-          if (baseImage) {
-            baseImage._expectedType = diff.meta_a.content_type;
-          }
-          if (revisionImage) {
-            revisionImage._expectedType = diff.meta_b.content_type;
-          }
+        // Sometimes the server doesn't send back the content type.
+        if (baseImage) {
+          baseImage._expectedType = diff.meta_a.content_type;
+          baseImage._name = diff.meta_a.name;
+        }
+        if (revisionImage) {
+          revisionImage._expectedType = diff.meta_b.content_type;
+          revisionImage._name = diff.meta_b.name;
+        }
 
-          return {baseImage: baseImage, revisionImage: revisionImage};
-        }.bind(this));
+        return {baseImage, revisionImage};
+      });
     },
 
-    setChangeTopic: function(changeNum, topic) {
-      return this.send('PUT', '/changes/' + encodeURIComponent(changeNum) +
-          '/topic', {topic: topic});
+    /**
+     * @param {number|string} changeNum
+     * @param {?number|string=} opt_patchNum passed as null sometimes.
+     * @param {string=} opt_project
+     * @return {!Promise<string>}
+     */
+    _changeBaseURL(changeNum, opt_patchNum, opt_project) {
+      // TODO(kaspern): For full slicer migration, app should warn with a call
+      // stack every time _changeBaseURL is called without a project.
+      const projectPromise = opt_project ?
+          Promise.resolve(opt_project) :
+          this.getFromProjectLookup(changeNum);
+      return projectPromise.then(project => {
+        let url = `/changes/${encodeURIComponent(project)}~${changeNum}`;
+        if (opt_patchNum) {
+          url += `/revisions/${opt_patchNum}`;
+        }
+        return url;
+      });
     },
 
-    deleteAccountHttpPassword: function() {
+    /**
+     * @suppress {checkTypes}
+     * Resulted in error: Promise.prototype.then does not match formal
+     * parameter.
+     */
+    setChangeTopic(changeNum, topic) {
+      const p = {topic};
+      return this.getChangeURLAndSend(changeNum, 'PUT', null, '/topic', p)
+          .then(this.getResponseObject.bind(this));
+    },
+
+    /**
+     * @suppress {checkTypes}
+     * Resulted in error: Promise.prototype.then does not match formal
+     * parameter.
+     */
+    setChangeHashtag(changeNum, hashtag) {
+      return this.getChangeURLAndSend(changeNum, 'POST', null, '/hashtags',
+          hashtag).then(this.getResponseObject.bind(this));
+    },
+
+    deleteAccountHttpPassword() {
       return this.send('DELETE', '/accounts/self/password.http');
     },
 
-    generateAccountHttpPassword: function() {
+    /**
+     * @suppress {checkTypes}
+     * Resulted in error: Promise.prototype.then does not match formal
+     * parameter.
+     */
+    generateAccountHttpPassword() {
       return this.send('PUT', '/accounts/self/password.http', {generate: true})
-          .then(this.getResponseObject);
+          .then(this.getResponseObject.bind(this));
     },
 
-    getAccountSSHKeys: function() {
+    getAccountSSHKeys() {
       return this._fetchSharedCacheURL('/accounts/self/sshkeys');
     },
 
-    addAccountSSHKey: function(key) {
+    addAccountSSHKey(key) {
       return this.send('POST', '/accounts/self/sshkeys', key, null, null,
           'plain/text')
-          .then(function(response) {
+          .then(response => {
             if (response.status < 200 && response.status >= 300) {
               return Promise.reject();
             }
             return this.getResponseObject(response);
-          }.bind(this))
-          .then(function(obj) {
+          })
+          .then(obj => {
             if (!obj.valid) { return Promise.reject(); }
             return obj;
           });
     },
 
-    deleteAccountSSHKey: function(id) {
+    deleteAccountSSHKey(id) {
       return this.send('DELETE', '/accounts/self/sshkeys/' + id);
     },
 
-    deleteVote: function(changeID, account, label) {
-      return this.send('DELETE', '/changes/' + changeID +
-          '/reviewers/' + account + '/votes/' + encodeURIComponent(label));
+    deleteVote(changeNum, account, label) {
+      const e = `/reviewers/${account}/votes/${encodeURIComponent(label)}`;
+      return this.getChangeURLAndSend(changeNum, 'DELETE', null, e);
     },
 
-    setDescription: function(changeNum, patchNum, desc) {
-      return this.send('PUT',
-          this.getChangeActionURL(changeNum, patchNum, '/description'),
-          {description: desc});
+    setDescription(changeNum, patchNum, desc) {
+      const p = {description: desc};
+      return this.getChangeURLAndSend(changeNum, 'PUT', patchNum,
+          '/description', p);
     },
 
-    confirmEmail: function(token) {
-      return this.send('PUT', '/config/server/email.confirm', {token: token})
-          .then(function(response) {
+    confirmEmail(token) {
+      return this.send('PUT', '/config/server/email.confirm', {token})
+          .then(response => {
             if (response.status === 204) {
               return 'Email confirmed successfully.';
             }
@@ -1094,22 +1777,199 @@
           });
     },
 
-    setAssignee: function(changeNum, assignee) {
-      return this.send('PUT',
-          this.getChangeActionURL(changeNum, null, '/assignee'),
-          {assignee: assignee});
+    getCapabilities(token) {
+      return this.fetchJSON('/config/server/capabilities');
     },
 
-    deleteAssignee: function(changeNum) {
-      return this.send('DELETE',
-          this.getChangeActionURL(changeNum, null, '/assignee'));
+    setAssignee(changeNum, assignee) {
+      const p = {assignee};
+      return this.getChangeURLAndSend(changeNum, 'PUT', null, '/assignee', p);
     },
 
-    probePath: function(path) {
+    deleteAssignee(changeNum) {
+      return this.getChangeURLAndSend(changeNum, 'DELETE', null, '/assignee');
+    },
+
+    probePath(path) {
       return fetch(new Request(path, {method: 'HEAD'}))
-        .then(function(response) {
-          return response.ok;
-        });
+          .then(response => {
+            return response.ok;
+          });
+    },
+
+    /**
+     * @param {number|string} changeNum
+     * @param {number|string=} opt_message
+     */
+    startWorkInProgress(changeNum, opt_message) {
+      const payload = {};
+      if (opt_message) {
+        payload.message = opt_message;
+      }
+      return this.getChangeURLAndSend(changeNum, 'POST', null, '/wip', payload)
+          .then(response => {
+            if (response.status === 204) {
+              return 'Change marked as Work In Progress.';
+            }
+          });
+    },
+
+    /**
+     * @param {number|string} changeNum
+     * @param {number|string=} opt_body
+     * @param {function(?Response, string=)=} opt_errFn
+     */
+    startReview(changeNum, opt_body, opt_errFn) {
+      return this.getChangeURLAndSend(changeNum, 'POST', null, '/ready',
+          opt_body, opt_errFn);
+    },
+
+    /**
+     * @suppress {checkTypes}
+     * Resulted in error: Promise.prototype.then does not match formal
+     * parameter.
+     */
+    deleteComment(changeNum, patchNum, commentID, reason) {
+      const endpoint = `/comments/${commentID}/delete`;
+      const payload = {reason};
+      return this.getChangeURLAndSend(changeNum, 'POST', patchNum, endpoint,
+          payload).then(this.getResponseObject.bind(this));
+    },
+
+    /**
+     * Given a changeNum, gets the change.
+     *
+     * @param {number|string} changeNum
+     * @param {function(?Response, string=)=} opt_errFn
+     * @return {!Promise<?Object>} The change
+     */
+    getChange(changeNum, opt_errFn) {
+      // Cannot use _changeBaseURL, as this function is used by _projectLookup.
+      return this.fetchJSON(`/changes/${changeNum}`, opt_errFn);
+    },
+
+    /**
+     * @param {string|number} changeNum
+     * @param {string=} project
+     */
+    setInProjectLookup(changeNum, project) {
+      if (this._projectLookup[changeNum] &&
+          this._projectLookup[changeNum] !== project) {
+        console.warn('Change set with multiple project nums.' +
+            'One of them must be invalid.');
+      }
+      this._projectLookup[changeNum] = project;
+    },
+
+    /**
+     * Checks in _projectLookup for the changeNum. If it exists, returns the
+     * project. If not, calls the restAPI to get the change, populates
+     * _projectLookup with the project for that change, and returns the project.
+     *
+     * @param {string|number} changeNum
+     * @return {!Promise<string|undefined>}
+     */
+    getFromProjectLookup(changeNum) {
+      const project = this._projectLookup[changeNum];
+      if (project) { return Promise.resolve(project); }
+
+      const onError = response => {
+        // Fire a page error so that the visual 404 is displayed.
+        this.fire('page-error', {response});
+      };
+
+      return this.getChange(changeNum, onError).then(change => {
+        if (!change || !change.project) { return; }
+        this.setInProjectLookup(changeNum, change.project);
+        return change.project;
+      });
+    },
+
+    /**
+     * Alias for _changeBaseURL.then(send).
+     * @todo(beckysiegel) clean up comments
+     * @param {string|number} changeNum
+     * @param {string} method
+     * @param {?string|number} patchNum gets passed as null.
+     * @param {?string} endpoint gets passed as null.
+     * @param {?Object|number|string=} opt_payload gets passed as null, string,
+     *    Object, or number.
+     * @param {function(?Response, string=)=} opt_errFn
+     * @param {?=} opt_ctx
+     * @param {?=} opt_contentType
+     * @return {!Promise<!Object>}
+     */
+    getChangeURLAndSend(changeNum, method, patchNum, endpoint, opt_payload,
+        opt_errFn, opt_ctx, opt_contentType) {
+      return this._changeBaseURL(changeNum, patchNum).then(url => {
+        return this.send(method, url + endpoint, opt_payload, opt_errFn,
+            opt_ctx, opt_contentType);
+      });
+    },
+
+   /**
+    * Alias for _changeBaseURL.then(fetchJSON).
+    * @todo(beckysiegel) clean up comments
+    * @param {string|number} changeNum
+    * @param {string} endpoint
+    * @param {?string|number=} opt_patchNum gets passed as null.
+    * @param {?function(?Response, string=)=} opt_errFn gets passed as null.
+    * @param {?function()=} opt_cancelCondition gets passed as null.
+    * @param {?Object=} opt_params gets passed as null.
+    * @param {!Object=} opt_options
+    * @return {!Promise<!Object>}
+    */
+    _getChangeURLAndFetch(changeNum, endpoint, opt_patchNum, opt_errFn,
+        opt_cancelCondition, opt_params, opt_options) {
+      return this._changeBaseURL(changeNum, opt_patchNum).then(url => {
+        return this.fetchJSON(url + endpoint, opt_errFn, opt_cancelCondition,
+            opt_params, opt_options);
+      });
+    },
+
+    /**
+     * Get blame information for the given diff.
+     * @param {string|number} changeNum
+     * @param {string|number} patchNum
+     * @param {string} path
+     * @param {boolean=} opt_base If true, requests blame for the base of the
+     *     diff, rather than the revision.
+     * @return {!Promise<!Object>}
+     */
+    getBlame(changeNum, patchNum, path, opt_base) {
+      const encodedPath = encodeURIComponent(path);
+      return this._getChangeURLAndFetch(changeNum,
+          `/files/${encodedPath}/blame`, patchNum, undefined, undefined,
+          opt_base ? {base: 't'} : undefined);
+    },
+
+    /**
+     * Modify the given create draft request promise so that it fails and throws
+     * an error if the response bears HTTP status 200 instead of HTTP 201.
+     * @see Issue 7763
+     * @param {Promise} promise The original promise.
+     * @return {Promise} The modified promise.
+     */
+    _failForCreate200(promise) {
+      return promise.then(result => {
+        if (result.status === 200) {
+          // Read the response headers into an object representation.
+          const headers = Array.from(result.headers.entries())
+              .reduce((obj, [key, val]) => {
+                if (!HEADER_REPORTING_BLACKLIST.test(key)) {
+                  obj[key] = val;
+                }
+                return obj;
+              }, {});
+          const err = new Error([
+            CREATE_DRAFT_UNEXPECTED_STATUS_MESSAGE,
+            JSON.stringify(headers),
+          ].join('\n'));
+          // Throw the error so that it is caught by gr-reporting.
+          throw err;
+        }
+        return result;
+      });
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
index 0ff162d..ef2e14b 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface_test.html
@@ -20,9 +20,9 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
 <link rel="import" href="gr-rest-api-interface.html">
 
 <script>void(0);</script>
@@ -34,63 +34,65 @@
 </test-fixture>
 
 <script>
-  suite('gr-rest-api-interface tests', function() {
-    var element;
-    var sandbox;
+  suite('gr-rest-api-interface tests', () => {
+    let element;
+    let sandbox;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
       element = fixture('basic');
-      var testJSON = ')]}\'\n{"hello": "bonjour"}';
+      element._cache = {};
+      element._projectLookup = {};
+      const testJSON = ')]}\'\n{"hello": "bonjour"}';
       sandbox.stub(window, 'fetch').returns(Promise.resolve({
         ok: true,
-        text: function() {
+        text() {
           return Promise.resolve(testJSON);
         },
       }));
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('JSON prefix is properly removed', function(done) {
-      element.fetchJSON('/dummy/url').then(function(obj) {
+    test('JSON prefix is properly removed', done => {
+      element.fetchJSON('/dummy/url').then(obj => {
         assert.deepEqual(obj, {hello: 'bonjour'});
         done();
       });
     });
 
-    test('cached results', function(done) {
-      var n = 0;
-      sandbox.stub(element, 'fetchJSON', function() {
+    test('cached results', done => {
+      let n = 0;
+      sandbox.stub(element, 'fetchJSON', () => {
         return Promise.resolve(++n);
       });
-      var promises = [];
+      const promises = [];
       promises.push(element._fetchSharedCacheURL('/foo'));
       promises.push(element._fetchSharedCacheURL('/foo'));
       promises.push(element._fetchSharedCacheURL('/foo'));
 
-      Promise.all(promises).then(function(results) {
+      Promise.all(promises).then(results => {
         assert.deepEqual(results, [1, 1, 1]);
-        element._fetchSharedCacheURL('/foo').then(function(foo) {
+        element._fetchSharedCacheURL('/foo').then(foo => {
           assert.equal(foo, 1);
           done();
         });
       });
     });
 
-    test('cached promise', function(done) {
-      var promise = Promise.reject('foo');
+    test('cached promise', done => {
+      const promise = Promise.reject('foo');
       element._cache['/foo'] = promise;
-      element._fetchSharedCacheURL('/foo').catch(function(p) {
+      element._fetchSharedCacheURL('/foo').catch(p => {
         assert.equal(p, 'foo');
         done();
       });
     });
 
-    test('params are properly encoded', function() {
-      var url = element._urlWithParams('/path/', {
+    test('params are properly encoded', () => {
+      let url = element._urlWithParams('/path/', {
         sp: 'hola',
         gr: 'guten tag',
         noval: null,
@@ -110,23 +112,23 @@
       assert.equal(url, '/path/?l=c&l=b&l=a');
     });
 
-    test('request callbacks can be canceled', function(done) {
-      var cancelCalled = false;
+    test('request callbacks can be canceled', done => {
+      let cancelCalled = false;
       window.fetch.returns(Promise.resolve({
         body: {
-          cancel: function() { cancelCalled = true; },
+          cancel() { cancelCalled = true; },
         },
       }));
-      element.fetchJSON('/dummy/url', null, function() { return true; }).then(
-        function(obj) {
-          assert.isUndefined(obj);
-          assert.isTrue(cancelCalled);
-          done();
-        });
+      element.fetchJSON('/dummy/url', null, () => { return true; }).then(
+          obj => {
+            assert.isUndefined(obj);
+            assert.isTrue(cancelCalled);
+            done();
+          });
     });
 
-    test('parent diff comments are properly grouped', function(done) {
-      sandbox.stub(element, 'fetchJSON', function() {
+    test('parent diff comments are properly grouped', done => {
+      sandbox.stub(element, 'fetchJSON', () => {
         return Promise.resolve({
           '/COMMIT_MSG': [],
           'sieve.go': [
@@ -143,26 +145,26 @@
         });
       });
       element._getDiffComments('42', '', 'PARENT', 1, 'sieve.go').then(
-        function(obj) {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            side: 'PARENT',
-            message: 'how did this work in the first place?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:33:28.000000000',
+          obj => {
+            assert.equal(obj.baseComments.length, 1);
+            assert.deepEqual(obj.baseComments[0], {
+              side: 'PARENT',
+              message: 'how did this work in the first place?',
+              path: 'sieve.go',
+              updated: '2017-02-03 22:33:28.000000000',
+            });
+            assert.equal(obj.comments.length, 1);
+            assert.deepEqual(obj.comments[0], {
+              message: 'this isn’t quite right',
+              path: 'sieve.go',
+              updated: '2017-02-03 22:32:28.000000000',
+            });
+            done();
           });
-          assert.equal(obj.comments.length, 1);
-          assert.deepEqual(obj.comments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          done();
-        });
     });
 
-    test('_setRange', function() {
-      var comments = [
+    test('_setRange', () => {
+      const comments = [
         {
           id: 1,
           side: 'PARENT',
@@ -182,7 +184,7 @@
           updated: '2017-02-03 22:33:28.000000000',
         },
       ];
-      var expectedResult = {
+      const expectedResult = {
         id: 2,
         in_reply_to: 1,
         message: 'this isn’t quite right',
@@ -194,12 +196,12 @@
           end_character: 1,
         },
       };
-      var comment = comments[1];
+      const comment = comments[1];
       assert.deepEqual(element._setRange(comments, comment), expectedResult);
     });
 
-    test('_setRanges', function() {
-      var comments = [
+    test('_setRanges', () => {
+      const comments = [
         {
           id: 3,
           in_reply_to: 2,
@@ -225,7 +227,7 @@
           },
         },
       ];
-      var expectedResult = [
+      const expectedResult = [
         {
           id: 1,
           side: 'PARENT',
@@ -266,9 +268,11 @@
       assert.deepEqual(element._setRanges(comments), expectedResult);
     });
 
-    test('differing patch diff comments are properly grouped', function(done) {
-      sandbox.stub(element, 'fetchJSON', function(url) {
-        if (url == '/changes/42/revisions/1') {
+    test('differing patch diff comments are properly grouped', done => {
+      sandbox.stub(element, 'getFromProjectLookup')
+          .returns(Promise.resolve('test'));
+      sandbox.stub(element, 'fetchJSON', url => {
+        if (url === '/changes/test~42/revisions/1') {
           return Promise.resolve({
             '/COMMIT_MSG': [],
             'sieve.go': [
@@ -283,7 +287,7 @@
               },
             ],
           });
-        } else if (url == '/changes/42/revisions/2') {
+        } else if (url === '/changes/test~42/revisions/2') {
           return Promise.resolve({
             '/COMMIT_MSG': [],
             'sieve.go': [
@@ -305,29 +309,29 @@
         }
       });
       element._getDiffComments('42', '', 1, 2, 'sieve.go').then(
-        function(obj) {
-          assert.equal(obj.baseComments.length, 1);
-          assert.deepEqual(obj.baseComments[0], {
-            message: 'this isn’t quite right',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
+          obj => {
+            assert.equal(obj.baseComments.length, 1);
+            assert.deepEqual(obj.baseComments[0], {
+              message: 'this isn’t quite right',
+              path: 'sieve.go',
+              updated: '2017-02-03 22:32:28.000000000',
+            });
+            assert.equal(obj.comments.length, 2);
+            assert.deepEqual(obj.comments[0], {
+              message: 'What on earth are you thinking, here?',
+              path: 'sieve.go',
+              updated: '2017-02-03 22:32:28.000000000',
+            });
+            assert.deepEqual(obj.comments[1], {
+              message: '¯\\_(ツ)_/¯',
+              path: 'sieve.go',
+              updated: '2017-02-04 22:33:28.000000000',
+            });
+            done();
           });
-          assert.equal(obj.comments.length, 2);
-          assert.deepEqual(obj.comments[0], {
-            message: 'What on earth are you thinking, here?',
-            path: 'sieve.go',
-            updated: '2017-02-03 22:32:28.000000000',
-          });
-          assert.deepEqual(obj.comments[1], {
-            message: '¯\\_(ツ)_/¯',
-            path: 'sieve.go',
-            updated: '2017-02-04 22:33:28.000000000',
-          });
-          done();
-        });
     });
 
-    test('special file path sorting', function() {
+    test('special file path sorting', () => {
       assert.deepEqual(
           ['.b', '/COMMIT_MSG', '.a', 'file'].sort(
               element.specialFilePathCompare),
@@ -354,13 +358,13 @@
           ['foo/bar.h', 'foo/bar.hpp', 'foo/bar.hxx']);
 
       // Regression test for Issue 4448.
-      assert.deepEqual([
-          'minidump/minidump_memory_writer.cc',
-          'minidump/minidump_memory_writer.h',
-          'minidump/minidump_thread_writer.cc',
-          'minidump/minidump_thread_writer.h',
-          ]
-        .sort(element.specialFilePathCompare),
+      assert.deepEqual(
+          [
+            'minidump/minidump_memory_writer.cc',
+            'minidump/minidump_memory_writer.h',
+            'minidump/minidump_thread_writer.cc',
+            'minidump/minidump_thread_writer.h',
+          ].sort(element.specialFilePathCompare),
           [
             'minidump/minidump_memory_writer.h',
             'minidump/minidump_memory_writer.cc',
@@ -369,102 +373,124 @@
           ]);
 
       // Regression test for Issue 4545.
-      assert.deepEqual([
-          'task_test.go',
-          'task.go',
-          ]
-        .sort(element.specialFilePathCompare),
+      assert.deepEqual(
+          [
+            'task_test.go',
+            'task.go',
+          ].sort(element.specialFilePathCompare),
           [
             'task.go',
             'task_test.go',
           ]);
     });
 
-    suite('rebase action', function() {
-      var resolveFetchJSON;
-      setup(function() {
+    suite('rebase action', () => {
+      let resolveFetchJSON;
+      setup(() => {
         sandbox.stub(element, 'fetchJSON').returns(
-            new Promise(function(resolve) {
+            new Promise(resolve => {
               resolveFetchJSON = resolve;
             }));
       });
 
-      test('no rebase on current', function(done) {
+      test('no rebase on current', done => {
         element.getChangeRevisionActions('42', '1337').then(
-          function(response) {
-            assert.isTrue(response.rebase.enabled);
-            assert.isFalse(response.rebase.rebaseOnCurrent);
-            done();
-          });
+            response => {
+              assert.isTrue(response.rebase.enabled);
+              assert.isFalse(response.rebase.rebaseOnCurrent);
+              done();
+            });
         resolveFetchJSON({rebase: {}});
       });
 
-      test('rebase on current', function(done) {
+      test('rebase on current', done => {
         element.getChangeRevisionActions('42', '1337').then(
-          function(response) {
-            assert.isTrue(response.rebase.enabled);
-            assert.isTrue(response.rebase.rebaseOnCurrent);
-            done();
-          });
+            response => {
+              assert.isTrue(response.rebase.enabled);
+              assert.isTrue(response.rebase.rebaseOnCurrent);
+              done();
+            });
         resolveFetchJSON({rebase: {enabled: true}});
       });
     });
 
 
-    test('server error', function(done) {
-      var getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
+    test('server error', done => {
+      const getResponseObjectStub = sandbox.stub(element, 'getResponseObject');
       window.fetch.returns(Promise.resolve({ok: false}));
-      var serverErrorEventPromise = new Promise(function(resolve) {
-        element.addEventListener('server-error', function() { resolve(); });
+      const serverErrorEventPromise = new Promise(resolve => {
+        element.addEventListener('server-error', resolve);
       });
 
-      element.fetchJSON().then(
-          function(response) {
-            assert.isUndefined(response);
-            assert.isTrue(getResponseObjectStub.notCalled);
-            serverErrorEventPromise.then(function() {
-              done();
-            });
-          });
+      element.fetchJSON().then(response => {
+        assert.isUndefined(response);
+        assert.isTrue(getResponseObjectStub.notCalled);
+        serverErrorEventPromise.then(() => done());
+      });
     });
 
-    test('checkCredentials', function(done) {
-      var responses = [
+    test('auth failure', done => {
+      const fakeAuthResponse = {
+        ok: false,
+        status: 403,
+      };
+      window.fetch.onFirstCall().returns(
+          Promise.reject({message: 'Failed to fetch'}));
+      window.fetch.onSecondCall().returns(Promise.resolve(fakeAuthResponse));
+      // Emulate logged in.
+      element._cache['/accounts/self/detail'] = {};
+      const serverErrorStub = sandbox.stub();
+      element.addEventListener('server-error', serverErrorStub);
+      const authErrorStub = sandbox.stub();
+      element.addEventListener('auth-error', authErrorStub);
+      element.fetchJSON('/bar').then(r => {
+        flush(() => {
+          assert.isTrue(authErrorStub.called);
+          assert.isFalse(serverErrorStub.called);
+          assert.isNull(element._cache['/accounts/self/detail']);
+          done();
+        });
+      });
+    });
+
+    test('checkCredentials', done => {
+      const responses = [
         {
           ok: false,
           status: 403,
-          text: function() { return Promise.resolve(); },
+          text() { return Promise.resolve(); },
         },
         {
           ok: true,
           status: 200,
-          text: function() { return Promise.resolve(')]}\'{}'); },
+          text() { return Promise.resolve(')]}\'{}'); },
         },
       ];
       window.fetch.restore();
-      sandbox.stub(window, 'fetch', function(url) {
+      sandbox.stub(window, 'fetch', url => {
         if (url === '/accounts/self/detail') {
           return Promise.resolve(responses.shift());
         }
       });
 
-      element.getLoggedIn().then(function(account) {
+      element.getLoggedIn().then(account => {
         assert.isNotOk(account);
-        element.checkCredentials().then(function(account) {
+        element.checkCredentials().then(account => {
           assert.isOk(account);
           done();
         });
       });
     });
 
-    test('legacy n,z key in change url is replaced', function() {
-      var stub = sandbox.stub(element, 'fetchJSON');
+    test('legacy n,z key in change url is replaced', () => {
+      const stub = sandbox.stub(element, 'fetchJSON')
+          .returns(Promise.resolve([]));
       element.getChanges(1, null, 'n,z');
       assert.equal(stub.args[0][3].S, 0);
     });
 
-    test('saveDiffPreferences invalidates cache line', function() {
-      var cacheKey = '/accounts/self/preferences.diff';
+    test('saveDiffPreferences invalidates cache line', () => {
+      const cacheKey = '/accounts/self/preferences.diff';
       sandbox.stub(element, 'send');
       element._cache[cacheKey] = {tab_size: 4};
       element.saveDiffPreferences({tab_size: 8});
@@ -472,108 +498,106 @@
       assert.notOk(element._cache[cacheKey]);
     });
 
-    var preferenceSetup = function(testJSON, loggedIn, smallScreen) {
-      sandbox.stub(element, 'getLoggedIn', function() {
+    const preferenceSetup = function(testJSON, loggedIn, smallScreen) {
+      sandbox.stub(element, 'getLoggedIn', () => {
         return Promise.resolve(loggedIn);
       });
-      sandbox.stub(element, '_isNarrowScreen', function() {
+      sandbox.stub(element, '_isNarrowScreen', () => {
         return smallScreen;
       });
-      sandbox.stub(element, '_fetchSharedCacheURL', function() {
+      sandbox.stub(element, '_fetchSharedCacheURL', () => {
         return Promise.resolve(testJSON);
       });
     };
 
     test('getPreferences returns correctly on small screens logged in',
-        function(done) {
+        done => {
+          const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+          const loggedIn = true;
+          const smallScreen = true;
 
-      var testJSON = {diff_view: 'SIDE_BY_SIDE'};
-      var loggedIn = true;
-      var smallScreen = true;
+          preferenceSetup(testJSON, loggedIn, smallScreen);
 
-      preferenceSetup(testJSON, loggedIn, smallScreen);
-
-      element.getPreferences().then(function(obj) {
-        assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-        assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        done();
-      });
-    });
+          element.getPreferences().then(obj => {
+            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+            done();
+          });
+        });
 
     test('getPreferences returns correctly on small screens not logged in',
-          function(done) {
+        done => {
+          const testJSON = {diff_view: 'SIDE_BY_SIDE'};
+          const loggedIn = false;
+          const smallScreen = true;
 
-      var testJSON = {diff_view: 'SIDE_BY_SIDE'};
-      var loggedIn = false;
-      var smallScreen = true;
-
-      preferenceSetup(testJSON, loggedIn, smallScreen);
-      element.getPreferences().then(function(obj) {
-        assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-        assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        done();
-      });
-    });
+          preferenceSetup(testJSON, loggedIn, smallScreen);
+          element.getPreferences().then(obj => {
+            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+            done();
+          });
+        });
 
     test('getPreferences returns correctly on larger screens logged in',
-        function(done) {
-      var testJSON = {diff_view: 'UNIFIED_DIFF'};
-      var loggedIn = true;
-      var smallScreen = false;
+        done => {
+          const testJSON = {diff_view: 'UNIFIED_DIFF'};
+          const loggedIn = true;
+          const smallScreen = false;
 
-      preferenceSetup(testJSON, loggedIn, smallScreen);
+          preferenceSetup(testJSON, loggedIn, smallScreen);
 
-      element.getPreferences().then(function(obj) {
-        assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
-        assert.equal(obj.diff_view, 'UNIFIED_DIFF');
-        done();
-      });
-    });
+          element.getPreferences().then(obj => {
+            assert.equal(obj.default_diff_view, 'UNIFIED_DIFF');
+            assert.equal(obj.diff_view, 'UNIFIED_DIFF');
+            done();
+          });
+        });
 
     test('getPreferences returns correctly on larger screens not logged in',
-        function(done) {
-      var testJSON = {diff_view: 'UNIFIED_DIFF'};
-      var loggedIn = false;
-      var smallScreen = false;
+        done => {
+          const testJSON = {diff_view: 'UNIFIED_DIFF'};
+          const loggedIn = false;
+          const smallScreen = false;
 
-      preferenceSetup(testJSON, loggedIn, smallScreen);
+          preferenceSetup(testJSON, loggedIn, smallScreen);
 
-      element.getPreferences().then(function(obj) {
-        assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
-        assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
-        done();
-      });
-    });
+          element.getPreferences().then(obj => {
+            assert.equal(obj.default_diff_view, 'SIDE_BY_SIDE');
+            assert.equal(obj.diff_view, 'SIDE_BY_SIDE');
+            done();
+          });
+        });
 
-    test('savPreferences normalizes download scheme', function() {
+    test('savPreferences normalizes download scheme', () => {
       sandbox.stub(element, 'send');
       element.savePreferences({download_scheme: 'HTTP'});
       assert.isTrue(element.send.called);
       assert.equal(element.send.lastCall.args[2].download_scheme, 'http');
     });
 
-    test('confirmEmail', function() {
+    test('confirmEmail', () => {
       sandbox.spy(element, 'send');
       element.confirmEmail('foo');
       assert.isTrue(element.send.calledWith(
           'PUT', '/config/server/email.confirm', {token: 'foo'}));
     });
 
-    test('GrReviewerUpdatesParser.parse is used', function() {
+    test('GrReviewerUpdatesParser.parse is used', () => {
       sandbox.stub(GrReviewerUpdatesParser, 'parse').returns(
           Promise.resolve('foo'));
-      return element.getChangeDetail(42).then(function(result) {
+      return element.getChangeDetail(42).then(result => {
         assert.isTrue(GrReviewerUpdatesParser.parse.calledOnce);
         assert.equal(result, 'foo');
       });
     });
 
-    test('setAccountStatus', function(done) {
+    test('setAccountStatus', done => {
       sandbox.stub(element, 'send').returns(Promise.resolve('OOO'));
       sandbox.stub(element, 'getResponseObject')
           .returns(Promise.resolve('OOO'));
       element._cache['/accounts/self/detail'] = {};
-      element.setAccountStatus('OOO').then(function() {
+      element.setAccountStatus('OOO').then(() => {
         assert.isTrue(element.send.calledWith('PUT', '/accounts/self/status',
             {status: 'OOO'}));
         assert.deepEqual(element._cache['/accounts/self/detail'],
@@ -582,38 +606,440 @@
       });
     });
 
-    test('_sendDiffDraft pending requests tracked', function(done) {
-      sandbox.stub(element, 'send', function() {
-        assert.equal(element._pendingRequests.sendDiffDraft, 1);
-        return Promise.resolve([]);
+    suite('draft comments', () => {
+      test('_sendDiffDraftRequest pending requests tracked', () => {
+        const obj = element._pendingRequests;
+        sandbox.stub(element, 'getChangeURLAndSend', () => mockPromise());
+        assert.notOk(element.hasPendingDiffDrafts());
+
+        element._sendDiffDraftRequest(null, null, null, {});
+        assert.equal(obj.sendDiffDraft.length, 1);
+        assert.isTrue(!!element.hasPendingDiffDrafts());
+
+        element._sendDiffDraftRequest(null, null, null, {});
+        assert.equal(obj.sendDiffDraft.length, 2);
+        assert.isTrue(!!element.hasPendingDiffDrafts());
+
+        for (const promise of obj.sendDiffDraft) { promise.resolve(); }
+
+        return element.awaitPendingDiffDrafts().then(() => {
+          assert.equal(obj.sendDiffDraft.length, 0);
+          assert.isFalse(!!element.hasPendingDiffDrafts());
+        });
       });
-      element.saveDiffDraft('', 1, 1).then(function() {
-        assert.equal(element._pendingRequests.sendDiffDraft, 0);
-        element.deleteDiffDraft('', 1, 1).then(function() {
-          assert.equal(element._pendingRequests.sendDiffDraft, 0);
-          done();
+
+      suite('_failForCreate200', () => {
+        test('_sendDiffDraftRequest checks for 200 on create', () => {
+          const sendPromise = Promise.resolve();
+          sandbox.stub(element, 'getChangeURLAndSend').returns(sendPromise);
+          const failStub = sandbox.stub(element, '_failForCreate200')
+              .returns(Promise.resolve());
+          return element._sendDiffDraftRequest('PUT', 123, 4, {}).then(() => {
+            assert.isTrue(failStub.calledOnce);
+            assert.isTrue(failStub.calledWithExactly(sendPromise));
+          });
+        });
+
+        test('_sendDiffDraftRequest no checks for 200 on non create', () => {
+          sandbox.stub(element, 'getChangeURLAndSend')
+              .returns(Promise.resolve());
+          const failStub = sandbox.stub(element, '_failForCreate200')
+              .returns(Promise.resolve());
+          return element._sendDiffDraftRequest('PUT', 123, 4, {id: '123'})
+              .then(() => {
+                assert.isFalse(failStub.called);
+              });
+        });
+
+        test('_failForCreate200 fails on 200', done => {
+          const result = {
+            ok: true,
+            status: 200,
+            headers: {entries: () => [
+              ['Set-CoOkiE', 'secret'],
+              ['Innocuous', 'hello'],
+            ]},
+          };
+          element._failForCreate200(Promise.resolve(result)).then(() => {
+            assert.isTrue(false, 'Promise should not resolve');
+          }).catch(e => {
+            assert.isOk(e);
+            assert.include(e.message, 'Saving draft resulted in HTTP 200');
+            assert.include(e.message, 'hello');
+            assert.notInclude(e.message, 'secret');
+            done();
+          });
+        });
+
+        test('_failForCreate200 does not fail on 201', done => {
+          const result = {
+            ok: true,
+            status: 201,
+            headers: {entries: () => []},
+          };
+          element._failForCreate200(Promise.resolve(result)).then(() => {
+            done();
+          }).catch(e => {
+            assert.isTrue(false, 'Promise should not fail');
+          });
         });
       });
     });
 
-    test('saveChangeEdit', function(done) {
-      var change_num = '1';
-      var file_name = 'index.php';
-      var file_contents = '<?php';
+    test('saveChangeEdit', done => {
+      element._projectLookup = {1: 'test'};
+      const change_num = '1';
+      const file_name = 'index.php';
+      const file_contents = '<?php';
       sandbox.stub(element, 'send').returns(
           Promise.resolve([change_num, file_name, file_contents])
       );
       sandbox.stub(element, 'getResponseObject')
           .returns(Promise.resolve([change_num, file_name, file_contents]));
       element._cache['/changes/' + change_num + '/edit/' + file_name] = {};
-      element.saveChangeEdit(change_num, file_name, file_contents).then(
-          function() {
-            assert.isTrue(element.send.calledWith('PUT',
-                '/changes/' + change_num + '/edit/' + file_name,
-                file_contents));
-            done();
-          }
+      element.saveChangeEdit(change_num, file_name, file_contents).then(() => {
+        assert.isTrue(element.send.calledWith('PUT',
+            '/changes/test~1/edit/' + file_name,
+            file_contents));
+        done();
+      });
+    });
+
+    test('putChangeCommitMessage', done => {
+      element._projectLookup = {1: 'test'};
+      const change_num = '1';
+      const message = 'this is a commit message';
+      sandbox.stub(element, 'send').returns(
+          Promise.resolve([change_num, message])
       );
+      sandbox.stub(element, 'getResponseObject')
+          .returns(Promise.resolve([change_num, message]));
+      element._cache['/changes/' + change_num + '/message'] = {};
+      element.putChangeCommitMessage(change_num, message).then(() => {
+        assert.isTrue(element.send.calledWith('PUT',
+            '/changes/test~1/message', {message}));
+        done();
+      });
+    });
+
+    test('startWorkInProgress', () => {
+      sandbox.stub(element, 'getChangeURLAndSend')
+          .returns(Promise.resolve('ok'));
+      element.startWorkInProgress('42');
+      assert.isTrue(element.getChangeURLAndSend.calledWith(
+          '42', 'POST', null, '/wip', {}));
+      element.startWorkInProgress('42', 'revising...');
+      assert.isTrue(element.getChangeURLAndSend.calledWith(
+          '42', 'POST', null, '/wip', {message: 'revising...'}));
+    });
+
+    test('startReview', () => {
+      sandbox.stub(element, 'getChangeURLAndSend')
+          .returns(Promise.resolve({}));
+      element.startReview('42', {message: 'Please review.'});
+      assert.isTrue(element.getChangeURLAndSend.calledWith(
+          '42', 'POST', null, '/ready', {message: 'Please review.'}));
+    });
+
+    test('deleteComment', done => {
+      sandbox.stub(element, 'getChangeURLAndSend').returns(Promise.resolve());
+      sandbox.stub(element, 'getResponseObject').returns('some response');
+      element.deleteComment('foo', 'bar', '01234', 'removal reason')
+          .then(response => {
+            assert.equal(response, 'some response');
+            done();
+          });
+      assert.isTrue(element.getChangeURLAndSend.calledWith(
+          'foo', 'POST', 'bar', '/comments/01234/delete',
+          {reason: 'removal reason'}));
+    });
+
+    test('createProject encodes name', () => {
+      const sendStub = sandbox.stub(element, 'send');
+      element.createProject({name: 'x/y'});
+      assert.equal(sendStub.lastCall.args[1], '/projects/x%2Fy');
+    });
+
+    test('getProjects', () => {
+      sandbox.stub(element, '_fetchSharedCacheURL');
+      element.getProjects('test', 25);
+      assert.isTrue(element._fetchSharedCacheURL.lastCall
+          .calledWithExactly('/projects/?d&n=26&S=0&m=test'));
+
+      element.getProjects(null, 25);
+      assert.isTrue(element._fetchSharedCacheURL.lastCall
+          .calledWithExactly('/projects/?d&n=26&S=0'));
+
+      element.getProjects('test', 25, 25);
+      assert.isTrue(element._fetchSharedCacheURL.lastCall
+          .calledWithExactly('/projects/?d&n=26&S=25&m=test'));
+    });
+
+    test('getProjects filter', () => {
+      sandbox.stub(element, '_fetchSharedCacheURL');
+      element.getProjects('test/test/test', 25);
+      assert.isTrue(element._fetchSharedCacheURL.lastCall
+          .calledWithExactly('/projects/?d&n=26&S=0&m=test%2Ftest%2Ftest'));
+    });
+
+    test('getProjects filter regex', () => {
+      sandbox.stub(element, '_fetchSharedCacheURL');
+      element.getProjects('^test.*', 25);
+      assert.isTrue(element._fetchSharedCacheURL.lastCall
+          .calledWithExactly('/projects/?d&n=26&S=0&r=%5Etest.*'));
+    });
+
+    test('getGroups filter regex', () => {
+      sandbox.stub(element, '_fetchSharedCacheURL');
+      element.getGroups('^test.*', 25);
+      assert.isTrue(element._fetchSharedCacheURL.lastCall
+          .calledWithExactly('/groups/?n=26&S=0&r=%5Etest.*'));
+    });
+
+    test('gerrit auth is used', () => {
+      sandbox.stub(Gerrit.Auth, 'fetch').returns(Promise.resolve());
+      element.fetchJSON('foo');
+      assert(Gerrit.Auth.fetch.called);
+    });
+
+    test('getSuggestedAccounts does not return fetchJSON', () => {
+      const fetchJSONSpy = sandbox.spy(element, 'fetchJSON');
+      return element.getSuggestedAccounts().then(accts => {
+        assert.isFalse(fetchJSONSpy.called);
+        assert.equal(accts.length, 0);
+      });
+    });
+
+    test('fetchJSON gets called by getSuggestedAccounts', () => {
+      const fetchJSONStub = sandbox.stub(element, 'fetchJSON',
+          () => Promise.resolve());
+      return element.getSuggestedAccounts('own').then(() => {
+        assert.deepEqual(fetchJSONStub.lastCall.args[3], {
+          q: 'own',
+          suggest: null,
+        });
+      });
+    });
+
+    suite('_getChangeDetail', () => {
+      test('_getChangeDetail passes params to ETags decorator', () => {
+        const changeNum = 4321;
+        element._projectLookup[changeNum] = 'test';
+        const params = {foo: 'bar'};
+        const expectedUrl = '/changes/test~4321/detail?foo=bar';
+        sandbox.stub(element._etags, 'getOptions');
+        sandbox.stub(element._etags, 'collect');
+        return element._getChangeDetail(changeNum, params).then(() => {
+          assert.isTrue(element._etags.getOptions.calledWithExactly(
+              expectedUrl));
+          assert.equal(element._etags.collect.lastCall.args[0], expectedUrl);
+        });
+      });
+
+      test('_getChangeDetail calls errFn on 500', () => {
+        const errFn = sinon.stub();
+        sandbox.stub(element, '_fetchRawJSON')
+            .returns(Promise.resolve({ok: false, status: 500}));
+        return element._getChangeDetail(123, {}, errFn).then(() => {
+          assert.isTrue(errFn.called);
+        });
+      });
+
+      test('_getChangeDetail populates _projectLookup', () => {
+        sandbox.stub(element, '_fetchRawJSON')
+            .returns(Promise.resolve({ok: true}));
+
+        const mockResponse = {_number: 1, project: 'test'};
+        sandbox.stub(element, '_readResponsePayload').returns(Promise.resolve({
+          parsed: mockResponse,
+          raw: JSON.stringify(mockResponse),
+        }));
+        return element._getChangeDetail(1).then(() => {
+          assert.equal(Object.keys(element._projectLookup).length, 1);
+          assert.equal(element._projectLookup[1], 'test');
+        });
+      });
+
+      suite('_getChangeDetail ETag cache', () => {
+        let requestUrl;
+        let mockResponseSerial;
+        let collectSpy;
+        let getPayloadSpy;
+
+        setup(() => {
+          requestUrl = '/foo/bar';
+          const mockResponse = {foo: 'bar', baz: 42};
+          mockResponseSerial = element.JSON_PREFIX +
+              JSON.stringify(mockResponse);
+          sandbox.stub(element, '_urlWithParams').returns(requestUrl);
+          sandbox.stub(element, 'getChangeActionURL')
+              .returns(Promise.resolve(requestUrl));
+          collectSpy = sandbox.spy(element._etags, 'collect');
+          getPayloadSpy = sandbox.spy(element._etags, 'getCachedPayload');
+        });
+
+        test('contributes to cache', () => {
+          sandbox.stub(element, '_fetchRawJSON').returns(Promise.resolve({
+            text: () => Promise.resolve(mockResponseSerial),
+            status: 200,
+            ok: true,
+          }));
+
+          return element._getChangeDetail(123, {}).then(detail => {
+            assert.isFalse(getPayloadSpy.called);
+            assert.isTrue(collectSpy.calledOnce);
+            const cachedResponse = element._etags.getCachedPayload(requestUrl);
+            assert.equal(cachedResponse, mockResponseSerial);
+          });
+        });
+
+        test('uses cache on HTTP 304', () => {
+          sandbox.stub(element, '_fetchRawJSON').returns(Promise.resolve({
+            text: () => Promise.resolve(mockResponseSerial),
+            status: 304,
+            ok: true,
+          }));
+
+          return element._getChangeDetail(123, {}).then(detail => {
+            assert.isFalse(collectSpy.called);
+            assert.isTrue(getPayloadSpy.calledOnce);
+          });
+        });
+      });
+    });
+
+    test('setInProjectLookup', () => {
+      element.setInProjectLookup('test', 'project');
+      assert.deepEqual(element._projectLookup, {test: 'project'});
+    });
+
+    suite('getFromProjectLookup', () => {
+      test('getChange fails', () => {
+        sandbox.stub(element, 'getChange')
+            .returns(Promise.resolve());
+        return element.getFromProjectLookup().then(val => {
+          assert.strictEqual(val, undefined);
+          assert.deepEqual(element._projectLookup, {});
+        });
+      });
+
+      test('getChange succeeds, no project', () => {
+        sandbox.stub(element, 'getChange').returns(Promise.resolve());
+        return element.getFromProjectLookup().then(val => {
+          assert.strictEqual(val, undefined);
+          assert.deepEqual(element._projectLookup, {});
+        });
+      });
+
+      test('getChange succeeds with project', () => {
+        sandbox.stub(element, 'getChange')
+            .returns(Promise.resolve({project: 'project'}));
+        return element.getFromProjectLookup('test').then(val => {
+          assert.equal(val, 'project');
+          assert.deepEqual(element._projectLookup, {test: 'project'});
+        });
+      });
+    });
+
+    suite('getChanges populates _projectLookup', () => {
+      test('multiple queries', () => {
+        sandbox.stub(element, 'fetchJSON')
+            .returns(Promise.resolve([
+              [
+                {_number: 1, project: 'test'},
+                {_number: 2, project: 'test'},
+              ], [
+                {_number: 3, project: 'test/test'},
+              ],
+            ]));
+        // When opt_query instanceof Array, fetchJSON returns
+        // Array<Array<Object>>.
+        return element.getChanges(null, []).then(() => {
+          assert.equal(Object.keys(element._projectLookup).length, 3);
+          assert.equal(element._projectLookup[1], 'test');
+          assert.equal(element._projectLookup[2], 'test');
+          assert.equal(element._projectLookup[3], 'test/test');
+        });
+      });
+
+      test('no query', () => {
+        sandbox.stub(element, 'fetchJSON')
+            .returns(Promise.resolve([
+              {_number: 1, project: 'test'},
+              {_number: 2, project: 'test'},
+              {_number: 3, project: 'test/test'},
+            ]));
+
+        // When opt_query !instanceof Array, fetchJSON returns
+        // Array<Object>.
+        return element.getChanges().then(() => {
+          assert.equal(Object.keys(element._projectLookup).length, 3);
+          assert.equal(element._projectLookup[1], 'test');
+          assert.equal(element._projectLookup[2], 'test');
+          assert.equal(element._projectLookup[3], 'test/test');
+        });
+      });
+    });
+
+    test('_getChangeURLAndFetch', () => {
+      element._projectLookup = {1: 'test'};
+      const fetchStub = sandbox.stub(element, 'fetchJSON')
+          .returns(Promise.resolve());
+      return element._getChangeURLAndFetch(1, '/test', 1).then(() => {
+        assert.isTrue(fetchStub.calledWith('/changes/test~1/revisions/1/test'));
+      });
+    });
+
+    test('getChangeURLAndSend', () => {
+      element._projectLookup = {1: 'test'};
+      const sendStub = sandbox.stub(element, 'send').returns(Promise.resolve());
+      return element.getChangeURLAndSend(1, 'POST', 1, '/test').then(() => {
+        assert.isTrue(sendStub.calledWith('POST',
+            '/changes/test~1/revisions/1/test'));
+      });
+    });
+
+    suite('reading responses', () => {
+      test('_readResponsePayload', () => {
+        const mockObject = {foo: 'bar', baz: 'foo'};
+        const serial = element.JSON_PREFIX + JSON.stringify(mockObject);
+        const mockResponse = {text: () => Promise.resolve(serial)};
+        return element._readResponsePayload(mockResponse).then(payload => {
+          assert.deepEqual(payload.parsed, mockObject);
+          assert.equal(payload.raw, serial);
+        });
+      });
+
+      test('_parsePrefixedJSON', () => {
+        const obj = {x: 3, y: {z: 4}, w: 23};
+        const serial = element.JSON_PREFIX + JSON.stringify(obj);
+        const result = element._parsePrefixedJSON(serial);
+        assert.deepEqual(result, obj);
+      });
+    });
+
+    test('setChangeTopic', () => {
+      const sendSpy = sandbox.spy(element, 'getChangeURLAndSend');
+      return element.setChangeTopic(123, 'foo-bar').then(() => {
+        assert.isTrue(sendSpy.calledOnce);
+        assert.deepEqual(sendSpy.lastCall.args[4], {topic: 'foo-bar'});
+      });
+    });
+
+    test('setChangeHashtag', () => {
+      const sendSpy = sandbox.spy(element, 'getChangeURLAndSend');
+      return element.setChangeHashtag(123, 'foo-bar').then(() => {
+        assert.isTrue(sendSpy.calledOnce);
+        assert.equal(sendSpy.lastCall.args[4], 'foo-bar');
+      });
+    });
+
+    test('generateAccountHttpPassword', () => {
+      const sendSpy = sandbox.spy(element, 'send');
+      return element.generateAccountHttpPassword().then(() => {
+        assert.isTrue(sendSpy.calledOnce);
+        assert.deepEqual(sendSpy.lastCall.args[2], {generate: true});
+      });
     });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
index 21a6bc6..c119cdf 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
@@ -21,7 +21,7 @@
     // TODO (viktard): Polyfill Object.assign for IE.
     this.result = Object.assign({}, change);
     this._lastState = {};
-  };
+  }
 
   GrReviewerUpdatesParser.parse = function(change) {
     if (!change ||
@@ -30,13 +30,15 @@
         !change.reviewer_updates.length) {
       return change;
     }
-    var parser = new GrReviewerUpdatesParser(change);
+    const parser = new GrReviewerUpdatesParser(change);
     parser._filterRemovedMessages();
     parser._groupUpdates();
     parser._formatUpdates();
+    parser._advanceUpdates();
     return parser.result;
   };
 
+  GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS = 500;
   GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
 
   GrReviewerUpdatesParser.prototype.result = null;
@@ -49,7 +51,7 @@
    * are used.
    */
   GrReviewerUpdatesParser.prototype._filterRemovedMessages = function() {
-    this.result.messages = this.result.messages.filter(function(message) {
+    this.result.messages = this.result.messages.filter(message => {
       return message.tag !== 'autogenerated:gerrit:deleteReviewer';
     });
   };
@@ -74,10 +76,10 @@
    * @param {Object} update instance of ReviewerUpdateInfo
    */
   GrReviewerUpdatesParser.prototype._completeBatch = function(update) {
-    var items = [];
-    for (var accountId in this._updateItems) {
+    const items = [];
+    for (const accountId in this._updateItems) {
       if (!this._updateItems.hasOwnProperty(accountId)) continue;
-      var updateItem = this._updateItems[accountId];
+      const updateItem = this._updateItems[accountId];
       if (this._lastState[accountId] !== updateItem.state) {
         this._lastState[accountId] = updateItem.state;
         items.push(updateItem);
@@ -96,14 +98,14 @@
    * - Groups with no-change updates are discarded (eg CC -> CC)
    */
   GrReviewerUpdatesParser.prototype._groupUpdates = function() {
-    var updates = this.result.reviewer_updates;
-    var newUpdates = updates.reduce(function(newUpdates, update) {
+    const updates = this.result.reviewer_updates;
+    const newUpdates = updates.reduce((newUpdates, update) => {
       if (!this._batch) {
         this._batch = this._startBatch(update);
       }
-      var updateDate = util.parseDate(update.updated).getTime();
-      var batchUpdateDate = util.parseDate(this._batch.date).getTime();
-      var reviewerId = update.reviewer._account_id.toString();
+      const updateDate = util.parseDate(update.updated).getTime();
+      const batchUpdateDate = util.parseDate(this._batch.date).getTime();
+      const reviewerId = update.reviewer._account_id.toString();
       if (updateDate - batchUpdateDate >
           GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS ||
           update.updated_by._account_id !== this._batch.author._account_id) {
@@ -122,7 +124,7 @@
         this._updateItems[reviewerId].prev_state = this._lastState[reviewerId];
       }
       return newUpdates;
-    }.bind(this), []);
+    }, []);
     this._completeBatch();
     if (this._batch.updates && this._batch.updates.length) {
       newUpdates.push(this._batch);
@@ -157,14 +159,14 @@
    * @return {!Object} Hash of arrays of AccountInfo, message as key.
    */
   GrReviewerUpdatesParser.prototype._groupUpdatesByMessage = function(updates) {
-    return updates.reduce(function(result, item) {
-      var message = this._getUpdateMessage(item.prev_state, item.state);
+    return updates.reduce((result, item) => {
+      const message = this._getUpdateMessage(item.prev_state, item.state);
       if (!result[message]) {
         result[message] = [];
       }
       result[message].push(item.reviewer);
       return result;
-    }.bind(this), {});
+    }, {});
   };
 
   /**
@@ -173,21 +175,48 @@
    * @see https://gerrit-review.googlesource.com/c/94490/
    */
   GrReviewerUpdatesParser.prototype._formatUpdates = function() {
-    this.result.reviewer_updates.forEach(function(update) {
-      var grouppedReviewers = this._groupUpdatesByMessage(update.updates);
-      var newUpdates = [];
-      for (var message in grouppedReviewers) {
+    for (const update of this.result.reviewer_updates) {
+      const grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+      const newUpdates = [];
+      for (const message in grouppedReviewers) {
         if (grouppedReviewers.hasOwnProperty(message)) {
           newUpdates.push({
-            message: message,
+            message,
             reviewers: grouppedReviewers[message],
           });
         }
       }
       update.updates = newUpdates;
-    }.bind(this));
+    }
+  };
+
+  /**
+   * Moves reviewer updates that are within short time frame of change messages
+   * back in time so they would come before change messages.
+   * TODO(viktard): Remove when server-side serves reviewer updates like so.
+   */
+  GrReviewerUpdatesParser.prototype._advanceUpdates = function() {
+    const updates = this.result.reviewer_updates;
+    const messages = this.result.messages;
+    messages.forEach((message, index) => {
+      const messageDate = util.parseDate(message.date).getTime();
+      const nextMessageDate = index === messages.length - 1 ? null :
+          util.parseDate(messages[index + 1].date).getTime();
+      for (const update of updates) {
+        const date = util.parseDate(update.date).getTime();
+        if (date >= messageDate
+            && (!nextMessageDate || date < nextMessageDate)) {
+          const timestamp = util.parseDate(update.date).getTime() -
+              GrReviewerUpdatesParser.MESSAGE_REVIEWERS_THRESHOLD_MILLIS;
+          update.date = new Date(timestamp)
+            .toISOString().replace('T', ' ').replace('Z', '000000');
+        }
+        if (nextMessageDate && date > nextMessageDate) {
+          break;
+        }
+      }
+    });
   };
 
   window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
-
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
index 1ae04a0..bf082e8 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
@@ -20,27 +20,25 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
-<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <script src="../../../scripts/util.js"></script>
 <script src="gr-reviewer-updates-parser.js"></script>
 
 <script>
-  suite('gr-reviewer-updates-parser tests', function() {
-    var sandbox;
-    var instance;
+  suite('gr-reviewer-updates-parser tests', () => {
+    let sandbox;
+    let instance;
 
-    setup(function() {
+    setup(() => {
       sandbox = sinon.sandbox.create();
     });
 
-    teardown(function() {
+    teardown(() => {
       sandbox.restore();
     });
 
-    test('ignores changes without messages', function() {
-      var change = {};
+    test('ignores changes without messages', () => {
+      const change = {};
       sandbox.stub(
           GrReviewerUpdatesParser.prototype, '_filterRemovedMessages');
       sandbox.stub(
@@ -56,8 +54,8 @@
           GrReviewerUpdatesParser.prototype._formatUpdates.called);
     });
 
-    test('ignores changes without reviewer updates', function() {
-      var change = {
+    test('ignores changes without reviewer updates', () => {
+      const change = {
         messages: [],
       };
       sandbox.stub(
@@ -75,8 +73,8 @@
           GrReviewerUpdatesParser.prototype._formatUpdates.called);
     });
 
-    test('ignores changes with empty reviewer updates', function() {
-      var change = {
+    test('ignores changes with empty reviewer updates', () => {
+      const change = {
         messages: [],
         reviewer_updates: [],
       };
@@ -95,18 +93,18 @@
           GrReviewerUpdatesParser.prototype._formatUpdates.called);
     });
 
-    test('filter removed messages', function() {
-      var change = {
-          messages: [
-            {
-              message: 'msg1',
-              tag: 'autogenerated:gerrit:deleteReviewer',
-            },
-            {
-              message: 'msg2',
-              tag: 'foo',
-            }
-          ],
+    test('filter removed messages', () => {
+      const change = {
+        messages: [
+          {
+            message: 'msg1',
+            tag: 'autogenerated:gerrit:deleteReviewer',
+          },
+          {
+            message: 'msg2',
+            tag: 'foo',
+          },
+        ],
       };
       instance = new GrReviewerUpdatesParser(change);
       instance._filterRemovedMessages();
@@ -118,22 +116,22 @@
       });
     });
 
-    test('group reviewer updates', function() {
-      var reviewer1 = {_account_id: 1};
-      var reviewer2 = {_account_id: 2};
-      var date1 = '2017-01-26 12:11:50.000000000';
-      var date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
-      var date3 = '2017-01-26 12:33:50.000000000';
-      var date4 = '2017-01-26 12:44:50.000000000';
-      var makeItem = function(state, reviewer, opt_date, opt_author) {
+    test('group reviewer updates', () => {
+      const reviewer1 = {_account_id: 1};
+      const reviewer2 = {_account_id: 2};
+      const date1 = '2017-01-26 12:11:50.000000000';
+      const date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
+      const date3 = '2017-01-26 12:33:50.000000000';
+      const date4 = '2017-01-26 12:44:50.000000000';
+      const makeItem = function(state, reviewer, opt_date, opt_author) {
         return {
-          reviewer: reviewer,
+          reviewer,
           updated: opt_date || date1,
           updated_by: opt_author || reviewer1,
-          state: state,
+          state,
         };
       };
-      var change = {
+      let change = {
         reviewer_updates: [
           makeItem('REVIEWER', reviewer1), // New group.
           makeItem('CC', reviewer2), // Appended.
@@ -198,36 +196,36 @@
       ]);
     });
 
-    test('format reviewer updates', function() {
-      var reviewer1 = {_account_id: 1};
-      var reviewer2 = {_account_id: 2};
-      var makeItem = function(prev, state, opt_reviewer) {
+    test('format reviewer updates', () => {
+      const reviewer1 = {_account_id: 1};
+      const reviewer2 = {_account_id: 2};
+      const makeItem = function(prev, state, opt_reviewer) {
         return {
           reviewer: opt_reviewer || reviewer1,
           prev_state: prev,
-          state: state,
+          state,
         };
       };
-      var makeUpdate = function(items) {
+      const makeUpdate = function(items) {
         return {
           author: reviewer1,
           updated: '',
           updates: items,
         };
       };
-      var change = {
-          reviewer_updates: [
-            makeUpdate([
-              makeItem(undefined, 'CC'),
-              makeItem(undefined, 'CC', reviewer2)
-            ]),
-            makeUpdate([
-              makeItem('CC', 'REVIEWER'),
-              makeItem('REVIEWER', 'REMOVED'),
-              makeItem('REMOVED', 'REVIEWER'),
-              makeItem(undefined, 'REVIEWER', reviewer2),
-            ]),
-          ],
+      const change = {
+        reviewer_updates: [
+          makeUpdate([
+            makeItem(undefined, 'CC'),
+            makeItem(undefined, 'CC', reviewer2),
+          ]),
+          makeUpdate([
+            makeItem('CC', 'REVIEWER'),
+            makeItem('REVIEWER', 'REMOVED'),
+            makeItem('REMOVED', 'REVIEWER'),
+            makeItem(undefined, 'REVIEWER', reviewer2),
+          ]),
+        ],
       };
 
       instance = new GrReviewerUpdatesParser(change);
@@ -237,7 +235,7 @@
       assert.equal(change.reviewer_updates[0].updates.length, 1);
       assert.equal(change.reviewer_updates[1].updates.length, 3);
 
-      var items = change.reviewer_updates[0].updates;
+      let items = change.reviewer_updates[0].updates;
       assert.equal(items[0].message, 'added to CC: ');
       assert.deepEqual(items[0].reviewers, [reviewer1, reviewer2]);
 
@@ -249,5 +247,56 @@
       assert.equal(items[2].message, 'added to REVIEWER: ');
       assert.deepEqual(items[2].reviewers, [reviewer1, reviewer2]);
     });
+
+    test('_advanceUpdates', () => {
+      const T0 = util.parseDate('2017-02-17 19:04:18.000000000').getTime();
+      const tplus = delta => {
+        return new Date(T0 + delta)
+            .toISOString().replace('T', ' ').replace('Z', '000000');
+      };
+      const change = {
+        reviewer_updates: [{
+          date: tplus(0),
+          type: 'REVIEWER_UPDATE',
+          updates: [{
+            message: 'same time update',
+          }],
+        }, {
+          date: tplus(200),
+          type: 'REVIEWER_UPDATE',
+          updates: [{
+            message: 'update within threshold',
+          }],
+        }, {
+          date: tplus(600),
+          type: 'REVIEWER_UPDATE',
+          updates: [{
+            message: 'update between messages',
+          }],
+        }, {
+          date: tplus(1000),
+          type: 'REVIEWER_UPDATE',
+          updates: [{
+            message: 'late update',
+          }],
+        }],
+        messages: [{
+          id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+          date: tplus(0),
+          message: 'Uploaded patch set 1.',
+        }, {
+          id: '6734489eb9d642de28dbf2bcf9bda875923800d8',
+          date: tplus(800),
+          message: 'Uploaded patch set 2.',
+        }],
+      };
+      instance = new GrReviewerUpdatesParser(change);
+      instance._advanceUpdates();
+      const updates = instance.result.reviewer_updates;
+      assert.isBelow(util.parseDate(updates[0].date).getTime(), T0);
+      assert.isBelow(util.parseDate(updates[1].date).getTime(), T0);
+      assert.equal(updates[2].date, tplus(100));
+      assert.equal(updates[3].date, tplus(500));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
index 8141c8a..add07ea 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/mock-diff-response_test.html
@@ -15,51 +15,51 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <dom-module id="mock-diff-response">
   <template></template>
   <script>
     (function() {
       'use strict';
 
-      var RESPONSE = {
-        'meta_a': {
-          'name': 'lorem-ipsum.txt',
-          'content_type': 'text/plain',
-          'lines': 45,
+      const RESPONSE = {
+        meta_a: {
+          name: 'lorem-ipsum.txt',
+          content_type: 'text/plain',
+          lines: 45,
         },
-        'meta_b': {
-          'name': 'lorem-ipsum.txt',
-          'content_type': 'text/plain',
-          'lines': 48,
+        meta_b: {
+          name: 'lorem-ipsum.txt',
+          content_type: 'text/plain',
+          lines: 48,
         },
-        'intraline_status': 'OK',
-        'change_type': 'MODIFIED',
-        'diff_header': [
+        intraline_status: 'OK',
+        change_type: 'MODIFIED',
+        diff_header: [
           'diff --git a/lorem-ipsum.txt b/lorem-ipsum.txt',
           'index b2adcf4..554ae49 100644',
           '--- a/lorem-ipsum.txt',
           '+++ b/lorem-ipsum.txt',
         ],
-        'content': [
+        content: [
           {
-            'ab': [
+            ab: [
               'Lorem ipsum dolor sit amet, suspendisse inceptos vehicula, ' +
                 'nulla phasellus.',
               'Mattis lectus.',
               'Sodales duis.',
               'Orci a faucibus.',
-            ]
+            ],
           },
           {
-            'b': [
+            b: [
               'Nullam neque, ligula ac, id blandit.',
               'Sagittis tincidunt torquent, tempor nunc amet.',
               'At rhoncus id.',
             ],
           },
           {
-            'ab': [
+            ab: [
               'Sem nascetur, erat ut, non in.',
               'A donec, venenatis pellentesque dis.',
               'Mauris mauris.',
@@ -68,7 +68,7 @@
             ],
           },
           {
-            'a': [
+            a: [
               'Est amet, vestibulum pellentesque.',
               'Erat ligula.',
               'Justo eros.',
@@ -76,25 +76,25 @@
             ],
           },
           {
-            'ab': [
+            ab: [
               'Arcu eget, rhoncus amet cursus, ipsum elementum.',
               'Eros suspendisse.',
             ],
           },
           {
-            'a': [
+            a: [
               'Rhoncus tempor, ultricies aliquam ipsum.',
             ],
-            'b': [
+            b: [
               'Rhoncus tempor, ultricies praesent ipsum.',
             ],
-            'edit_a': [
+            edit_a: [
               [
                 26,
                 7,
               ],
             ],
-            'edit_b': [
+            edit_b: [
               [
                 26,
                 8,
@@ -102,7 +102,7 @@
             ],
           },
           {
-            'ab': [
+            ab: [
               'Sollicitudin duis.',
               'Blandit blandit, ante nisl fusce.',
               'Felis ac at, tellus consectetuer.',
@@ -131,7 +131,7 @@
             ],
           },
           {
-            'b': [
+            b: [
               'Eu congue risus.',
               'Enim ac, quis elementum.',
               'Non et elit.',
@@ -139,7 +139,7 @@
             ],
           },
           {
-            'ab': [
+            ab: [
               'Nec at.',
               'Arcu mauris, venenatis lacus fermentum, praesent duis.',
               'Pellentesque amet et, tellus duis.',
@@ -155,7 +155,7 @@
         properties: {
           diffResponse: {
             type: Object,
-            value: function() {
+            value() {
               return RESPONSE;
             },
           },
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
index fed81bb..b0b6ea9 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.html
@@ -16,5 +16,6 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <dom-module id="gr-select">
+  <content></content>
   <script src="gr-select.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
index bef260e9..21e5e1f 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select.js
@@ -16,7 +16,6 @@
 
   Polymer({
     is: 'gr-select',
-    extends: 'select',
     properties: {
       bindValue: {
         type: String,
@@ -26,23 +25,34 @@
     },
 
     listeners: {
-      change: '_valueChanged',
+      'change': '_valueChanged',
       'dom-change': '_updateValue',
     },
 
-    _updateValue: function() {
+    get nativeSelect() {
+      return this.$$('select');
+    },
+
+    _updateValue() {
       if (this.bindValue) {
-        this.value = this.bindValue;
+        // Set for chrome/safari so it happens instantly
+        this.nativeSelect.value = this.bindValue;
+        // Async needed for firefox to populate value. It was trying to do it
+        // before options from a dom-repeat were rendered previously.
+        // See https://bugs.chromium.org/p/gerrit/issues/detail?id=7735
+        this.async(() => {
+          this.nativeSelect.value = this.bindValue;
+        }, 1);
       }
     },
 
-    _valueChanged: function() {
-      this.bindValue = this.value;
+    _valueChanged() {
+      this.bindValue = this.nativeSelect.value;
     },
 
-    ready: function() {
+    ready() {
       // If not set via the property, set bind-value to the element value.
-      if (!this.bindValue) { this.bindValue = this.value; }
+      if (!this.bindValue) { this.bindValue = this.nativeSelect.value; }
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
index bd22505..e7a4965 100644
--- a/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-select/gr-select_test.html
@@ -20,35 +20,37 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-select.html">
 
 <script>void(0);</script>
 
 <test-fixture id="basic">
   <template>
-    <select is="gr-select">
-      <option value="1">One</option>
-      <option value="2">Two</option>
-      <option value="3">Three</option>
-    </select>
+    <gr-select>
+      <select>
+        <option value="1">One</option>
+        <option value="2">Two</option>
+        <option value="3">Three</option>
+      </select>
+    </gr-select>
   </template>
 </test-fixture>
 
 <script>
-  suite('gr-select tests', function() {
-    var element;
+  suite('gr-select tests', () => {
+    let element;
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('bidirectional binding property-to-attribute', function() {
-      var changeStub = sinon.stub();
+    test('bidirectional binding property-to-attribute', () => {
+      const changeStub = sinon.stub();
       element.addEventListener('bind-value-changed', changeStub);
 
       // The selected element should be the first one by default.
-      assert.equal(element.value, '1');
+      assert.equal(element.nativeSelect.value, '1');
       assert.equal(element.bindValue, '1');
       assert.isFalse(changeStub.called);
 
@@ -56,26 +58,26 @@
       element.bindValue = '2';
 
       // It should be updated.
-      assert.equal(element.value, '2');
+      assert.equal(element.nativeSelect.value, '2');
       assert.equal(element.bindValue, '2');
       assert.isTrue(changeStub.called);
     });
 
-    test('bidirectional binding attribute-to-property', function() {
-      var changeStub = sinon.stub();
+    test('bidirectional binding attribute-to-property', () => {
+      const changeStub = sinon.stub();
       element.addEventListener('bind-value-changed', changeStub);
 
       // The selected element should be the first one by default.
-      assert.equal(element.value, '1');
+      assert.equal(element.nativeSelect.value, '1');
       assert.equal(element.bindValue, '1');
       assert.isFalse(changeStub.called);
 
       // Now change the value.
-      element.value = '3';
+      element.nativeSelect.value = '3';
       element.fire('change');
 
       // It should be updated.
-      assert.equal(element.value, '3');
+      assert.equal(element.nativeSelect.value, '3');
       assert.equal(element.bindValue, '3');
       assert.isTrue(changeStub.called);
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
index 77d1c05..7f61edd 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage.js
@@ -15,19 +15,20 @@
   'use strict';
 
   // Date cutoff is one day:
-  var DRAFT_MAX_AGE = 24 * 60 * 60 * 1000;
+  const DRAFT_MAX_AGE = 24 * 60 * 60 * 1000;
 
   // Clean up old entries no more frequently than one day.
-  var CLEANUP_THROTTLE_INTERVAL = 24 * 60 * 60 * 1000;
+  const CLEANUP_THROTTLE_INTERVAL = 24 * 60 * 60 * 1000;
 
   Polymer({
     is: 'gr-storage',
 
     properties: {
       _lastCleanup: Number,
+      /** @type {?Storage} */
       _storage: {
         type: Object,
-        value: function() {
+        value() {
           return window.localStorage;
         },
       },
@@ -37,42 +38,43 @@
       },
     },
 
-    getDraftComment: function(location) {
+    getDraftComment(location) {
       this._cleanupDrafts();
       return this._getObject(this._getDraftKey(location));
     },
 
-    setDraftComment: function(location, message) {
-      var key = this._getDraftKey(location);
-      this._setObject(key, {message: message, updated: Date.now()});
+    setDraftComment(location, message) {
+      const key = this._getDraftKey(location);
+      this._setObject(key, {message, updated: Date.now()});
     },
 
-    eraseDraftComment: function(location) {
-      var key = this._getDraftKey(location);
+    eraseDraftComment(location) {
+      const key = this._getDraftKey(location);
       this._storage.removeItem(key);
     },
 
-    getPreferences: function() {
+    getPreferences() {
       return this._getObject('localPrefs');
     },
 
-    savePreferences: function(localPrefs) {
+    savePreferences(localPrefs) {
       this._setObject('localPrefs', localPrefs || null);
     },
 
-    _getDraftKey: function(location) {
-      var range = location.range ? location.range.start_line + '-' +
-          location.range.start_character + '-' + location.range.end_character +
-          '-' + location.range.end_line : null;
-      var key = ['draft', location.changeNum, location.patchNum, location.path,
-          location.line || ''].join(':');
+    _getDraftKey(location) {
+      const range = location.range ?
+          `${location.range.start_line}-${location.range.start_character}` +
+              `-${location.range.end_character}-${location.range.end_line}` :
+          null;
+      let key = ['draft', location.changeNum, location.patchNum, location.path,
+        location.line || ''].join(':');
       if (range) {
         key = key + ':' + range;
       }
       return key;
     },
 
-    _cleanupDrafts: function() {
+    _cleanupDrafts() {
       // Throttle cleanup to the throttle interval.
       if (this._lastCleanup &&
           Date.now() - this._lastCleanup < CLEANUP_THROTTLE_INTERVAL) {
@@ -80,9 +82,9 @@
       }
       this._lastCleanup = Date.now();
 
-      var draft;
-      for (var key in this._storage) {
-        if (key.indexOf('draft:') === 0) {
+      let draft;
+      for (const key in this._storage) {
+        if (key.startsWith('draft:')) {
           draft = this._getObject(key);
           if (Date.now() - draft.updated > DRAFT_MAX_AGE) {
             this._storage.removeItem(key);
@@ -91,13 +93,13 @@
       }
     },
 
-    _getObject: function(key) {
-      var serial = this._storage.getItem(key);
+    _getObject(key) {
+      const serial = this._storage.getItem(key);
       if (!serial) { return null; }
       return JSON.parse(serial);
     },
 
-    _setObject: function(key, obj) {
+    _setObject(key, obj) {
       if (this._exceededQuota) { return; }
       try {
         this._storage.setItem(key, JSON.stringify(obj));
diff --git a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
index 6d77c55..ce8ec20 100644
--- a/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-storage/gr-storage_test.html
@@ -19,7 +19,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-storage.html">
 
 <script>void(0);</script>
@@ -31,43 +31,44 @@
 </test-fixture>
 
 <script>
-  suite('gr-storage tests', function() {
-    var element;
+  suite('gr-storage tests', () => {
+    let element;
 
     function mockStorage(opt_quotaExceeded) {
       return {
-        getItem: function(key) { return this[key]; },
-        removeItem: function(key) { delete this[key]; },
-        setItem: function(key, value) {
+        getItem(key) { return this[key]; },
+        removeItem(key) { delete this[key]; },
+        setItem(key, value) {
+          // eslint-disable-next-line no-throw-literal
           if (opt_quotaExceeded) { throw {code: 22}; /* Quota exceeded */ }
           this[key] = value;
         },
       };
     }
 
-    setup(function() {
+    setup(() => {
       element = fixture('basic');
       element._storage = mockStorage();
     });
 
-    test('storing, retrieving and erasing drafts', function() {
-      var changeNum = 1234;
-      var patchNum = 5;
-      var path = 'my_source_file.js';
-      var line = 123;
-      var location = {
-        changeNum: changeNum,
-        patchNum: patchNum,
-        path: path,
-        line: line,
+    test('storing, retrieving and erasing drafts', () => {
+      const changeNum = 1234;
+      const patchNum = 5;
+      const path = 'my_source_file.js';
+      const line = 123;
+      const location = {
+        changeNum,
+        patchNum,
+        path,
+        line,
       };
 
       // The key is in the expected format.
-      var key = element._getDraftKey(location);
+      const key = element._getDraftKey(location);
       assert.equal(key, ['draft', changeNum, patchNum, path, line].join(':'));
 
       // There should be no draft initially.
-      var draft = element.getDraftComment(location);
+      const draft = element.getDraftComment(location);
       assert.isNotOk(draft);
 
       // Setting the draft stores it under the expected key.
@@ -82,24 +83,24 @@
       assert.isNotOk(element._storage.getItem(key));
     });
 
-    test('automatically removes old drafts', function() {
-      var changeNum = 1234;
-      var patchNum = 5;
-      var path = 'my_source_file.js';
-      var line = 123;
-      var location = {
-        changeNum: changeNum,
-        patchNum: patchNum,
-        path: path,
-        line: line,
+    test('automatically removes old drafts', () => {
+      const changeNum = 1234;
+      const patchNum = 5;
+      const path = 'my_source_file.js';
+      const line = 123;
+      const location = {
+        changeNum,
+        patchNum,
+        path,
+        line,
       };
 
-      var key = element._getDraftKey(location);
+      const key = element._getDraftKey(location);
 
       // Make sure that the call to cleanup doesn't get throttled.
       element._lastCleanup = 0;
 
-      var cleanupSpy = sinon.spy(element, '_cleanupDrafts');
+      const cleanupSpy = sinon.spy(element, '_cleanupDrafts');
 
       // Create a message with a timestamp that is a second behind the max age.
       element._storage.setItem(key, JSON.stringify({
@@ -108,7 +109,7 @@
       }));
 
       // Getting the draft should cause it to be removed.
-      var draft = element.getDraftComment(location);
+      const draft = element.getDraftComment(location);
 
       assert.isTrue(cleanupSpy.called);
       assert.isNotOk(draft);
@@ -117,18 +118,18 @@
       cleanupSpy.restore();
     });
 
-    test('_getDraftKey', function() {
-      var changeNum = 1234;
-      var patchNum = 5;
-      var path = 'my_source_file.js';
-      var line = 123;
-      var location = {
-        changeNum: changeNum,
-        patchNum: patchNum,
-        path: path,
-        line: line,
+    test('_getDraftKey', () => {
+      const changeNum = 1234;
+      const patchNum = 5;
+      const path = 'my_source_file.js';
+      const line = 123;
+      const location = {
+        changeNum,
+        patchNum,
+        path,
+        line,
       };
-      var expectedResult = 'draft:1234:5:my_source_file.js:123';
+      let expectedResult = 'draft:1234:5:my_source_file.js:123';
       assert.equal(element._getDraftKey(location), expectedResult);
       location.range = {
         start_character: 1,
@@ -140,21 +141,21 @@
       assert.equal(element._getDraftKey(location), expectedResult);
     });
 
-    test('exceeded quota disables storage', function() {
+    test('exceeded quota disables storage', () => {
       element._storage = mockStorage(true);
       assert.isFalse(element._exceededQuota);
 
-      var changeNum = 1234;
-      var patchNum = 5;
-      var path = 'my_source_file.js';
-      var line = 123;
-      var location = {
-        changeNum: changeNum,
-        patchNum: patchNum,
-        path: path,
-        line: line,
+      const changeNum = 1234;
+      const patchNum = 5;
+      const path = 'my_source_file.js';
+      const line = 123;
+      const location = {
+        changeNum,
+        patchNum,
+        path,
+        line,
       };
-      var key = element._getDraftKey(location);
+      const key = element._getDraftKey(location);
       element.setDraftComment(location, 'my comment');
       assert.isTrue(element._exceededQuota);
       assert.isNotOk(element._storage.getItem(key));
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
new file mode 100644
index 0000000..3c28674
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.html
@@ -0,0 +1,96 @@
+<!--
+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.
+-->
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+
+<link rel="import" href="../../../behaviors/keyboard-shortcut-behavior/keyboard-shortcut-behavior.html">
+<link rel="import" href="../../shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown.html">
+<link rel="import" href="../../shared/gr-cursor-manager/gr-cursor-manager.html">
+<link rel="import" href="../../shared/gr-overlay/gr-overlay.html">
+<link rel="import" href="../../../bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html">
+<link rel="import" href="../../../bower_components/iron-autogrow-textarea/iron-autogrow-textarea.html">
+<link rel="import" href="../../../styles/shared-styles.html">
+
+<dom-module id="gr-textarea">
+  <template>
+    <style include="shared-styles">
+      :host {
+        display: block;
+        position: relative;
+      }
+      :host(.monospace) {
+        font-family: var(--monospace-font-family);
+      }
+      #emojiSuggestions {
+        font-family: var(--font-family);
+      }
+      gr-autocomplete {
+        display: inline-block
+      }
+      #textarea {
+        background-color: var(--background-color, none);
+        width: 100%;
+      }
+      #hiddenText #emojiSuggestions {
+        visibility: visible;
+        white-space: normal;
+      }
+      /*This is needed to not add a scroll bar on the side of gr-textarea
+      since there is 2px of padding in iron-autogrow-textarea for the
+      native textarea*/
+      iron-autogrow-textarea {
+        padding: 2px;
+        position: relative;
+      }
+      #textarea.noBorder {
+        border: none;
+      }
+      #hiddenText {
+        display: block;
+        float: left;
+        position: absolute;
+        visibility: hidden;
+        width: 100%;
+        white-space: pre-wrap;
+      }
+    </style>
+    <div id="hiddenText"></div>
+    <!-- When the autocomplete is open, the span is moved at the end of
+      hiddenText in order to correctly position the dropdown. After being moved,
+      it is set as the positionTarget for the emojiSuggestions dropdown. -->
+    <span id="caratSpan"></span>
+    <gr-autocomplete-dropdown
+        vertical-align="top"
+        horizontal-align="left"
+        dynamic-align
+        id="emojiSuggestions"
+        suggestions="[[_suggestions]]"
+        index="[[_index]]"
+        vertical-offset="[[_verticalOffset]]"
+        on-dropdown-closed="_resetEmojiDropdown"
+        on-item-selected="_handleEmojiSelect">
+    </gr-autocomplete-dropdown>
+    <iron-autogrow-textarea
+        id="textarea"
+        autocomplete="[[autocomplete]]"
+        placeholder=[[placeholder]]
+        disabled="[[disabled]]"
+        rows="[[rows]]"
+        max-rows="[[maxRows]]"
+        value="{{text}}"
+        on-bind-value-changed="_onValueChanged"></iron-autogrow-textarea>
+  </template>
+  <script src="gr-textarea.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
new file mode 100644
index 0000000..519db55
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea.js
@@ -0,0 +1,296 @@
+// 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.
+(function() {
+  'use strict';
+
+  const MAX_ITEMS_DROPDOWN = 10;
+
+  const ALL_SUGGESTIONS = [
+    {value: '💯', match: '100'},
+    {value: '💔', match: 'broken heart'},
+    {value: '🍺', match: 'beer'},
+    {value: '✔', match: 'check'},
+    {value: '😎', match: 'cool'},
+    {value: '😕', match: 'confused'},
+    {value: '😭', match: 'crying'},
+    {value: '🔥', match: 'fire'},
+    {value: '👊', match: 'fistbump'},
+    {value: '🐨', match: 'koala'},
+    {value: '😄', match: 'laugh'},
+    {value: '🤓', match: 'glasses'},
+    {value: '😆', match: 'grin'},
+    {value: '😐', match: 'neutral'},
+    {value: '👌', match: 'ok'},
+    {value: '🎉', match: 'party'},
+    {value: '💩', match: 'poop'},
+    {value: '🙏', match: 'pray'},
+    {value: '😞', match: 'sad'},
+    {value: '😮', match: 'shock'},
+    {value: '😊', match: 'smile'},
+    {value: '😢', match: 'tear'},
+    {value: '😂', match: 'tears'},
+    {value: '😋', match: 'tongue'},
+    {value: '👍', match: 'thumbs up'},
+    {value: '👎', match: 'thumbs down'},
+    {value: '😒', match: 'unamused'},
+    {value: '😉', match: 'wink'},
+    {value: '🍷', match: 'wine'},
+    {value: '😜', match: 'winking tongue'},
+  ];
+
+  Polymer({
+    is: 'gr-textarea',
+
+    /**
+     * @event bind-value-changed
+     */
+
+    properties: {
+      autocomplete: Boolean,
+      disabled: Boolean,
+      rows: Number,
+      maxRows: Number,
+      placeholder: String,
+      text: {
+        type: String,
+        notify: true,
+        observer: '_handleTextChanged',
+      },
+      backgroundColor: {
+        type: String,
+        value: '#fff',
+      },
+      hideBorder: {
+        type: Boolean,
+        value: false,
+      },
+      monospace: {
+        type: Boolean,
+        value: false,
+      },
+      /** @type(?number) */
+      _colonIndex: Number,
+      _currentSearchString: {
+        type: String,
+        value: '',
+        observer: '_determineSuggestions',
+      },
+      _hideAutocomplete: {
+        type: Boolean,
+        value: true,
+      },
+      _index: Number,
+      _suggestions: Array,
+      // Offset makes dropdown appear below text.
+      _verticalOffset: {
+        type: Number,
+        value: 20,
+        readOnly: true,
+      },
+    },
+
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
+    keyBindings: {
+      esc: '_handleEscKey',
+      tab: '_handleEnterByKey',
+      enter: '_handleEnterByKey',
+      up: '_handleUpKey',
+      down: '_handleDownKey',
+    },
+
+    ready() {
+      this._resetEmojiDropdown();
+      if (this.monospace) {
+        this.classList.add('monospace');
+      }
+      if (this.hideBorder) {
+        this.$.textarea.classList.add('noBorder');
+      }
+      if (this.backgroundColor) {
+        this.updateStyles({'--background-color': this.backgroundColor});
+      }
+    },
+
+    closeDropdown() {
+      return this.$.emojiSuggestions.close();
+    },
+
+    getNativeTextarea() {
+      return this.$.textarea.textarea;
+    },
+
+    putCursorAtEnd() {
+      const textarea = this.getNativeTextarea();
+      // Put the cursor at the end always.
+      textarea.selectionStart = textarea.value.length;
+      textarea.selectionEnd = textarea.selectionStart;
+      this.async(() => {
+        textarea.focus();
+      });
+    },
+
+    _handleEscKey(e) {
+      if (this._hideAutocomplete) { return; }
+      e.preventDefault();
+      e.stopPropagation();
+      this._resetEmojiDropdown();
+    },
+
+    _handleUpKey(e) {
+      if (this._hideAutocomplete) { return; }
+      e.preventDefault();
+      e.stopPropagation();
+      this.$.emojiSuggestions.cursorUp();
+      this.$.textarea.textarea.focus();
+    },
+
+    _handleDownKey(e) {
+      if (this._hideAutocomplete) { return; }
+      e.preventDefault();
+      e.stopPropagation();
+      this.$.emojiSuggestions.cursorDown();
+      this.$.textarea.textarea.focus();
+    },
+
+    _handleEnterByKey(e) {
+      if (this._hideAutocomplete) { return; }
+      e.preventDefault();
+      e.stopPropagation();
+      this.text = this._getText(this.$.emojiSuggestions.getCurrentText());
+      this._resetEmojiDropdown();
+    },
+
+    _handleEmojiSelect(e) {
+      this.text = this._getText(e.detail.selected.dataset.value);
+      this._resetEmojiDropdown();
+    },
+
+    _getText(value) {
+      return this.text.substr(0, this._colonIndex || 0) +
+          value + this.text.substr(this.$.textarea.selectionStart) + ' ';
+    },
+    /**
+     * Uses a hidden element with the same width and styling of the textarea and
+     * the text up until the point of interest. Then caratSpan element is added
+     * to the end and is set to be the positionTarget for the dropdown. Together
+     * this allows the dropdown to appear near where the user is typing.
+     */
+    _updateCaratPosition() {
+      this._hideAutocomplete = false;
+      this.$.hiddenText.textContent = this.$.textarea.value.substr(0,
+          this.$.textarea.selectionStart);
+
+      const caratSpan = this.$.caratSpan;
+      this.$.hiddenText.appendChild(caratSpan);
+      this.$.emojiSuggestions.positionTarget = caratSpan;
+      this._openEmojiDropdown();
+    },
+
+    _getFontSize() {
+      const fontSizePx = getComputedStyle(this).fontSize || '12px';
+      return parseInt(fontSizePx.substr(0, fontSizePx.length - 2),
+          10);
+    },
+
+    _getScrollTop() {
+      return document.body.scrollTop;
+    },
+
+    /**
+     * _handleKeydown used for key handling in the this.$.textarea AND all child
+     * autocomplete options.
+     */
+    _onValueChanged(e) {
+      // Relay the event.
+      this.fire('bind-value-changed', e);
+
+      // If cursor is not in textarea (just opened with colon as last char),
+      // Don't do anything.
+      if (!e.currentTarget.focused) { return; }
+      const newChar = e.detail.value[this.$.textarea.selectionStart - 1];
+
+      // When a colon is detected, set a colon index, but don't do anything else
+      // yet.
+      if (newChar === ':') {
+        this._colonIndex = this.$.textarea.selectionStart - 1;
+      // If the colon index exists, continue to determine what needs to be done
+      // with the dropdown. It may be open or closed at this point.
+      } else if (this._colonIndex !== null) {
+        // The search string is a substring of the textarea's value from (1
+        // position after) the colon index to the cursor position.
+        this._currentSearchString = e.detail.value.substr(this._colonIndex + 1,
+            this.$.textarea.selectionStart);
+        // Under the following conditions, close and reset the dropdown:
+        // - The cursor is no longer at the end of the current search string
+        // - The search string is an space or new line
+        // - The colon has been removed
+        // - There are no suggestions that match the search string
+        if (this.$.textarea.selectionStart !==
+            this._currentSearchString.length + this._colonIndex + 1 ||
+            this._currentSearchString === ' ' ||
+            this._currentSearchString === '\n' ||
+            !(e.detail.value[this._colonIndex] === ':') ||
+            !this._suggestions.length) {
+          this._resetEmojiDropdown();
+        // Otherwise open the dropdown and set the position to be just below the
+        // cursor.
+        } else if (this.$.emojiSuggestions.isHidden) {
+          this._updateCaratPosition();
+        }
+        this.$.textarea.textarea.focus();
+      }
+    },
+    _openEmojiDropdown() {
+      this.$.emojiSuggestions.open();
+    },
+
+    _formatSuggestions(matchedSuggestions) {
+      const suggestions = [];
+      for (const suggestion of matchedSuggestions) {
+        suggestion.dataValue = suggestion.value;
+        suggestion.text = suggestion.value + ' ' + suggestion.match;
+        suggestions.push(suggestion);
+      }
+      this.set('_suggestions', suggestions);
+    },
+
+    _determineSuggestions(emojiText) {
+      if (!emojiText.length) {
+        this._formatSuggestions(ALL_SUGGESTIONS);
+      }
+      const matches = ALL_SUGGESTIONS.filter(suggestion => {
+        return suggestion.match.includes(emojiText);
+      }).splice(0, MAX_ITEMS_DROPDOWN);
+      this._formatSuggestions(matches);
+    },
+
+    _resetEmojiDropdown() {
+      // hide and reset the autocomplete dropdown.
+      Polymer.dom.flush();
+      this._currentSearchString = '';
+      this._hideAutocomplete = true;
+      this.closeDropdown();
+      this._colonIndex = null;
+      this.$.textarea.textarea.focus();
+    },
+
+    _handleTextChanged(text) {
+      this.dispatchEvent(
+          new CustomEvent('value-changed', {detail: {value: text}}));
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
new file mode 100644
index 0000000..0434865
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-textarea/gr-textarea_test.html
@@ -0,0 +1,245 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-textarea</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+<link rel="import" href="gr-textarea.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-textarea></gr-textarea>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-textarea tests', () => {
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => {
+      sandbox.restore();
+    });
+
+    test('monospace is set properly', () => {
+      assert.isFalse(element.classList.contains('monospace'));
+      element.monospace = true;
+      element.ready();
+      assert.isTrue(element.classList.contains('monospace'));
+    });
+
+    test('hideBorder is set properly', () => {
+      assert.isFalse(element.$.textarea.classList.contains('noBorder'));
+      element.hideBorder = true;
+      element.ready();
+      assert.isTrue(element.$.textarea.classList.contains('noBorder'));
+    });
+
+    test('background color is set properly', () => {
+      assert.equal(getComputedStyle(element.$.textarea).backgroundColor,
+          'rgb(255, 255, 255)');
+      element.backgroundColor = 'pink';
+      element.ready();
+      assert.equal(getComputedStyle(element.$.textarea).backgroundColor,
+          'rgb(255, 192, 203)');
+    });
+
+    test('emoji selector is not open with the textarea lacks focus', () => {
+      element.$.textarea.selectionStart = 1;
+      element.$.textarea.selectionEnd = 1;
+      element.text = ':';
+      assert.isFalse(!element.$.emojiSuggestions.isHidden);
+    });
+
+    test('emoji selector is not open when a general text is entered', () => {
+      MockInteractions.focus(element.$.textarea);
+      element.$.textarea.selectionStart = 9;
+      element.$.textarea.selectionEnd = 9;
+      element.text = 'some text';
+      assert.isFalse(!element.$.emojiSuggestions.isHidden);
+    });
+
+    test('emoji selector opens when a colon is typed & the textarea has focus',
+        () => {
+          MockInteractions.focus(element.$.textarea);
+          // Needed for Safari tests. selectionStart is not updated when text is
+          // updated.
+          element.$.textarea.selectionStart = 1;
+          element.$.textarea.selectionEnd = 1;
+          element.text = ':';
+          element.$.textarea.selectionStart = 2;
+          element.$.textarea.selectionEnd = 2;
+          element.text = ':t';
+          flushAsynchronousOperations();
+          assert.isFalse(element.$.emojiSuggestions.isHidden);
+          assert.equal(element._colonIndex, 0);
+          assert.isFalse(element._hideAutocomplete);
+          assert.equal(element._currentSearchString, 't');
+        });
+
+    test('emoji selector closes when text changes before the colon', () => {
+      const resetStub = sandbox.stub(element, '_resetEmojiDropdown');
+      MockInteractions.focus(element.$.textarea);
+      flushAsynchronousOperations();
+      element.$.textarea.selectionStart = 10;
+      element.$.textarea.selectionEnd = 10;
+      element.text = 'test test ';
+      element.$.textarea.selectionStart = 12;
+      element.$.textarea.selectionEnd = 12;
+      element.text = 'test test :';
+      element.$.textarea.selectionStart = 15;
+      element.$.textarea.selectionEnd = 15;
+      element.text = 'test test :smi';
+
+      assert.equal(element._currentSearchString, 'smi');
+      assert.isFalse(resetStub.called);
+      element.text = 'test test test :smi';
+      assert.isTrue(resetStub.called);
+    });
+
+    test('_resetEmojiDropdown', () => {
+      const closeSpy = sandbox.spy(element, 'closeDropdown');
+      element._resetEmojiDropdown();
+      assert.equal(element._currentSearchString, '');
+      assert.isTrue(element._hideAutocomplete);
+      assert.equal(element._colonIndex, null);
+
+      element.$.emojiSuggestions.open();
+      flushAsynchronousOperations();
+      element._resetEmojiDropdown();
+      assert.isTrue(closeSpy.called);
+    });
+
+    test('_determineSuggestions', () => {
+      const emojiText = 'tear';
+      const formatSpy = sandbox.spy(element, '_formatSuggestions');
+      element._determineSuggestions(emojiText);
+      assert.isTrue(formatSpy.called);
+      assert.isTrue(formatSpy.lastCall.calledWithExactly(
+          [{dataValue: '😢', value: '😢', match: 'tear', text: '😢 tear'},
+          {dataValue: '😂', value: '😂', match: 'tears', text: '😂 tears'}]));
+    });
+
+    test('_formatSuggestions', () => {
+      const matchedSuggestions = [{value: '😢', match: 'tear'},
+          {value: '😂', match: 'tears'}];
+      element._formatSuggestions(matchedSuggestions);
+      assert.deepEqual(
+          [{value: '😢', dataValue: '😢', match: 'tear', text: '😢 tear'},
+          {value: '😂', dataValue: '😂', match: 'tears', text: '😂 tears'}],
+          element._suggestions);
+    });
+
+    test('_handleEmojiSelect', () => {
+      element.$.textarea.selectionStart = 16;
+      element.$.textarea.selectionEnd = 16;
+      element.text = 'test test :tears';
+      element._colonIndex = 10;
+      const selectedItem = {dataset: {value: '😂'}};
+      const event = {detail: {selected: selectedItem}};
+      element._handleEmojiSelect(event);
+      assert.equal(element.text, 'test test 😂 ');
+    });
+
+    test('_updateCaratPosition', () => {
+      element.$.textarea.selectionStart = 4;
+      element.$.textarea.selectionEnd = 4;
+      element.text = 'test';
+      element._updateCaratPosition();
+      assert.deepEqual(element.$.hiddenText.innerHTML, element.text +
+          element.$.caratSpan.outerHTML);
+    });
+
+    test('emoji dropdown is closed when iron-overlay-closed is fired', () => {
+      const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
+      element.$.emojiSuggestions.fire('dropdown-closed');
+      assert.isTrue(resetSpy.called);
+    });
+
+    test('_onValueChanged fires bind-value-changed', () => {
+      const listenerStub = sinon.stub();
+      const eventObject = {currentTarget: {focused: false}};
+      element.addEventListener('bind-value-changed', listenerStub);
+      element._onValueChanged(eventObject);
+      assert.isTrue(listenerStub.called);
+    });
+
+    suite('keyboard shortcuts', () => {
+      function setupDropdown(callback) {
+        MockInteractions.focus(element.$.textarea);
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 1;
+        element.text = ':';
+        element.$.textarea.selectionStart = 1;
+        element.$.textarea.selectionEnd = 2;
+        element.text = ':1';
+        flushAsynchronousOperations();
+      }
+
+      test('escape key', () => {
+        const resetSpy = sandbox.spy(element, '_resetEmojiDropdown');
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+        assert.isFalse(resetSpy.called);
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 27);
+        assert.isTrue(resetSpy.called);
+        assert.isFalse(!element.$.emojiSuggestions.isHidden);
+      });
+
+      test('up key', () => {
+        const upSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorUp');
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+        assert.isFalse(upSpy.called);
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 38);
+        assert.isTrue(upSpy.called);
+      });
+
+      test('down key', () => {
+        const downSpy = sandbox.spy(element.$.emojiSuggestions, 'cursorDown');
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+        assert.isFalse(downSpy.called);
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 40);
+        assert.isTrue(downSpy.called);
+      });
+
+      test('enter key', () => {
+        const enterSpy = sandbox.spy(element.$.emojiSuggestions,
+            'getCursorTarget');
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+        assert.isFalse(enterSpy.called);
+        setupDropdown();
+        MockInteractions.pressAndReleaseKeyOn(element.$.textarea, 13);
+        assert.isTrue(enterSpy.called);
+        flushAsynchronousOperations();
+        // A space is automatically added at the end.
+        assert.equal(element.text, '💯 ');
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
index 81e65e3..58f8e39 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content.html
@@ -19,8 +19,8 @@
 
 <dom-module id="gr-tooltip-content">
   <template>
-    <content></content>
-    <span class="arrow" hidden$="[[!showIcon]]">&#9432;</span>
+    <content></content><!--
+ --><span class="arrow" hidden$="[[!showIcon]]">&#9432;</span>
   </template>
   <script src="gr-tooltip-content.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
index aac2ea8..2fa02a3 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip-content/gr-tooltip-content_test.html
@@ -19,7 +19,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-tooltip-content.html">
 
 <script>void(0);</script>
@@ -32,18 +32,18 @@
 </test-fixture>
 
 <script>
-  suite('gr-tooltip-content tests', function() {
-    var element;
-    setup(function() {
+  suite('gr-tooltip-content tests', () => {
+    let element;
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('icon is not visible by default', function() {
+    test('icon is not visible by default', () => {
       assert.equal(Polymer.dom(element.root)
           .querySelector('.arrow').hidden, true);
     });
 
-    test('icon is visible with showIcon property', function() {
+    test('icon is visible with showIcon property', () => {
       element.showIcon = true;
       assert.equal(Polymer.dom(element.root)
           .querySelector('.arrow').hidden, false);
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
index 2af9c86..e79fb19 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.html
@@ -15,10 +15,11 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../styles/shared-styles.html">
 
 <dom-module id="gr-tooltip">
   <template>
-    <style>
+    <style include="shared-styles">
       :host {
         --gr-tooltip-arrow-size: .5em;
         --gr-tooltip-arrow-center-offset: 0;
@@ -27,11 +28,13 @@
         box-shadow: 0 1px 3px rgba(0, 0, 0, .3);
         color: #fff;
         font-size: .75rem;
-        padding: .5em .85em;
         position: absolute;
         z-index: 1000;
         max-width: var(--tooltip-max-width);
       }
+      :host .tooltip {
+        padding: .5em .85em;
+      }
       .arrow {
         border-left: var(--gr-tooltip-arrow-size) solid transparent;
         border-right: var(--gr-tooltip-arrow-size) solid transparent;
@@ -44,8 +47,10 @@
         width: 0;
       }
     </style>
-    [[text]]
-    <i class="arrow"></i>
+    <div class="tooltip">
+      [[text]]
+      <i class="arrow"></i>
+    </div>
   </template>
   <script src="gr-tooltip.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
index 4a5f631..e30afa7 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip.js
@@ -25,9 +25,8 @@
       },
     },
 
-    _updateWidth: function(maxWidth) {
-      this.customStyle['--tooltip-max-width'] = maxWidth;
-      this.updateStyles();
+    _updateWidth(maxWidth) {
+      this.updateStyles({'--tooltip-max-width': maxWidth});
     },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
index 69a5b75..e1e6449 100644
--- a/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-tooltip/gr-tooltip_test.html
@@ -19,7 +19,7 @@
 
 <script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../../../bower_components/web-component-tester/browser.js"></script>
-
+<link rel="import" href="../../../test/common-test-setup.html"/>
 <link rel="import" href="gr-tooltip.html">
 
 <script>void(0);</script>
@@ -32,13 +32,13 @@
 </test-fixture>
 
 <script>
-  suite('gr-tooltip tests', function() {
-    var element;
-    setup(function() {
+  suite('gr-tooltip tests', () => {
+    let element;
+    setup(() => {
       element = fixture('basic');
     });
 
-    test('max-width is respected if set', function() {
+    test('max-width is respected if set', () => {
       element.text = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit' +
           ', sed do eiusmod tempor incididunt ut labore et dolore magna aliqua';
       element.maxWidth = '50px';
diff --git a/polygerrit-ui/app/elements/test/plugin.html b/polygerrit-ui/app/elements/test/plugin.html
new file mode 100644
index 0000000..bd29b90
--- /dev/null
+++ b/polygerrit-ui/app/elements/test/plugin.html
@@ -0,0 +1,18 @@
+<dom-module id="my-plugin">
+  <script>
+    Gerrit.install(plugin =>
+      plugin.registerStyleModule('app-theme', 'myplugin-app-theme')
+    );
+  </script>
+</dom-module>
+
+<dom-module id="myplugin-app-theme">
+  <style>
+    html {
+      --primary-text-color: #F00BAA;
+      --header-background-color: #F01BAA;
+      --header-title-content: "MyGerrit";
+      --footer-background-color: #F02BAA;
+    }
+  </style>
+</dom-module>
diff --git a/polygerrit-ui/app/embed/change-diff-views.html b/polygerrit-ui/app/embed/change-diff-views.html
new file mode 100644
index 0000000..8426585
--- /dev/null
+++ b/polygerrit-ui/app/embed/change-diff-views.html
@@ -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.
+-->
+<link rel="import" href="../bower_components/polymer/polymer.html">
+<link rel="import" href="../elements/change/gr-change-view/gr-change-view.html">
+<link rel="import" href="../elements/diff/gr-diff-view/gr-diff-view.html">
+<link rel="import" href="../styles/app-theme.html">
diff --git a/polygerrit-ui/app/embed/embed_test.html b/polygerrit-ui/app/embed/embed_test.html
new file mode 100644
index 0000000..26ea895
--- /dev/null
+++ b/polygerrit-ui/app/embed/embed_test.html
@@ -0,0 +1,51 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>change-diff-views-embed_test</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../polygerrit_ui/elements/change-diff-views.html"/>
+
+<script>void(0);</script>
+
+<test-fixture id="change-view">
+  <template>
+    <gr-change-view></gr-change-view>
+  </template>
+</test-fixture>
+
+<test-fixture id="diff-view">
+  <template>
+    <gr-diff-view></gr-diff-view>
+  </template>
+</test-fixture>
+
+<script>
+  suite('embed test', () => {
+    test('gr-change-view is embedded', () => {
+      const element = fixture('change-view');
+      assert.equal(element.is, 'gr-change-view');
+    });
+
+    test('diff-view is embedded', () => {
+      const element = fixture('diff-view');
+      assert.equal(element.is, 'gr-diff-view');
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/embed/test.html b/polygerrit-ui/app/embed/test.html
new file mode 100644
index 0000000..0587562
--- /dev/null
+++ b/polygerrit-ui/app/embed/test.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>Embed Test Runner</title>
+<meta charset="utf-8">
+<script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../bower_components/web-component-tester/browser.js"></script>
+<script>
+  WCT.loadSuites(['embed_test.html']);
+</script>
diff --git a/polygerrit-ui/app/embed_test.sh b/polygerrit-ui/app/embed_test.sh
new file mode 100755
index 0000000..d482796
--- /dev/null
+++ b/polygerrit-ui/app/embed_test.sh
@@ -0,0 +1,70 @@
+#!/bin/sh
+
+set -ex
+
+t=$(mktemp -d || mktemp -d -t wct-XXXXXXXXXX)
+components=$TEST_SRCDIR/gerrit/polygerrit-ui/app/test_components.zip
+code=$TEST_SRCDIR/gerrit/polygerrit-ui/app/polygerrit_embed_ui.zip
+index=$TEST_SRCDIR/gerrit/polygerrit-ui/app/embed/test.html
+tests=$TEST_SRCDIR/gerrit/polygerrit-ui/app/embed/*_test.html
+
+unzip -qd $t $components
+unzip -qd $t $code
+mkdir -p $t/test
+cp $index $t/test/
+cp $tests $t/test/
+
+if [ "${WCT_HEADLESS_MODE:-0}" != "0" ]; then
+    CHROME_OPTIONS=[\'start-maximized\',\'headless\',\'disable-gpu\',\'no-sandbox\']
+    # TODO(paladox): Fix Firefox support for headless mode
+    FIREFOX_OPTIONS=[\'\']
+else
+    CHROME_OPTIONS=[\'start-maximized\']
+    FIREFOX_OPTIONS=[\'\']
+fi
+
+# For some reason wct tries to install selenium into its node_modules
+# directory on first run. If you've installed into /usr/local and
+# aren't running wct as root, you're screwed. Turning this option off
+# through skipSeleniumInstall seems to still work, so there's that.
+
+# Sauce tests are disabled by default in order to run local tests
+# only.  Run it with (saucelabs.com account required; free for open
+# source): WCT_ARGS='--plugin sauce' ./polygerrit-ui/app/embed_test.sh
+
+cat <<EOF > $t/wct.conf.js
+module.exports = {
+      'suites': ['test'],
+      'webserver': {
+        'pathMappings': [
+          {'/components/bower_components': 'bower_components'}
+        ]
+      },
+      'plugins': {
+        'local': {
+          'skipSeleniumInstall': true,
+          'browserOptions': {
+            'chrome': ${CHROME_OPTIONS},
+            'firefox': ${FIREFOX_OPTIONS}
+          }
+        },
+        'sauce': {
+          'disabled': true,
+          'browsers': [
+            'OS X 10.12/chrome',
+            'Windows 10/chrome',
+            'Linux/firefox',
+            'OS X 10.12/safari',
+            'Windows 10/microsoftedge'
+          ]
+        }
+      }
+    };
+EOF
+
+export PATH="$(dirname $WCT):$(dirname $NPM):$PATH"
+
+cd $t
+test -n "${WCT}"
+
+$(basename ${WCT}) ${WCT_ARGS}
diff --git a/polygerrit-ui/app/index.html b/polygerrit-ui/app/index.html
index db9a1c5..976806a 100644
--- a/polygerrit-ui/app/index.html
+++ b/polygerrit-ui/app/index.html
@@ -21,14 +21,26 @@
 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0">
 
 <!--
-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="/fonts/SourceCodePro-Regular.woff2" as="font" type="font/woff2" crossorigin>
-<link rel="preload" href="/fonts/SourceCodePro-Regular.woff" as="font" type="font/woff" crossorigin>
+<link rel="preload" href="/fonts/RobotoMono-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">
+<link rel="preload" href="/fonts/RobotoMono-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">
+<link rel="preload" href="/fonts/Roboto-Regular.woff2" as="font" type="font/woff2" crossorigin="anonymous">
+<link rel="preload" href="/fonts/Roboto-Regular.woff" as="font" type="font/woff" crossorigin="anonymous">
+<link rel="preload" href="/fonts/Roboto-Medium.woff2" as="font" type="font/woff2" crossorigin="anonymous">
+<link rel="preload" href="/fonts/Roboto-Medium.woff" as="font" type="font/woff" crossorigin="anonymous">
 <link rel="stylesheet" href="/styles/fonts.css">
 <link rel="stylesheet" href="/styles/main.css">
 <script src="/bower_components/webcomponentsjs/webcomponents-lite.js"></script>
+<!--
+  - 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="/elements/gr-app.js" as="script" crossorigin="anonymous">
 <link rel="import" href="/elements/gr-app.html">
 
diff --git a/polygerrit-ui/app/lint_test.sh b/polygerrit-ui/app/lint_test.sh
new file mode 100755
index 0000000..35939ba
--- /dev/null
+++ b/polygerrit-ui/app/lint_test.sh
@@ -0,0 +1,22 @@
+#!/bin/sh
+
+set -ex
+
+eslint_bin=$(which npm)
+if [ -z "$eslint_bin" ]; then
+    echo "NPM must be on the path."
+    exit 1
+fi
+
+eslint_bin=$(which eslint)
+eslint_config=$(npm list -g | grep -c eslint-config-google)
+eslint_plugin=$(npm list -g | grep -c eslint-plugin-html)
+if [ -z "$eslint_bin" ] || [ "$eslint_config" -eq "0" ] || [ "$eslint_plugin" -eq "0" ]; then
+    echo "You must install ESLint and its dependencies from NPM."
+    echo "> npm install -g eslint eslint-config-google eslint-plugin-html"
+    echo "For more information, view the README:"
+    echo "https://gerrit.googlesource.com/gerrit/+/master/polygerrit-ui/#Style-guide"
+    exit 1
+fi
+
+${eslint_bin} --ignore-pattern 'bower_components/' --ignore-pattern 'gr-linked-text' --ignore-pattern 'scripts/vendor' --ext .html,.js .
diff --git a/polygerrit-ui/app/polylint_test.sh b/polygerrit-ui/app/polylint_test.sh
new file mode 100755
index 0000000..ca9f9a9
--- /dev/null
+++ b/polygerrit-ui/app/polylint_test.sh
@@ -0,0 +1,20 @@
+#!/bin/sh
+
+set -ex
+
+npm_bin=$(which npm)
+if [[ -z "$npm_bin" ]]; then
+    echo "NPM must be on the path."
+    exit 1
+fi
+
+polylint_bin=$(which polylint)
+if [[ -z "$polylint_bin" ]]; then
+    echo "You must install polylint and its dependencies from NPM."
+    echo "> npm install -g polylint"
+    exit 1
+fi
+
+unzip -o polygerrit-ui/polygerrit_components.bower_components.zip -d polygerrit-ui/app
+
+${polylint_bin} --root polygerrit-ui/app --input elements/gr-app.html --b 'bower_components'
\ No newline at end of file
diff --git a/polygerrit-ui/app/rules.bzl b/polygerrit-ui/app/rules.bzl
new file mode 100644
index 0000000..f836cf5
--- /dev/null
+++ b/polygerrit-ui/app/rules.bzl
@@ -0,0 +1,95 @@
+load("@io_bazel_rules_closure//closure:defs.bzl", "closure_js_binary", "closure_js_library")
+load("//tools/bzl:genrule2.bzl", "genrule2")
+load(
+    "//tools/bzl:js.bzl",
+    "vulcanize",
+)
+
+def polygerrit_bundle(name, srcs, outs, app):
+    appName = app.split(".html")[0].split("/").pop()  # eg: gr-app
+
+    closure_js_binary(
+        name = name + "_closure_bin",
+        # Known issue: Closure compilation not compatible with Polymer behaviors.
+        # See: https://github.com/google/closure-compiler/issues/2042
+        compilation_level = "WHITESPACE_ONLY",
+        defs = [
+            "--polymer_pass",
+            "--jscomp_off=duplicate",
+            "--force_inject_library=es6_runtime",
+        ],
+        language = "ECMASCRIPT5",
+        deps = [name + "_closure_lib"],
+    )
+
+    closure_js_library(
+        name = name + "_closure_lib",
+        srcs = [appName + ".js"],
+        convention = "GOOGLE",
+        # TODO(davido): Clean up these issues: http://paste.openstack.org/show/608548
+        # and remove this supression
+        suppress = [
+            "JSC_JSDOC_MISSING_TYPE_WARNING",
+            "JSC_UNNECESSARY_ESCAPE",
+            "JSC_UNUSED_LOCAL_ASSIGNMENT",
+        ],
+        deps = [
+            "//lib/polymer_externs:polymer_closure",
+            "@io_bazel_rules_closure//closure/library",
+        ],
+    )
+
+    vulcanize(
+        name = appName,
+        srcs = srcs,
+        app = app,
+        deps = ["//polygerrit-ui:polygerrit_components.bower_components"],
+    )
+
+    native.filegroup(
+        name = name + "_app_sources",
+        srcs = [
+            name + "_closure_bin.js",
+            appName + ".html",
+        ],
+    )
+
+    native.filegroup(
+        name = name + "_css_sources",
+        srcs = native.glob(["styles/**/*.css"]),
+    )
+
+    native.filegroup(
+        name = name + "_top_sources",
+        srcs = [
+            "favicon.ico",
+            "index.html",
+        ],
+    )
+
+    genrule2(
+        name = name,
+        srcs = [
+            name + "_app_sources",
+            name + "_css_sources",
+            name + "_top_sources",
+            "//lib/fonts:robotofonts",
+            "//lib/js:highlightjs_files",
+            # we extract from the zip, but depend on the component for license checking.
+            "@webcomponentsjs//:zipfile",
+            "//lib/js:webcomponentsjs",
+        ],
+        outs = outs,
+        cmd = " && ".join([
+            "mkdir -p $$TMP/polygerrit_ui/{styles,fonts,bower_components/{highlightjs,webcomponentsjs},elements}",
+            "for f in $(locations " + name + "_app_sources); do ext=$${f##*.}; cp -p $$f $$TMP/polygerrit_ui/elements/" + appName + ".$$ext; done",
+            "cp $(locations //lib/fonts:robotofonts) $$TMP/polygerrit_ui/fonts/",
+            "for f in $(locations " + name + "_top_sources); do cp $$f $$TMP/polygerrit_ui/; done",
+            "for f in $(locations " + name + "_css_sources); do cp $$f $$TMP/polygerrit_ui/styles; done",
+            "for f in $(locations //lib/js:highlightjs_files); do cp $$f $$TMP/polygerrit_ui/bower_components/highlightjs/ ; done",
+            "unzip -qd $$TMP/polygerrit_ui/bower_components $(location @webcomponentsjs//:zipfile) webcomponentsjs/webcomponents-lite.js",
+            "cd $$TMP",
+            "find . -exec touch -t 198001010000 '{}' ';'",
+            "zip -qr $$ROOT/$@ *",
+        ]),
+    )
diff --git a/polygerrit-ui/app/run_test.sh b/polygerrit-ui/app/run_test.sh
index 22cfcfe..df210b8 100755
--- a/polygerrit-ui/app/run_test.sh
+++ b/polygerrit-ui/app/run_test.sh
@@ -1,14 +1,14 @@
 #!/usr/bin/env bash
 
-wct_bin=$(which wct)
-if [[ -z "$wct_bin" ]]; then
-    echo "WCT must be on the path."
+npm_bin=$(which npm)
+if [[ -z "$npm_bin" ]]; then
+    echo "NPM must be on the path. (https://www.npmjs.com/)"
     exit 1
 fi
 
-npm_bin=$(which npm)
-if [[ -z "$npm_bin" ]]; then
-    echo "NPM must be on the path."
+wct_bin=$(which wct)
+if [[ -z "$wct_bin" ]]; then
+    echo "WCT must be on the path. (https://github.com/Polymer/web-component-tester)"
     exit 1
 fi
 
@@ -22,4 +22,5 @@
       --test_env="DISPLAY=${DISPLAY}" \
       --test_env="WCT_HEADLESS_MODE=${WCT_HEADLESS_MODE}" \
       "$@" \
+      //polygerrit-ui/app:embed_test \
       //polygerrit-ui/app:wct_test
diff --git a/polygerrit-ui/app/samples/lgtm-plugin.html b/polygerrit-ui/app/samples/lgtm-plugin.html
new file mode 100644
index 0000000..d58034d
--- /dev/null
+++ b/polygerrit-ui/app/samples/lgtm-plugin.html
@@ -0,0 +1,16 @@
+<dom-module id="lgtm-plugin">
+  <script>
+    Gerrit.install(plugin => {
+      const replyApi = plugin.changeReply();
+      replyApi.addReplyTextChangedCallback(text => {
+        const label = 'Code-Review';
+        const labelValue = replyApi.getLabelValue(label);
+        if (labelValue &&
+            labelValue === ' 0' &&
+            text.indexOf('LGTM') === 0) {
+          replyApi.setLabelValue(label, '+1');
+        }
+      });
+    });
+  </script>
+</dom-module>
diff --git a/polygerrit-ui/app/scripts/hiddenscroll.js b/polygerrit-ui/app/scripts/hiddenscroll.js
new file mode 100644
index 0000000..b80742a
--- /dev/null
+++ b/polygerrit-ui/app/scripts/hiddenscroll.js
@@ -0,0 +1,29 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+(function(window) {
+  window.Gerrit = window.Gerrit || {};
+  if (window.Gerrit.hasOwnProperty('hiddenscroll')) { return; }
+
+  window.Gerrit.hiddenscroll = undefined;
+
+  window.addEventListener('WebComponentsReady', () => {
+    const elem = document.createElement('div');
+    elem.setAttribute(
+        'style', 'width:100px;height:100px;overflow:scroll');
+    document.body.appendChild(elem);
+    window.Gerrit.hiddenscroll = elem.offsetWidth === elem.clientWidth;
+    elem.remove();
+  });
+})(window);
diff --git a/polygerrit-ui/app/scripts/rootElement.js b/polygerrit-ui/app/scripts/rootElement.js
new file mode 100644
index 0000000..1a07edf
--- /dev/null
+++ b/polygerrit-ui/app/scripts/rootElement.js
@@ -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.
+
+(function(window) {
+  window.Gerrit = window.Gerrit || {};
+  if (window.Gerrit.hasOwnProperty('getRootElement')) { return; }
+
+  window.Gerrit.getRootElement = () => document.body;
+})(window);
\ No newline at end of file
diff --git a/polygerrit-ui/app/scripts/util.js b/polygerrit-ui/app/scripts/util.js
index 6c83905..573335c 100644
--- a/polygerrit-ui/app/scripts/util.js
+++ b/polygerrit-ui/app/scripts/util.js
@@ -14,7 +14,7 @@
 (function(window) {
   'use strict';
 
-  var util = window.util || {};
+  const util = window.util || {};
 
   util.parseDate = function(dateStr) {
     // Timestamps are given in UTC and have the format
@@ -25,39 +25,18 @@
   };
 
   util.getCookie = function(name) {
-    var key = name + '=';
-    var cookies = document.cookie.split(';');
-    for (var i = 0; i < cookies.length; i++) {
-      var c = cookies[i];
-      while (c.charAt(0) == ' ') {
+    const key = name + '=';
+    const cookies = document.cookie.split(';');
+    for (let i = 0; i < cookies.length; i++) {
+      let c = cookies[i];
+      while (c.charAt(0) === ' ') {
         c = c.substring(1);
       }
-      if (c.indexOf(key) == 0) {
+      if (c.startsWith(key)) {
         return c.substring(key.length, c.length);
       }
     }
     return '';
   };
-
-  /**
-   * Truncates URLs to display filename only
-   * Example
-   * // returns '.../text.html'
-   * util.truncatePath.('dir/text.html');
-   * Example
-   * // returns 'text.html'
-   * util.truncatePath.('text.html');
-   * @return {String} Returns the truncated value of a URL.
-   */
-  util.truncatePath = function(path) {
-    var pathPieces = path.split('/');
-
-    if (pathPieces.length < 2) {
-      return path;
-    }
-    // Character is an ellipsis.
-    return '\u2026/' + pathPieces[pathPieces.length - 1];
-  };
-
   window.util = util;
 })(window);
diff --git a/polygerrit-ui/app/styles/app-theme.html b/polygerrit-ui/app/styles/app-theme.html
index 773b341..2fe7662 100644
--- a/polygerrit-ui/app/styles/app-theme.html
+++ b/polygerrit-ui/app/styles/app-theme.html
@@ -15,18 +15,32 @@
 -->
 <style is="custom-style">
 :root {
+  /* Following vars have LTS for plugin API. */
   --primary-text-color: #000;
+  --header-background-color: #eee;
+  --header-title-content: 'PolyGerrit';
+  --header-icon: none;
+  --header-icon-size: 0em;
+  --footer-background-color: var(--header-background-color);
+
+  /* Following are not part of plugin API. */
   --search-border-color: #ddd;
   --selection-background-color: #ebf5fb;
   --default-text-color: #000;
   --view-background-color: #fff;
   --default-horizontal-margin: 1rem;
-  --font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
-  --monospace-font-family: 'Source Code Pro', Menlo, 'Lucida Console', Monaco, monospace;
-
+  --font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  --font-family-bold: 'Roboto Medium', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  --monospace-font-family: 'Roboto Mono', Menlo, 'Lucida Console', Monaco, monospace;
   --iron-overlay-backdrop: {
     transition: none;
-  };
+  }
+
+  /* Follow are a part of the design refresh */
+  --color-link: #2a66d9;
+  --color-link-tertiary: #000;
+  /* 12% darker */
+  --color-button-hover: #0B47BA;
 }
 @media screen and (max-width: 50em) {
   :root {
diff --git a/polygerrit-ui/app/styles/fonts.css b/polygerrit-ui/app/styles/fonts.css
index b5bf9ae..6a5da44 100644
--- a/polygerrit-ui/app/styles/fonts.css
+++ b/polygerrit-ui/app/styles/fonts.css
@@ -1,20 +1,62 @@
 /* latin-ext */
 @font-face {
-  font-family: 'Source Code Pro';
+  font-family: 'Roboto Mono';
   font-style: normal;
   font-weight: 400;
-  src: local('Source Code Pro'), local('SourceCodePro-Regular'),
-       url(../fonts/SourceCodePro-Regular.woff2) format('woff2'),
-       url(../fonts/SourceCodePro-Regular.woff) format('woff');
+  src: local('Roboto Mono'), local('RobotoMono-Regular'),
+       url('../fonts/RobotoMono-Regular.woff2') format('woff2'),
+       url('../fonts/RobotoMono-Regular.woff') format('woff');
   unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
 }
 /* latin */
 @font-face {
-  font-family: 'Source Code Pro';
+  font-family: 'Roboto Mono';
   font-style: normal;
   font-weight: 400;
-  src: local('Source Code Pro'), local('SourceCodePro-Regular'),
-       url(../fonts/SourceCodePro-Regular.woff2) format('woff2'),
-       url(../fonts/SourceCodePro-Regular.woff) format('woff');
+  src: local('Roboto Mono'), local('RobotoMono-Regular'),
+       url('../fonts/RobotoMono-Regular.woff2') format('woff2'),
+       url('../fonts/RobotoMono-Regular.woff') format('woff');
   unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
 }
+
+/* latin-ext */
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto'), local('Roboto-Regular'),
+       url('../fonts/Roboto-Regular.woff2') format('woff2'),
+       url('../fonts/Roboto-Regular.woff') format('woff');
+  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Roboto';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto'), local('RobotoMono-Regular'),
+       url('../fonts/Roboto-Regular.woff2') format('woff2'),
+       url('../fonts/Roboto-Regular.woff') format('woff');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
+
+/* latin-ext */
+@font-face {
+  font-family: 'Roboto Medium';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+       url('../fonts/Roboto-Medium.woff2') format('woff2'),
+       url('../fonts/Roboto-Medium.woff') format('woff');
+  unicode-range: U+0100-024F, U+1E00-1EFF, U+20A0-20AB, U+20AD-20CF, U+2C60-2C7F, U+A720-A7FF;
+}
+/* latin */
+@font-face {
+  font-family: 'Roboto Medium';
+  font-style: normal;
+  font-weight: 400;
+  src: local('Roboto Medium'), local('Roboto-Medium'),
+       url('../fonts/Roboto-Medium.woff2') format('woff2'),
+       url('../fonts/Roboto-Medium.woff') format('woff');
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2212, U+2215, U+E0FF, U+EFFD, U+F000;
+}
\ No newline at end of file
diff --git a/polygerrit-ui/app/styles/gr-change-list-styles.html b/polygerrit-ui/app/styles/gr-change-list-styles.html
index d283aac..a0bba90 100644
--- a/polygerrit-ui/app/styles/gr-change-list-styles.html
+++ b/polygerrit-ui/app/styles/gr-change-list-styles.html
@@ -22,7 +22,7 @@
       .topHeader,
       .groupHeader {
         border-bottom: 1px solid #eee;
-        font-weight: bold;
+        font-family: var(--font-family-bold);
         padding: .3em .5em;
       }
       .topHeader {
@@ -45,6 +45,7 @@
       .label,
       .number,
       .owner,
+      .assignee,
       .updated,
       .size,
       .status,
@@ -61,6 +62,24 @@
       .label {
         text-align: center;
       }
+      .truncatedProject {
+        display: none;
+      }
+      @media only screen and (max-width: 90em) {
+        .assignee,
+        .branch,
+        .owner {
+          overflow: hidden;
+          max-width: 10rem;
+          text-overflow: ellipsis;
+        }
+        .truncatedProject {
+          display: inline-block;
+        }
+        .fullProject {
+          display: none;
+        }
+      }
       @media only screen and (max-width: 50em) {
         :host {
           font-size: 14px;
@@ -79,7 +98,8 @@
         .project,
         .branch,
         .updated,
-        .label {
+        .label,
+        .assignee {
           display: none;
         }
         .star {
diff --git a/polygerrit-ui/app/styles/gr-form-styles.html b/polygerrit-ui/app/styles/gr-form-styles.html
new file mode 100644
index 0000000..823fee6
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-form-styles.html
@@ -0,0 +1,128 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<dom-module id="gr-form-styles">
+  <template>
+    <style>
+      .gr-form-styles h1,
+      .gr-form-styles h2 {
+        margin-bottom: .3em;
+      }
+      .gr-form-styles fieldset {
+        border: none;
+        margin-bottom: 2em;
+      }
+      .gr-form-styles section {
+        margin: .25em 0;
+        min-height: 2em;
+      }
+      .gr-form-styles section * {
+        vertical-align: middle;
+      }
+      .gr-form-styles .title,
+      .gr-form-styles .value {
+        display: inline-block;
+      }
+      .gr-form-styles .title {
+        color: #666;
+        font-family: var(--font-family-bold);
+        padding-right: .5em;
+        width: 15em;
+      }
+      .gr-form-styles iron-autogrow-textarea {
+        font-size: 1em;
+      }
+      .gr-form-styles th {
+        color: #666;
+        text-align: left;
+        vertical-align: bottom;
+      }
+      .gr-form-styles td,
+      .gr-form-styles tfoot th {
+        height: 2em;
+        padding: .25em 0;
+        vertical-align: middle;
+      }
+      .gr-form-styles .emptyHeader {
+        text-align: right;
+      }
+      .gr-form-styles tbody tr:nth-child(even):not(.loading) {
+        background-color: #f4f4f4;
+      }
+      .gr-form-styles table {
+        width: 50em;
+      }
+      .gr-form-styles th:first-child,
+      .gr-form-styles td:first-child {
+        width: 15em;
+      }
+      .gr-form-styles th:first-child input,
+      .gr-form-styles td:first-child input {
+        width: 14em;
+      }
+      .gr-form-styles input:not([type="checkbox"]),
+      .gr-form-styles select,
+      .gr-form-styles textarea {
+        border: 1px solid #d1d2d3;
+        border-radius: 2px;
+        font-size: 1em;
+        height: 2em;
+        padding: 0 .15em;
+      }
+      .gr-form-styles td:last-child {
+        width: 5em;
+      }
+      .gr-form-styles th:last-child gr-button,
+      .gr-form-styles td:last-child gr-button {
+        width: 100%;
+      }
+      .gr-form-styles iron-autogrow-textarea {
+        border: none;
+        height: auto;
+        min-height: 2em;
+        --iron-autogrow-textarea: {
+          border: 1px solid #d1d2d3;
+          border-radius: 2px;
+          box-sizing: border-box;
+          font-size: 1em;
+          padding: .25em .15em 0 .15em;
+        }
+      }
+      .gr-form-styles gr-autocomplete {
+        border: none;
+        --gr-autocomplete: {
+          border: 1px solid #d1d2d3;
+          border-radius: 2px;
+          font-size: 1em;
+          height: 2em;
+          padding: 0 .15em;
+          width: 14em;
+        }
+      }
+      @media only screen and (max-width: 40em) {
+        .gr-form-styles section {
+          margin-bottom: 1em;
+        }
+        .gr-form-styles .title,
+        .gr-form-styles .value {
+          display: block;
+        }
+        .gr-form-styles table {
+          width: 100%;
+        }
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-menu-page-styles.html b/polygerrit-ui/app/styles/gr-menu-page-styles.html
new file mode 100644
index 0000000..81d4179
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-menu-page-styles.html
@@ -0,0 +1,62 @@
+<!--
+Copyright (C) 2016 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+<dom-module id="gr-menu-page-styles">
+  <template>
+    <style>
+      :host {
+        background-color: var(--view-background-color);
+        display: block;
+      }
+      main {
+        margin: 2em auto;
+        max-width: 50em;
+      }
+      main.table {
+        margin-top: 0;
+        margin-right: 0;
+        margin-left: 14em;
+        max-width: none;
+      }
+      h2.edited:after {
+        color: #444;
+        content: ' *';
+      }
+      .loading {
+        color: #666;
+        padding: 1em var(--default-horizontal-margin);
+      }
+      @media only screen and (max-width: 67em) {
+        main {
+          margin: 2em 0 2em 15em;
+        }
+        main.table {
+          margin-left: 14em;
+        }
+      }
+      @media only screen and (max-width: 53em) {
+        .loading {
+          padding: 0 var(--default-horizontal-margin);
+        }
+        main {
+          margin: 2em 1em;
+        }
+        main.table {
+          margin: 0;
+        }
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-page-nav-styles.html b/polygerrit-ui/app/styles/gr-page-nav-styles.html
new file mode 100644
index 0000000..0c4d20f
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-page-nav-styles.html
@@ -0,0 +1,63 @@
+<!--
+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.
+-->
+<dom-module id="gr-page-nav-styles">
+  <template>
+    <style>
+      .navStyles ul {
+        padding: 1em 0;
+      }
+      .navStyles li {
+        border-bottom: 1px solid transparent;
+        border-top: 1px solid transparent;
+        display: block;
+        padding: 0 2em;
+      }
+      .navStyles li  a {
+        display: block;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .navStyles .subsectionItem {
+        padding-left: 3em;
+      }
+      .navStyles .hideSubsection {
+        display: none;
+      }
+      .navStyles li.sectionTitle {
+        padding: 0 2em 0 1.5em;
+      }
+      .navStyles li.sectionTitle:not(:first-child) {
+        margin-top: 1em;
+      }
+      .navStyles .title {
+        font-family: var(--font-family-bold);
+        margin: .4em 0;
+      }
+      .navStyles .selected {
+        background-color: #fff;
+        border-bottom: 1px dotted #808080;
+        border-top: 1px dotted #808080;
+        font-family: var(--font-family-bold);
+      }
+      .navStyles a {
+        color: black;
+        display: inline-block;
+        margin: .4em 0;
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-settings-styles.html b/polygerrit-ui/app/styles/gr-settings-styles.html
deleted file mode 100644
index fcda1b4..0000000
--- a/polygerrit-ui/app/styles/gr-settings-styles.html
+++ /dev/null
@@ -1,58 +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.
--->
-<dom-module id="gr-settings-styles">
-  <template>
-    <style>
-      .gr-settings-styles fieldset {
-        border: none;
-        margin: 0 0 2em 2em;
-      }
-      .gr-settings-styles section {
-        margin-bottom: .5em;
-      }
-      .gr-settings-styles .title,
-      .gr-settings-styles .value {
-        display: inline-block;
-        vertical-align: top;
-      }
-      .gr-settings-styles .title {
-        color: #666;
-        font-weight: bold;
-        padding-right: .5em;
-        width: 11em;
-      }
-      .gr-settings-styles input {
-        font-size: 1em;
-      }
-      .gr-settings-styles th {
-        color: #666;
-        text-align: left;
-      }
-      .gr-settings-styles tbody tr:nth-child(even) {
-        background-color: #f4f4f4;
-      }
-      @media only screen and (max-width: 40em) {
-        .gr-settings-styles section {
-          margin-bottom: 1em;
-        }
-        .gr-settings-styles .title,
-        .gr-settings-styles .value {
-          display: block;
-        }
-      }
-    </style>
-  </template>
-</dom-module>
diff --git a/polygerrit-ui/app/styles/gr-table-styles.html b/polygerrit-ui/app/styles/gr-table-styles.html
new file mode 100644
index 0000000..6b8c88d0
--- /dev/null
+++ b/polygerrit-ui/app/styles/gr-table-styles.html
@@ -0,0 +1,61 @@
+<!--
+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.
+-->
+
+<dom-module id="gr-table-styles">
+  <template>
+    <style>
+      .genericList .loading {
+        display: none;
+      }
+      .genericList {
+        border-collapse: collapse;
+        width: 100%;
+      }
+      .genericList tr.table {
+        border-bottom: 1px solid #eee;
+      }
+      .genericList td {
+        flex-shrink: 0;
+        padding: .3em .5em;
+      }
+      .genericList th {
+        background-color: #ddd;
+        border-bottom: 1px solid #eee;
+        font-family: var(--font-family-bold);
+        padding: .3em .5em;
+        text-align: left;
+      }
+      .genericList a {
+        color: var(--default-text-color);
+        text-decoration: none;
+      }
+      .genericList a:hover {
+        text-decoration: underline;
+      }
+      .genericList .description {
+        width: 70%;
+      }
+      .genericList .loadingMsg {
+        color: #666;
+        display: block;
+        padding: 1em var(--default-horizontal-margin);
+      }
+      .genericList .loadingMsg:not(.loading) {
+        display: none;
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/styles/main.css b/polygerrit-ui/app/styles/main.css
index 6e48ae5..045821c 100644
--- a/polygerrit-ui/app/styles/main.css
+++ b/polygerrit-ui/app/styles/main.css
@@ -21,6 +21,7 @@
   margin: 0;
   padding: 0;
 }
+
 html {
   -webkit-text-size-adjust: none;
 }
@@ -34,7 +35,8 @@
    * IE has shoddy support for the font shorthand property.
    * Work around this using font-size and font-family.
    */
+  -webkit-text-size-adjust: none;
   font-size: 13px;
-  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
+  font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
   line-height: 1.4;
 }
diff --git a/polygerrit-ui/app/styles/shared-styles.html b/polygerrit-ui/app/styles/shared-styles.html
new file mode 100644
index 0000000..31b1c6e
--- /dev/null
+++ b/polygerrit-ui/app/styles/shared-styles.html
@@ -0,0 +1,92 @@
+<!--
+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.
+-->
+<dom-module id="shared-styles">
+  <template>
+    <style>
+      /* CSS reset */
+      html, body, button, div, span, applet, object, iframe, h1, h2, h3,
+      h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite,
+      code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub,
+      sup, tt, var, b, u, i, center, dl, dt, dd, ol, ul, li, fieldset, form,
+      label, legend, table, caption, tbody, tfoot, thead, tr, th, td, article,
+      aside, canvas, details, embed, figure, figcaption, footer, header, hgroup,
+      main, menu, nav, output, ruby, section, summary, time, mark, audio, video {
+        border: 0;
+        box-sizing: border-box;
+        font-size: 100%;
+        font: inherit;
+        margin: 0;
+        padding: 0;
+        vertical-align: baseline;
+      }
+      input,
+      iron-autogrow-textarea {
+        box-sizing: border-box;
+        margin: 0;
+        padding: 0;
+      }
+      a {
+        color: var(--color-link);
+      }
+      input,
+      textarea,
+      select,
+      button {
+        font: inherit;
+      }
+      body {
+        line-height: 1;
+      }
+      ol, ul {
+        list-style: none;
+      }
+      blockquote, q {
+        quotes: none;
+      }
+      blockquote:before, blockquote:after,
+      q:before, q:after {
+        content: '';
+        content: none;
+      }
+      table {
+        border-collapse: collapse;
+        border-spacing: 0;
+      }
+      /* Other Shared Styles*/
+      h1 {
+        font-size: 2em;
+        font-family: var(--font-family-bold);
+      }
+      h2 {
+        font-size: 1.5em;
+        font-family: var(--font-family-bold);
+      }
+      h3 {
+        font-size: 1.17em;
+        font-family: var(--font-family-bold);
+      }
+      iron-icon {
+        color: #757575;
+        --iron-icon-height: 20px;
+        --iron-icon-width: 20px;
+      }
+      /* Stopgap solution until we remove hidden$ attributes. */
+      [hidden] {
+        display: none !important;
+      }
+    </style>
+  </template>
+</dom-module>
diff --git a/polygerrit-ui/app/template_test.sh b/polygerrit-ui/app/template_test.sh
new file mode 100755
index 0000000..a9710cd
--- /dev/null
+++ b/polygerrit-ui/app/template_test.sh
@@ -0,0 +1,44 @@
+#!/bin/sh
+
+set -ex
+
+npm_bin=$(which npm)
+if [ -z "$npm_bin" ]; then
+    echo "NPM must be on the path."
+    exit 1
+fi
+
+node_bin=$(which node)
+if [ -z "$node_bin" ]; then
+    echo "node must be on the path."
+    exit 1
+fi
+
+fried_twinkie_config=$(npm list -g | grep -c fried-twinkie)
+typescript_config=$(npm list -g | grep -c typescript)
+if [ -z "$npm_bin" ] || [ "$fried_twinkie_config" -eq "0" ]; then
+    echo "You must install fried twinkie and its dependencies from NPM."
+    echo "> npm install -g fried-twinkie"
+    exit 1
+fi
+
+twinkie_version=$(npm list -g fried-twinkie@\>0.1 | grep fried-twinkie || :)
+if [ -z "$twinkie_version" ]; then
+    echo "Outdated version of fried-twinkie found. Bypassing template check."
+    exit 0
+fi
+
+# Have to find where node_modules are installed and set the NODE_PATH
+
+get_node_path() {
+    cd $(dirname $node_bin)
+    cd ../lib/node_modules
+    pwd
+}
+
+export NODE_PATH=$(get_node_path)
+
+unzip -o polygerrit-ui/polygerrit_components.bower_components.zip -d polygerrit-ui/app
+python $TEST_SRCDIR/gerrit/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py
+# Pass a file name argument from the --test_args (example: --test_arg=gr-list-view)
+${node_bin} $TEST_SRCDIR/gerrit/polygerrit-ui/app/template_test_srcs/template_test.js $1 $2
diff --git a/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py b/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py
new file mode 100644
index 0000000..3a5cd83b
--- /dev/null
+++ b/polygerrit-ui/app/template_test_srcs/convert_for_template_tests.py
@@ -0,0 +1,112 @@
+import os, re, json
+from shutil import copyfile, rmtree
+
+polymerRegex = r"Polymer\({"
+polymerCompiledRegex = re.compile(polymerRegex)
+
+removeSelfInvokeRegex = r"\(function\(\) {\n(.+)}\)\(\);"
+fnCompiledRegex = re.compile(removeSelfInvokeRegex, re.DOTALL)
+
+regexBehavior = r"<script>(.+)<\/script>"
+behaviorCompiledRegex = re.compile(regexBehavior, re.DOTALL)
+
+def _open(filename, mode="r"):
+  try:
+    return open(filename, mode, encoding="utf-8")
+  except TypeError:
+    return open(filename, mode)
+
+def replaceBehaviorLikeHTML (fileIn, fileOut):
+  with _open(fileIn) as f:
+    file_str = f.read()
+    match = behaviorCompiledRegex.search(file_str)
+    if (match):
+      with _open("polygerrit-ui/temp/behaviors/" + fileOut.replace("html", "js") , "w+") as f:
+        f.write(match.group(1))
+
+def replaceBehaviorLikeJS (fileIn, fileOut):
+  with _open(fileIn) as f:
+    file_str = f.read()
+    with _open("polygerrit-ui/temp/behaviors/" + fileOut , "w+") as f:
+      f.write(file_str)
+
+def generateStubBehavior(behaviorName):
+  with _open("polygerrit-ui/temp/behaviors/" + behaviorName + ".js", "w+") as f:
+    f.write("/** @polymerBehavior **/\n" + behaviorName + "= {};")
+
+def replacePolymerElement (fileIn, fileOut, root):
+  with _open(fileIn) as f:
+    key = fileOut.split('.')[0]
+    # Removed self invoked function
+    file_str = f.read()
+    file_str_no_fn = fnCompiledRegex.search(file_str)
+
+    if file_str_no_fn:
+      package = root.replace("/", ".") + "." + fileOut
+
+      with _open("polygerrit-ui/temp/" + fileOut, "w+") as f:
+        mainFileContents = re.sub(polymerCompiledRegex, "exports = Polymer({", file_str_no_fn.group(1)).replace("'use strict';", "")
+        f.write("/** \n" \
+          "* @fileoverview \n" \
+          "* @suppress {missingProperties} \n" \
+          "*/ \n\n" \
+          "goog.module('polygerrit." + package + "')\n\n" + mainFileContents)
+
+      # Add package and javascript to files object.
+      elements[key]["js"] = "polygerrit-ui/temp/" + fileOut
+      elements[key]["package"] = package
+
+def writeTempFile(file, root):
+  # This is included in an extern because it is directly on the window object.
+  # (for now at least).
+  if "gr-reporting" in file:
+    return
+  key = file.split('.')[0]
+  if not key in elements:
+    # gr-app doesn't have an additional level
+    elements[key] = {"directory": 'gr-app' if len(root.split("/")) < 4 else root.split("/")[3]}
+  if file.endswith(".html") and not file.endswith("_test.html"):
+    # gr-navigation is treated like a behavior rather than a standard element
+    # because of the way it added to the Gerrit object.
+    if file.endswith("gr-navigation.html"):
+      replaceBehaviorLikeHTML(os.path.join(root, file), file)
+    else:
+      elements[key]["html"] = os.path.join(root, file)
+  if file.endswith(".js"):
+    replacePolymerElement(os.path.join(root, file), file, root)
+
+
+if __name__ == "__main__":
+  # Create temp directory.
+  if not os.path.exists("polygerrit-ui/temp"):
+    os.makedirs("polygerrit-ui/temp")
+
+  # Within temp directory create behavior directory.
+  if not os.path.exists("polygerrit-ui/temp/behaviors"):
+    os.makedirs("polygerrit-ui/temp/behaviors")
+
+  elements = {}
+
+  # Go through every file in app/elements, and re-write accordingly to temp
+  # directory, and also added to elements object, which is used to generate a
+  # map of html files, package names, and javascript files.
+  for root, dirs, files in os.walk("polygerrit-ui/app/elements"):
+    for file in files:
+      writeTempFile(file, root)
+
+  # Special case for polymer behaviors we are using.
+  replaceBehaviorLikeHTML("polygerrit-ui/app/bower_components/iron-a11y-keys-behavior/iron-a11y-keys-behavior.html", "iron-a11y-keys-behavior.html")
+  generateStubBehavior("Polymer.IronOverlayBehavior")
+  generateStubBehavior("Polymer.IronFitBehavior")
+
+  #TODO figure out something to do with iron-overlay-behavior. it is hard-coded reformatted.
+
+  with _open("polygerrit-ui/temp/map.json", "w+") as f:
+    f.write(json.dumps(elements))
+
+  for root, dirs, files in os.walk("polygerrit-ui/app/behaviors"):
+    for file in files:
+      if file.endswith("behavior.html"):
+        replaceBehaviorLikeHTML(os.path.join(root, file), file)
+      elif file.endswith("behavior.js"):
+        replaceBehaviorLikeJS(os.path.join(root, file), file)
diff --git a/polygerrit-ui/app/template_test_srcs/template_test.js b/polygerrit-ui/app/template_test_srcs/template_test.js
new file mode 100644
index 0000000..a0db3af
--- /dev/null
+++ b/polygerrit-ui/app/template_test_srcs/template_test.js
@@ -0,0 +1,113 @@
+const fs = require('fs');
+const twinkie = require('fried-twinkie');
+
+/**
+ * For the purposes of template type checking, externs should be added for
+ * anything set on the window object. Note that sub-properties of these
+ * declared properties are considered something separate.
+ *
+ * @todo (beckysiegel) Gerrit's class definitions should be recognized in
+ *    closure types.
+ */
+const EXTERN_NAMES = [
+  'Gerrit',
+  'GrAnnotation',
+  'GrAttributeHelper',
+  'GrChangeActionsInterface',
+  'GrChangeReplyInterface',
+  'GrDiffBuilder',
+  'GrDiffBuilderImage',
+  'GrDiffBuilderSideBySide',
+  'GrDiffBuilderUnified',
+  'GrDiffGroup',
+  'GrDiffLine',
+  'GrDomHooks',
+  'GrEtagDecorator',
+  'GrGapiAuth',
+  'GrGerritAuth',
+  'GrLinkTextParser',
+  'GrPluginEndpoints',
+  'GrPopupInterface',
+  'GrRangeNormalizer',
+  'GrReporting',
+  'GrReviewerUpdatesParser',
+  'GrThemeApi',
+  'moment',
+  'page',
+  'util',
+];
+
+fs.readdir('./polygerrit-ui/temp/behaviors/', (err, data) => {
+  if (err) {
+    console.log('error /polygerrit-ui/temp/behaviors/ directory');
+  }
+  const behaviors = data;
+  const additionalSources = [];
+  const externMap = {};
+
+  for (const behavior of behaviors) {
+    if (!externMap[behavior]) {
+      additionalSources.push({
+        path: `./polygerrit-ui/temp/behaviors/${behavior}`,
+        src: fs.readFileSync(
+            `./polygerrit-ui/temp/behaviors/${behavior}`, 'utf-8'),
+      });
+      externMap[behavior] = true;
+    }
+  }
+
+  let mappings = JSON.parse(fs.readFileSync(
+      `./polygerrit-ui/temp/map.json`, 'utf-8'));
+
+  // The directory is passed as arg2 by the test target.
+  const directory = process.argv[2];
+  if (directory) {
+    const mappingSpecificDirectory = {};
+
+    for (key of Object.keys(mappings)) {
+      if (directory === mappings[key].directory) {
+        mappingSpecificDirectory[key] = mappings[key];
+      }
+    }
+    mappings = mappingSpecificDirectory;
+  }
+
+  // If a particular file was passed by the user, don't test everything.
+  const file = process.argv[3];
+  if (file) {
+    const mappingSpecificFile = {};
+    for (key of Object.keys(mappings)) {
+      if (key.includes(file)) {
+        mappingSpecificFile[key] = mappings[key];
+      }
+    }
+    mappings = mappingSpecificFile;
+  }
+
+  additionalSources.push({
+    path: 'custom-externs.js',
+    src: '/** @externs */' +
+        EXTERN_NAMES.map( name => { return `var ${name};`; }).join(' '),
+  });
+
+  const toCheck = [];
+  for (key of Object.keys(mappings)) {
+    if (mappings[key].html && mappings[key].js) {
+      toCheck.push({
+        htmlSrcPath: mappings[key].html,
+        jsSrcPath: mappings[key].js,
+        jsModule: 'polygerrit.' + mappings[key].package,
+      });
+    }
+  }
+
+  twinkie.checkTemplate(toCheck, additionalSources)
+      .then(() => {}, joinedErrors => {
+        if (joinedErrors) {
+          process.exit(1);
+        }
+      }).catch(e => {
+        console.error(e);
+        process.exit(1);
+      });
+});
diff --git a/polygerrit-ui/app/test/common-test-setup.html b/polygerrit-ui/app/test/common-test-setup.html
new file mode 100644
index 0000000..d901bec
--- /dev/null
+++ b/polygerrit-ui/app/test/common-test-setup.html
@@ -0,0 +1,64 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<link rel="import"
+    href="../bower_components/polymer-resin/standalone/polymer-resin.html" />
+<script>
+  security.polymer_resin.install({
+    allowedIdentifierPrefixes: [''],
+    reportHandler(isViolation, fmt, ...args) {
+      const log = security.polymer_resin.CONSOLE_LOGGING_REPORT_HANDLER;
+      log(isViolation, fmt, ...args);
+      if (isViolation) {
+        // This will cause the test to fail if there is a data binding
+        // violation.
+        throw new Error(
+            'polymer-resin violation: ' + fmt
+            + JSON.stringify(args));
+      }
+    },
+  });
+</script>
+<script>
+  // eslint-disable-next-line no-unused-vars
+  const mockPromise = () => {
+    let res;
+    const promise = new Promise(resolve => {
+      res = resolve;
+    });
+    promise.resolve = res;
+    return promise;
+  };
+</script>
+<script>
+  (function() {
+    setup(() => {
+      if (!window.Gerrit) { return; }
+      Gerrit._pluginsPending = -1;
+      Gerrit._allPluginsPromise = undefined;
+      if (Gerrit._resetPlugins) {
+        Gerrit._resetPlugins();
+      }
+      if (Gerrit._endpoints) {
+        Gerrit._endpoints = new GrPluginEndpoints();
+      }
+    });
+  })();
+</script>
+<link rel="import"
+    href="../bower_components/iron-test-helpers/iron-test-helpers.html" />
+<link rel="import" href="test-router.html" />
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 4dcc9a8..cbc5cb0 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -21,40 +21,73 @@
 <script src="../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
 <script src="../bower_components/web-component-tester/browser.js"></script>
 <script>
-  var testFiles = [];
-  var elementsPath = '../elements/';
-  var behaviorsPath = '../behaviors/';
+  const testFiles = [];
+  const elementsPath = '../elements/';
+  const behaviorsPath = '../behaviors/';
 
   // Elements tests.
-  [
+  const elements = [
+    // This seemed to be flakey when it was farther down the list. Keep at the
+    // beginning.
+    'gr-app_test.html',
+    'admin/gr-access-section/gr-access-section_test.html',
+    'admin/gr-admin-group-list/gr-admin-group-list_test.html',
+    'admin/gr-admin-view/gr-admin-view_test.html',
+    'admin/gr-confirm-delete-item-dialog/gr-confirm-delete-item-dialog_test.html',
+    'admin/gr-create-change-dialog/gr-create-change-dialog_test.html',
+    'admin/gr-create-group-dialog/gr-create-group-dialog_test.html',
+    'admin/gr-create-pointer-dialog/gr-create-pointer-dialog_test.html',
+    'admin/gr-create-project-dialog/gr-create-project-dialog_test.html',
+    'admin/gr-group-audit-log/gr-group-audit-log_test.html',
+    'admin/gr-group-members/gr-group-members_test.html',
+    'admin/gr-group/gr-group_test.html',
+    'admin/gr-permission/gr-permission_test.html',
+    'admin/gr-plugin-list/gr-plugin-list_test.html',
+    'admin/gr-project-access/gr-project-access_test.html',
+    'admin/gr-project-commands/gr-project-commands_test.html',
+    'admin/gr-project-detail-list/gr-project-detail-list_test.html',
+    'admin/gr-project-list/gr-project-list_test.html',
+    'admin/gr-project/gr-project_test.html',
+    'admin/gr-rule-editor/gr-rule-editor_test.html',
     'change-list/gr-change-list-item/gr-change-list-item_test.html',
     'change-list/gr-change-list-view/gr-change-list-view_test.html',
     'change-list/gr-change-list/gr-change-list_test.html',
+    'change-list/gr-user-header/gr-user-header_test.html',
     'change/gr-account-entry/gr-account-entry_test.html',
     'change/gr-account-list/gr-account-list_test.html',
     'change/gr-change-actions/gr-change-actions_test.html',
+    'change/gr-change-metadata/gr-change-metadata-it_test.html',
     'change/gr-change-metadata/gr-change-metadata_test.html',
     'change/gr-change-view/gr-change-view_test.html',
     'change/gr-comment-list/gr-comment-list_test.html',
     'change/gr-commit-info/gr-commit-info_test.html',
+    'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
+    'change/gr-confirm-move-dialog/gr-confirm-move-dialog_test.html',
     'change/gr-confirm-rebase-dialog/gr-confirm-rebase-dialog_test.html',
     'change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html',
-    'change/gr-confirm-cherrypick-dialog/gr-confirm-cherrypick-dialog_test.html',
     'change/gr-download-dialog/gr-download-dialog_test.html',
+    'change/gr-file-list-header/gr-file-list-header_test.html',
     'change/gr-file-list/gr-file-list_test.html',
+    'change/gr-included-in-dialog/gr-included-in-dialog_test.html',
+    'change/gr-label-score-row/gr-label-score-row_test.html',
+    'change/gr-label-scores/gr-label-scores_test.html',
     'change/gr-message/gr-message_test.html',
     'change/gr-messages-list/gr-messages-list_test.html',
     'change/gr-related-changes-list/gr-related-changes-list_test.html',
+    'change/gr-reply-dialog/gr-reply-dialog-it_test.html',
     'change/gr-reply-dialog/gr-reply-dialog_test.html',
     'change/gr-reviewer-list/gr-reviewer-list_test.html',
     'core/gr-account-dropdown/gr-account-dropdown_test.html',
     'core/gr-error-manager/gr-error-manager_test.html',
     'core/gr-main-header/gr-main-header_test.html',
+    'core/gr-navigation/gr-navigation_test.html',
     'core/gr-reporting/gr-reporting_test.html',
+    'core/gr-router/gr-router_test.html',
     'core/gr-search-bar/gr-search-bar_test.html',
+    'diff/gr-comment-api/gr-comment-api_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
-    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html',
+    'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',
     'diff/gr-diff-comment/gr-diff-comment_test.html',
     'diff/gr-diff-cursor/gr-diff-cursor_test.html',
     'diff/gr-diff-highlight/gr-annotation_test.html',
@@ -70,7 +103,13 @@
     'diff/gr-selection-action-box/gr-selection-action-box_test.html',
     'diff/gr-syntax-layer/gr-syntax-layer_test.html',
     'diff/gr-syntax-lib-loader/gr-syntax-lib-loader_test.html',
-    'gr-app_test.html',
+    'plugins/gr-attribute-helper/gr-attribute-helper_test.html',
+    'plugins/gr-endpoint-decorator/gr-endpoint-decorator_test.html',
+    'plugins/gr-event-helper/gr-event-helper_test.html',
+    'plugins/gr-external-style/gr-external-style_test.html',
+    'plugins/gr-plugin-host/gr-plugin-host_test.html',
+    'plugins/gr-popup-interface/gr-plugin-popup_test.html',
+    'plugins/gr-popup-interface/gr-popup-interface_test.html',
     'settings/gr-account-info/gr-account-info_test.html',
     'settings/gr-change-table-editor/gr-change-table-editor_test.html',
     'settings/gr-email-editor/gr-email-editor_test.html',
@@ -84,46 +123,63 @@
     'shared/gr-account-label/gr-account-label_test.html',
     'shared/gr-account-link/gr-account-link_test.html',
     'shared/gr-alert/gr-alert_test.html',
+    'shared/gr-autocomplete-dropdown/gr-autocomplete-dropdown_test.html',
     'shared/gr-autocomplete/gr-autocomplete_test.html',
     'shared/gr-avatar/gr-avatar_test.html',
     'shared/gr-button/gr-button_test.html',
     'shared/gr-change-star/gr-change-star_test.html',
     'shared/gr-confirm-dialog/gr-confirm-dialog_test.html',
+    'shared/gr-copy-clipboard/gr-copy-clipboard_test.html',
     'shared/gr-cursor-manager/gr-cursor-manager_test.html',
     'shared/gr-date-formatter/gr-date-formatter_test.html',
+    'shared/gr-download-commands/gr-download-commands_test.html',
+    'shared/gr-dropdown-list/gr-dropdown-list_test.html',
     'shared/gr-editable-content/gr-editable-content_test.html',
     'shared/gr-editable-label/gr-editable-label_test.html',
     'shared/gr-formatted-text/gr-formatted-text_test.html',
     'shared/gr-js-api-interface/gr-change-actions-js-api_test.html',
     'shared/gr-js-api-interface/gr-change-reply-js-api_test.html',
     'shared/gr-js-api-interface/gr-js-api-interface_test.html',
+    'shared/gr-js-api-interface/gr-plugin-rest-api_test.html',
+    'shared/gr-limited-text/gr-limited-text_test.html',
     'shared/gr-linked-chip/gr-linked-chip_test.html',
     'shared/gr-linked-text/gr-linked-text_test.html',
+    'shared/gr-list-view/gr-list-view_test.html',
+    'shared/gr-page-nav/gr-page-nav_test.html',
+    'shared/gr-rest-api-interface/gr-auth_test.html',
     'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
     'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
     'shared/gr-select/gr-select_test.html',
     'shared/gr-storage/gr-storage_test.html',
-    'shared/gr-tooltip/gr-tooltip_test.html',
+    'shared/gr-textarea/gr-textarea_test.html',
     'shared/gr-tooltip-content/gr-tooltip-content_test.html',
-  ].forEach(function(file) {
+    'shared/gr-tooltip/gr-tooltip_test.html',
+  ];
+  for (let file of elements) {
     file = elementsPath + file;
     testFiles.push(file);
     testFiles.push(file + '?dom=shadow');
-  });
+  }
 
   // Behaviors tests.
-  [
+  const behaviors = [
+    'async-foreach-behavior/async-foreach-behavior_test.html',
     'base-url-behavior/base-url-behavior_test.html',
+    'docs-url-behavior/docs-url-behavior_test.html',
+    'keyboard-shortcut-behavior/keyboard-shortcut-behavior_test.html',
     'rest-client-behavior/rest-client-behavior_test.html',
+    'gr-access-behavior/gr-access-behavior_test.html',
+    'gr-anonymous-name-behavior/gr-anonymous-name-behavior_test.html',
     'gr-change-table-behavior/gr-change-table-behavior_test.html',
     'gr-patch-set-behavior/gr-patch-set-behavior_test.html',
     'gr-path-list-behavior/gr-path-list-behavior_test.html',
     'gr-tooltip-behavior/gr-tooltip-behavior_test.html',
-  ].forEach(function(file) {
+  ];
+  for (let file of behaviors) {
     // Behaviors do not utilize the DOM, so no shadow DOM test is necessary.
     file = behaviorsPath + file;
     testFiles.push(file);
-  });
+  }
 
   WCT.loadSuites(testFiles);
 </script>
diff --git a/polygerrit-ui/app/test/test-router.html b/polygerrit-ui/app/test/test-router.html
new file mode 100644
index 0000000..37a20c4
--- /dev/null
+++ b/polygerrit-ui/app/test/test-router.html
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<!--
+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.
+-->
+
+<link rel="import" href="../elements/core/gr-navigation/gr-navigation.html">
+<script>
+  Gerrit.Nav.setup(url => { /* noop */ }, params => '');
+</script>
diff --git a/polygerrit-ui/app/wct_test.sh b/polygerrit-ui/app/wct_test.sh
index 4f2d50a..a8394cd 100755
--- a/polygerrit-ui/app/wct_test.sh
+++ b/polygerrit-ui/app/wct_test.sh
@@ -49,10 +49,10 @@
         'sauce': {
           'disabled': true,
           'browsers': [
-            'OS X 10.11/chrome',
+            'OS X 10.12/chrome',
             'Windows 10/chrome',
             'Linux/firefox',
-            'OS X 10.11/safari',
+            'OS X 10.12/safari',
             'Windows 10/microsoftedge'
           ]
         }
diff --git a/polygerrit-ui/server.go b/polygerrit-ui/server.go
index b19137e..79cf4bf 100644
--- a/polygerrit-ui/server.go
+++ b/polygerrit-ui/server.go
@@ -17,9 +17,11 @@
 import (
 	"bufio"
 	"compress/gzip"
+	"encoding/json"
 	"errors"
 	"flag"
 	"io"
+	"io/ioutil"
 	"log"
 	"net"
 	"net/http"
@@ -53,14 +55,22 @@
 	if len(*plugins) > 0 {
 		http.Handle("/plugins/", http.StripPrefix("/plugins/",
 			http.FileServer(http.Dir(*plugins))))
-		log.Println("Local plugins at", *plugins)
+		log.Println("Local plugins from", *plugins)
+	} else {
+		http.HandleFunc("/plugins/", handleRESTProxy)
 	}
 	log.Println("Serving on port", *port)
 	log.Fatal(http.ListenAndServe(*port, &server{}))
 }
 
 func handleRESTProxy(w http.ResponseWriter, r *http.Request) {
-	w.Header().Set("Content-Type", "application/json")
+	if strings.HasSuffix(r.URL.Path, ".html") {
+		w.Header().Set("Content-Type", "text/html")
+	} else if strings.HasSuffix(r.URL.Path, ".css") {
+		w.Header().Set("Content-Type", "text/css")
+	} else {
+		w.Header().Set("Content-Type", "application/json")
+	}
 	req := &http.Request{
 		Method: "GET",
 		URL: &url.URL{
@@ -77,12 +87,89 @@
 	}
 	defer res.Body.Close()
 	w.WriteHeader(res.StatusCode)
-	if _, err := io.Copy(w, res.Body); err != nil {
+	if _, err := io.Copy(w, patchResponse(r, res)); err != nil {
 		log.Println("Error copying response to ResponseWriter:", err)
 		return
 	}
 }
 
+func getJsonPropByPath(json map[string]interface{}, path []string) interface{} {
+	prop, path := path[0], path[1:]
+	if json[prop] == nil {
+		return nil
+	}
+	switch json[prop].(type) {
+	case map[string]interface{}: // map
+		return getJsonPropByPath(json[prop].(map[string]interface{}), path)
+	case []interface{}: // array
+		return json[prop].([]interface{})
+	default:
+		return json[prop].(interface{})
+	}
+}
+
+func setJsonPropByPath(json map[string]interface{}, path []string, value interface{}) {
+	prop, path := path[0], path[1:]
+	if json[prop] == nil {
+		return // path not found
+	}
+	if len(path) > 0 {
+		setJsonPropByPath(json[prop].(map[string]interface{}), path, value)
+	} else {
+		json[prop] = value
+	}
+}
+
+func patchResponse(r *http.Request, res *http.Response) io.Reader {
+	switch r.URL.EscapedPath() {
+	case "/config/server/info":
+		return injectLocalPlugins(res.Body)
+	default:
+		return res.Body
+	}
+}
+
+func injectLocalPlugins(r io.Reader) io.Reader {
+	if len(*plugins) == 0 {
+		return r
+	}
+	// Skip escape prefix
+	io.CopyN(ioutil.Discard, r, 5)
+	dec := json.NewDecoder(r)
+
+	var response map[string]interface{}
+	err := dec.Decode(&response)
+	if err != nil {
+		log.Fatal(err)
+	}
+
+	// Configuration path in the JSON server response
+	pluginsPath := []string{"plugin", "html_resource_paths"}
+
+	htmlResources := getJsonPropByPath(response, pluginsPath).([]interface{})
+	files, err := ioutil.ReadDir(*plugins)
+	if err != nil {
+		log.Fatal(err)
+	}
+	for _, f := range files {
+		if strings.HasSuffix(f.Name(), ".html") {
+			htmlResources = append(htmlResources, "plugins/"+f.Name())
+		}
+	}
+	setJsonPropByPath(response, pluginsPath, htmlResources)
+
+	reader, writer := io.Pipe()
+	go func() {
+		defer writer.Close()
+		io.WriteString(writer, ")]}'") // Write escape prefix
+		err := json.NewEncoder(writer).Encode(&response)
+		if err != nil {
+			log.Fatal(err)
+		}
+	}()
+	return reader
+}
+
 func handleAccountDetail(w http.ResponseWriter, r *http.Request) {
 	http.Error(w, http.StatusText(http.StatusForbidden), http.StatusForbidden)
 }
@@ -113,7 +200,7 @@
 
 // Any path prefixes that should resolve to index.html.
 var (
-	fePaths    = []string{"/q/", "/c/", "/dashboard/"}
+	fePaths    = []string{"/q/", "/c/", "/dashboard/", "/admin/"}
 	issueNumRE = regexp.MustCompile(`^\/\d+\/?$`)
 )
 
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
index 42b3d9a..9a3e38f 100644
--- a/tools/bzl/asciidoc.bzl
+++ b/tools/bzl/asciidoc.bzl
@@ -1,6 +1,6 @@
 def documentation_attributes():
     return [
-        "toc",
+        "toc2",
         'newline="\\n"',
         'asterisk="&#42;"',
         'plus="&#43;"',
diff --git a/tools/bzl/gwt.bzl b/tools/bzl/gwt.bzl
index b94ea51..b0e5697 100644
--- a/tools/bzl/gwt.bzl
+++ b/tools/bzl/gwt.bzl
@@ -52,10 +52,6 @@
     "-XdisableCastChecking",
 ]
 
-PLUGIN_DEPS_NEVERLINK = [
-    "//gerrit-plugin-api:lib-neverlink",
-]
-
 GWT_PLUGIN_DEPS_NEVERLINK = [
     "//gerrit-plugin-gwtui:gwtui-api-lib-neverlink",
     "//lib/gwt:user-neverlink",
diff --git a/tools/bzl/js.bzl b/tools/bzl/js.bzl
index 11f17ba..a4f43fe 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -1,19 +1,9 @@
+load("//lib/js:npm.bzl", "NPM_SHA1S", "NPM_VERSIONS")
+
 NPMJS = "NPMJS"
 
 GERRIT = "GERRIT:"
 
-NPM_VERSIONS = {
-    "bower": "1.8.2",
-    "crisper": "2.0.2",
-    "vulcanize": "1.14.8",
-}
-
-NPM_SHA1S = {
-    "bower": "adf53529c8d4af02ef24fb8d5341c1419d33e2f7",
-    "crisper": "7183c58cea33632fb036c91cefd1b43e390d22a2",
-    "vulcanize": "679107f251c19ab7539529b1e3fdd40829e6fc63",
-}
-
 def _npm_tarball(name):
     return "%s@%s.npm_binary.tgz" % (name, NPM_VERSIONS[name])
 
@@ -21,7 +11,7 @@
     """rule to download a NPM archive."""
     name = ctx.name
     version = NPM_VERSIONS[name]
-    sha1 = NPM_VERSIONS[name]
+    sha1 = NPM_SHA1S[name]
 
     dir = "%s-%s" % (name, version)
     filename = "%s.tgz" % dir
@@ -38,7 +28,6 @@
     python = ctx.which("python")
     script = ctx.path(ctx.attr._download_script)
 
-    sha1 = NPM_SHA1S[name]
     args = [python, script, "-o", dest, "-u", url, "-v", sha1]
     out = ctx.execute(args)
     if out.return_code:
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index 939daf8..84ec1c1 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -6,11 +6,11 @@
     "GWT_PLUGIN_DEPS",
     "GWT_PLUGIN_DEPS_NEVERLINK",
     "GWT_TRANSITIVE_DEPS",
-    "PLUGIN_DEPS_NEVERLINK",
     "gwt_binary",
 )
 
 PLUGIN_DEPS = ["//gerrit-plugin-api:lib"]
+PLUGIN_DEPS_NEVERLINK = ["//gerrit-plugin-api:lib-neverlink"]
 
 PLUGIN_TEST_DEPS = [
     "//gerrit-acceptance-framework:lib",
@@ -27,6 +27,7 @@
         gwt_module = [],
         resources = [],
         manifest_entries = [],
+        dir_name = None,
         target_suffix = "",
         **kwargs):
     native.java_library(
@@ -42,6 +43,9 @@
     if gwt_module:
         static_jars = [":%s-static" % name]
 
+    if not dir_name:
+        dir_name = name
+
     native.java_binary(
         name = "%s__non_stamped" % name,
         deploy_manifest_lines = manifest_entries + ["Gerrit-ApiType: plugin"],
@@ -88,7 +92,7 @@
         stamp = 1,
         srcs = ["%s__non_stamped_deploy.jar" % name],
         cmd = " && ".join([
-            "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep -w STABLE_BUILD_%s_LABEL | cut -d ' ' -f 2)" % name.upper(),
+            "GEN_VERSION=$$(cat bazel-out/stable-status.txt | grep -w STABLE_BUILD_%s_LABEL | cut -d ' ' -f 2)" % dir_name.upper(),
             "cd $$TMP",
             "unzip -q $$ROOT/$<",
             "echo \"Implementation-Version: $$GEN_VERSION\n$$(cat META-INF/MANIFEST.MF)\" > META-INF/MANIFEST.MF",
diff --git a/tools/checkstyle.xml b/tools/checkstyle.xml
deleted file mode 100644
index cb24e8f..0000000
--- a/tools/checkstyle.xml
+++ /dev/null
@@ -1,114 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<!DOCTYPE module PUBLIC "-//Puppy Crawl//DTD Check Configuration 1.3//EN" "http://www.puppycrawl.com/dtds/configuration_1_3.dtd">
-
-<!--
-    This configuration file was written by the eclipse-cs plugin configuration editor
--->
-<!--
-    Checkstyle-Configuration: Google Checks for Gerrit
-    Description:
-Checkstyle configuration based on the Google coding conventions (https://google-styleguide.googlecode.com/svn-history/r130/trunk/javaguide.html),
-edited to remove noisy warnings.
--->
-<module name="Checker">
-  <property name="severity" value="warning"/>
-  <property name="charset" value="UTF-8"/>
-  <module name="TreeWalker">
-    <module name="FileContentsHolder"/>
-    <module name="OuterTypeFilename"/>
-    <module name="LineLength">
-      <property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
-      <property name="max" value="150"/>
-      <property name="tabWidth" value="2"/>
-    </module>
-    <module name="OneTopLevelClass"/>
-    <module name="NoLineWrap"/>
-    <module name="EmptyBlock">
-      <property name="option" value="TEXT"/>
-      <property name="tokens" value="LITERAL_TRY, LITERAL_CATCH, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
-    </module>
-    <module name="NeedBraces"/>
-    <module name="LeftCurly">
-      <property name="maxLineLength" value="150"/>
-    </module>
-    <module name="RightCurly">
-      <property name="option" value="alone"/>
-      <property name="tokens" value="CLASS_DEF, METHOD_DEF, CTOR_DEF, LITERAL_FOR, LITERAL_WHILE, LITERAL_DO, STATIC_INIT, INSTANCE_INIT"/>
-    </module>
-    <module name="WhitespaceAround">
-      <property name="severity" value="ignore"/>
-      <property name="allowEmptyConstructors" value="true"/>
-      <property name="allowEmptyMethods" value="true"/>
-      <property name="allowEmptyTypes" value="true"/>
-      <property name="allowEmptyLoops" value="true"/>
-      <message key="ws.notFollowed" value="WhitespaceAround: ''{0}'' is not followed by whitespace."/>
-      <message key="ws.notPreceded" value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
-      <metadata name="net.sf.eclipsecs.core.lastEnabledSeverity" value="inherit"/>
-    </module>
-    <module name="OneStatementPerLine"/>
-    <module name="MultipleVariableDeclarations"/>
-    <module name="ArrayTypeStyle"/>
-    <module name="UpperEll"/>
-    <module name="ModifierOrder"/>
-    <module name="EmptyLineSeparator">
-      <property name="severity" value="ignore"/>
-      <property name="allowNoEmptyLineBetweenFields" value="true"/>
-      <metadata name="net.sf.eclipsecs.core.lastEnabledSeverity" value="inherit"/>
-    </module>
-    <module name="SeparatorWrap">
-      <property name="severity" value="ignore"/>
-      <property name="option" value="nl"/>
-      <property name="tokens" value="DOT"/>
-      <metadata name="net.sf.eclipsecs.core.lastEnabledSeverity" value="inherit"/>
-    </module>
-    <module name="SeparatorWrap">
-      <property name="severity" value="ignore"/>
-      <property name="option" value="EOL"/>
-      <property name="tokens" value="COMMA"/>
-      <metadata name="net.sf.eclipsecs.core.lastEnabledSeverity" value="inherit"/>
-    </module>
-    <module name="NoFinalizer"/>
-    <module name="GenericWhitespace">
-      <property name="severity" value="ignore"/>
-      <message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
-      <message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
-      <message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
-      <message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
-      <metadata name="net.sf.eclipsecs.core.lastEnabledSeverity" value="inherit"/>
-    </module>
-    <module name="Indentation">
-      <property name="severity" value="ignore"/>
-      <property name="basicOffset" value="2"/>
-      <property name="caseIndent" value="2"/>
-      <property name="arrayInitIndent" value="2"/>
-      <metadata name="net.sf.eclipsecs.core.lastEnabledSeverity" value="inherit"/>
-    </module>
-    <module name="MethodParamPad">
-      <property name="severity" value="ignore"/>
-      <metadata name="net.sf.eclipsecs.core.lastEnabledSeverity" value="inherit"/>
-    </module>
-    <module name="OperatorWrap">
-      <property name="severity" value="ignore"/>
-      <property name="option" value="NL"/>
-      <property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR "/>
-      <metadata name="net.sf.eclipsecs.core.lastEnabledSeverity" value="inherit"/>
-    </module>
-    <module name="RedundantImport"/>
-    <module name="RedundantModifier"/>
-    <module name="ExplicitInitialization"/>
-    <module name="ArrayTrailingComma"/>
-  </module>
-  <module name="FileTabCharacter">
-    <property name="severity" value="ignore"/>
-    <property name="eachLine" value="true"/>
-    <metadata name="net.sf.eclipsecs.core.lastEnabledSeverity" value="inherit"/>
-  </module>
-  <module name="SuppressWithNearbyCommentFilter">
-    <property name="commentFormat" value="CS IGNORE (\w+) FOR NEXT (\d+) LINES\. REASON\: \w+"/>
-    <property name="checkFormat" value="$1"/>
-    <property name="influenceFormat" value="$2"/>
-  </module>
-  <module name="SuppressionFilter">
-    <property name="file" value="${samedir}/checkstyle_suppressions.xml"/>
-  </module>
-</module>
diff --git a/tools/checkstyle_suppressions.xml b/tools/checkstyle_suppressions.xml
deleted file mode 100644
index 5f5d9ee..0000000
--- a/tools/checkstyle_suppressions.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-<?xml version="1.0"?>
-
-<!DOCTYPE suppressions PUBLIC
-  "-//Puppy Crawl//DTD Suppressions 1.1//EN"
-  "http://www.puppycrawl.com/dtds/suppressions_1_1.dtd">
-<suppressions>
-  <suppress files="[/\\].apt_generated[/\\]" checks=".*"/>
-</suppressions>
diff --git a/tools/coverage.sh b/tools/coverage.sh
new file mode 100755
index 0000000..8fa979f
--- /dev/null
+++ b/tools/coverage.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+#
+# Usage
+#
+#   COVERAGE_CPUS=32 tools/coverage.sh [/path/to/report-directory/]
+#
+# COVERAGE_CPUS defaults to 2, and the default destination is a temp
+# dir.
+
+genhtml=$(which genhtml)
+if [[ -z "${genhtml}" ]]; then
+    echo "Install 'genhtml' (contained in the 'lcov' package)"
+    exit 1
+fi
+
+destdir="$1"
+if [[ -z "${destdir}" ]]; then
+    destdir=$(mktemp -d /tmp/gerritcov.XXXXXX)
+fi
+
+echo "Running 'bazel coverage'; this may take a while"
+
+# coverage is expensive to run; use --jobs=2 to avoid overloading the
+# machine.
+bazel coverage -k --jobs=${COVERAGE_CPUS:-2} -- ... -//gerrit-common:auto_value_tests
+
+# The coverage data contains filenames relative to the Java root, and
+# genhtml has no logic to search these elsewhere. Workaround this
+# limitation by running genhtml in a directory with the files in the
+# right place. Also -inexplicably- genhtml wants to have the source
+# files relative to the output directory.
+mkdir -p ${destdir}/
+cp -a */src/{main,test}/java/* ${destdir}/
+
+base=$(bazel info bazel-testlogs)
+for f in $(find ${base}  -name 'coverage.dat') ; do
+  cp $f ${destdir}/$(echo $f| sed "s|${base}/||" | sed "s|/|_|g")
+done
+
+cd ${destdir}
+find -name '*coverage.dat' -size 0 -delete
+
+genhtml -o . --ignore-errors source *coverage.dat
+
+echo "coverage report at file://${destdir}/index.html"
diff --git a/tools/download_file.py b/tools/download_file.py
index b705c11..26671f0 100755
--- a/tools/download_file.py
+++ b/tools/download_file.py
@@ -84,7 +84,7 @@
 args, _ = opts.parse_args()
 
 root_dir = args.o
-while root_dir and root_dir != "/":
+while root_dir and path.dirname(root_dir) != root_dir:
   root_dir, n = path.split(root_dir)
   if n == 'WORKSPACE':
     break
diff --git a/tools/eclipse/BUILD b/tools/eclipse/BUILD
index e9eb1c7..356dfbb 100644
--- a/tools/eclipse/BUILD
+++ b/tools/eclipse/BUILD
@@ -8,9 +8,11 @@
 )
 
 TEST_DEPS = [
+    "//gerrit-elasticsearch:elasticsearch_test_utils",
     "//gerrit-gpg:gpg_tests",
     "//gerrit-gwtui:ui_tests",
     "//gerrit-httpd:httpd_tests",
+    "//gerrit-index:index_tests",
     "//gerrit-patch-jgit:jgit_patch_tests",
     "//gerrit-reviewdb:client_tests",
     "//gerrit-server:server_tests",
diff --git a/tools/eclipse/project.py b/tools/eclipse/project.py
index 833474a1..c9f382b 100755
--- a/tools/eclipse/project.py
+++ b/tools/eclipse/project.py
@@ -154,7 +154,7 @@
       src.add(m.group(1))
       # Exceptions: both source and lib
       if p.endswith('libquery_parser.jar') or \
-         p.endswith('prolog/libcommon.jar'):
+         p.endswith('libprolog-common.jar'):
         lib.add(p)
       # JGit dependency from external repository
       if 'gerrit-' not in p and 'jgit' in p:
diff --git a/tools/js/bower2bazel.py b/tools/js/bower2bazel.py
index 0415e26..21dea94 100755
--- a/tools/js/bower2bazel.py
+++ b/tools/js/bower2bazel.py
@@ -35,6 +35,7 @@
 package_licenses = {
   "es6-promise": "es6-promise",
   "fetch": "fetch",
+  "font-roboto": "polymer",
   "iron-a11y-announcer": "polymer",
   "iron-a11y-keys-behavior": "polymer",
   "iron-autogrow-textarea": "polymer",
@@ -43,7 +44,10 @@
   "iron-fit-behavior": "polymer",
   "iron-flex-layout": "polymer",
   "iron-form-element-behavior": "polymer",
+  "iron-icon": "polymer",
+  "iron-iconset-svg": "polymer",
   "iron-input": "polymer",
+  "iron-menu-behavior": "polymer",
   "iron-meta": "polymer",
   "iron-overlay-behavior": "polymer",
   "iron-resizable-behavior": "polymer",
@@ -52,10 +56,22 @@
   "moment": "moment",
   "neon-animation": "polymer",
   "page": "page.js",
+  "paper-button": "polymer",
+  "paper-input": "polymer",
+  "paper-item": "polymer",
+  "paper-listbox": "polymer",
+  "paper-styles": "polymer",
   "polymer": "polymer",
+  "polymer-resin": "polymer",
   "promise-polyfill": "promise-polyfill",
   "web-animations-js": "Apache2.0",
   "webcomponentsjs": "polymer",
+  "paper-material": "polymer",
+  "paper-styles": "polymer",
+  "paper-behaviors": "polymer",
+  "paper-ripple": "polymer",
+  "iron-checked-element-behavior": "polymer",
+  "font-roboto": "polymer",
 }
 
 
@@ -219,15 +235,7 @@
     license = package_licenses.get(pkg_name, "DO_NOT_DISTRIBUTE")
 
     pkg["bazel-license"] = license
-
-    # TODO(hanwen): bower packages can also have 'fully qualified'
-    # names, ("PolymerElements/iron-ajax") as well as short names
-    # ("iron-ajax").  It is possible for bower.json files to refer to
-    # long names as their dependencies. If any package does this, we
-    # will have to either 1) strip off the prefix (typically github
-    # user?), or 2) build a map of short name <=> fully qualified
-    # name. For now, we just ignore the problem.
-    pkg["normalized-name"] = pkg["name"]
+    pkg["normalized-name"] = pkg["_originalSource"]
     data.append(pkg)
 
   dump_workspace(data, seeds, ws_out)
diff --git a/tools/merge_jars.py b/tools/merge_jars.py
index 89e83ca..97a87c4 100755
--- a/tools/merge_jars.py
+++ b/tools/merge_jars.py
@@ -17,7 +17,7 @@
 import collections
 import sys
 import zipfile
-
+import io
 
 if len(sys.argv) < 3:
   print('usage: %s <out.zip> <in.zip>...' % sys.argv[0], file=sys.stderr)
diff --git a/tools/release-announcement-template.txt b/tools/release-announcement-template.txt
new file mode 100644
index 0000000..87f5d49
--- /dev/null
+++ b/tools/release-announcement-template.txt
@@ -0,0 +1,26 @@
+Gerrit version {{ data.version }} is now available.{% if data.summary %} {{ data.summary }} {% endif %}Please see the release notes for details.
+
+Release Notes:
+https://www.gerritcodereview.com/releases/{{ data.version.major }}.md{% if data.version.patch %}#{{ data.version.patch }}{% endif %}
+
+Documentation:
+http://gerrit-documentation.storage.googleapis.com/Documentation/{{ data.version }}/index.html
+{% if data.previous %}
+Log of changes since {{ data.previous }}:
+https://gerrit.googlesource.com/gerrit/+log/v{{ data.previous }}..v{{ data.version }}
+{% endif %}
+Download:
+https://gerrit-releases.storage.googleapis.com/gerrit-{{ data.version }}.war
+
+SHA1:
+{{ data.sha1 }}
+
+SHA256:
+{{ data.sha256 }}
+
+MD5:
+{{ data.md5 }}
+
+Maintainers' public keys:
+https://www.gerritcodereview.com/releases/public-keys.md
+
diff --git a/tools/release-announcement.py b/tools/release-announcement.py
new file mode 100755
index 0000000..f700185
--- /dev/null
+++ b/tools/release-announcement.py
@@ -0,0 +1,156 @@
+#!/usr/bin/env python
+# 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.
+
+# Generates the text to paste into the email for announcing a new
+# release of Gerrit. The text is generated based on a template that
+# is filled with values either passed to the script or calculated
+# at runtime.
+#
+# The script outputs a plain text file with the announcement text:
+#
+#   release-announcement-gerrit-X.Y.txt
+#
+# and, if GPG is available, the announcement text wrapped with a
+# signature:
+#
+#   release-announcement-gerrit-X.Y.txt.asc
+#
+# Usage:
+#
+#   ./tools/release-announcement.py -v 2.14.2 -p 2.14.1 \
+#      -s "This release fixes several bugs since 2.14.1"
+#
+# Parameters:
+#
+#   --version (-v): The version of Gerrit being released.
+#
+#   --previous (-p): The previous version of Gerrit.  Optional. If
+#   specified, the generated text includes a link to the gitiles
+#   log of commits between the previous and new versions.
+#
+#   --summary (-s): Short summary of the release. Optional. When
+#   specified, the summary is inserted in the introductory sentence
+#   of the generated text.
+#
+# Prerequisites:
+#
+# - The Jinja2 python library [1] must be installed.
+#
+# - For GPG signing to work, the python-gnupg library [2] must be
+#   installed, and the ~/.gnupg folder must exist.
+#
+# - The war file must have been installed to the local Maven repository
+#   using the `./tools/mvn/api.sh war_install` command.
+#
+# [1] http://jinja.pocoo.org/
+# [2] http://pythonhosted.org/gnupg/
+
+
+from __future__ import print_function
+import argparse
+import hashlib
+import os
+import sys
+from gnupg import GPG
+from jinja2 import Template
+
+
+class Version:
+    def __init__(self, version):
+        self.version = version
+        parts = version.split('.')
+        if len(parts) > 2:
+            self.major = ".".join(parts[:2])
+            self.patch = version
+        else:
+            self.major = version
+            self.patch = None
+
+    def __str__(self):
+        return self.version
+
+
+def _main():
+    descr = 'Generate Gerrit release announcement email text'
+    parser = argparse.ArgumentParser(
+        description=descr,
+        formatter_class=argparse.ArgumentDefaultsHelpFormatter)
+    parser.add_argument('-v', '--version', dest='version',
+                        required=True,
+                        help='gerrit version to release')
+    parser.add_argument('-p', '--previous', dest='previous',
+                        help='previous gerrit version (optional)')
+    parser.add_argument('-s', '--summary', dest='summary',
+                        help='summary of the release content (optional)')
+    options = parser.parse_args()
+
+    summary = options.summary
+    if summary and not summary.endswith("."):
+        summary = summary + "."
+
+    data = {
+         "version": Version(options.version),
+         "previous": options.previous,
+         "summary": summary
+    }
+
+    war = os.path.join(
+        os.path.expanduser("~/.m2/repository/com/google/gerrit/gerrit-war/"),
+        "%(version)s/gerrit-war-%(version)s.war" % data)
+    if not os.path.isfile(war):
+        print("Could not find war file for Gerrit %s in local Maven repository"
+              % data["version"], file=sys.stderr)
+        sys.exit(1)
+
+    md5 = hashlib.md5()
+    sha1 = hashlib.sha1()
+    sha256 = hashlib.sha256()
+    BUF_SIZE = 65536  # Read data in 64kb chunks
+    with open(war, 'rb') as f:
+        while True:
+            d = f.read(BUF_SIZE)
+            if not d:
+                break
+            md5.update(d)
+            sha1.update(d)
+            sha256.update(d)
+
+    data["sha1"] = sha1.hexdigest()
+    data["sha256"] = sha256.hexdigest()
+    data["md5"] = md5.hexdigest()
+
+    template = Template(open("tools/release-announcement-template.txt").read())
+    output = template.render(data=data)
+
+    filename = "release-announcement-gerrit-%s.txt" % data["version"]
+    with open(filename, "w") as f:
+        f.write(output)
+
+    gpghome = os.path.abspath(os.path.expanduser("~/.gnupg"))
+    if not os.path.isdir(gpghome):
+        print("Skipping signing due to missing gnupg home folder")
+    else:
+        try:
+            gpg = GPG(homedir=gpghome)
+        except TypeError:
+            gpg = GPG(gnupghome=gpghome)
+        signed = gpg.sign(output)
+        filename = filename + ".asc"
+        with open(filename, "w") as f:
+            f.write(str(signed))
+
+
+if __name__ == "__main__":
+    _main()
diff --git a/tools/version.py b/tools/version.py
index 2603829..fed6d5d 100755
--- a/tools/version.py
+++ b/tools/version.py
@@ -53,7 +53,3 @@
 
 src_pattern = re.compile(r'^(GERRIT_VERSION = ")([-.\w]+)(")$', re.MULTILINE)
 replace_in_file('version.bzl', src_pattern)
-
-src_pattern = re.compile(r'^(\s*-DarchetypeVersion=)([-.\w]+)(\s*\\)$',
-                         re.MULTILINE)
-replace_in_file(os.path.join('Documentation', 'dev-plugins.txt'), src_pattern)
diff --git a/tools/workspace-status.sh b/tools/workspace-status.sh
index 4c8b7f6..af6e180 100755
--- a/tools/workspace-status.sh
+++ b/tools/workspace-status.sh
@@ -17,6 +17,6 @@
 echo STABLE_BUILD_GERRIT_LABEL $(rev .)
 for p in plugins/* ; do
   test -d "$p" || continue
-  echo STABLE_BUILD_$(echo $(basename $p)_LABEL|tr '[a-z]' '[A-Z]' ) $(rev $p)
+  echo STABLE_BUILD_$(echo $(basename $p)_LABEL|tr '[a-z]' '[A-Z]' ) $(rev $p || echo unknown)
 done
 echo "STABLE_WORKSPACE_ROOT ${PWD}"
diff --git a/version.bzl b/version.bzl
index 86b6be5..5062e30 100644
--- a/version.bzl
+++ b/version.bzl
@@ -2,4 +2,4 @@
 # Used by :api_install and :api_deploy targets
 # when talking to the destination repository.
 #
-GERRIT_VERSION = "2.14.18"
+GERRIT_VERSION = "2.15.8-SNAPSHOT"